WebOrbiton
v3.0.0.0

FlatlyPage

2,772 lines · 143.8 KB
  1. const FLATLY_BASE_URL = window.FLATLY_BASE || '';
  2. ​
  3. function flatlyUrl(path) {
  4. if (!FLATLY_BASE_URL || typeof path !== 'string' || path[0] !== '/' || path[1] === '/') return path;
  5. if (path === FLATLY_BASE_URL || ['/', '?', '#'].some(sep => path.startsWith(FLATLY_BASE_URL + sep))) return path;
  6. return FLATLY_BASE_URL + path;
  7. }
  8. ​
  9. function flatlyRewriteHtml(html) {
  10. if (!FLATLY_BASE_URL) return html;
  11. return html
  12. .replace(/(\s(?:href|src|action|poster|formaction|data-src)\s*=\s*)(["'])(\/(?!\/)[^"']*)\2/gi, (m, attr, quote, path) => attr + quote + flatlyUrl(path) + quote)
  13. .replace(/url\(\s*(["']?)(\/(?!\/)[^)"']*)\1\s*\)/gi, (m, quote, path) => 'url(' + quote + flatlyUrl(path) + quote + ')');
  14. }
  15. ​
  16. const ICONS = {
  17. bolt: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
  18. layout: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>',
  19. clock: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
  20. shield: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
  21. chart: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>',
  22. code: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
  23. star: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
  24. heart: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
  25. globe: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
  26. users: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
  27. settings: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
  28. edit: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
  29. plus: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
  30. trash: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><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"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>',
  31. copy: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
  32. eye: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
  33. eyeOff: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><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.94"/><path d="M9.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.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>',
  34. image: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
  35. file: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
  36. upload: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>',
  37. money: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>',
  38. };
  39. ​
  40. let draggedBlock = null;
  41. let currentLinkCallback = null;
  42. let previewTimeout = null;
  43. let expandedBlockIndices = new Set();
  44. ​
  45. ​
  46. function setPreviewDevice(device) {
  47. const wrapper = document.getElementById('previewFrameWrapper');
  48. const buttons = document.querySelectorAll('.preview-device-btn');
  49. ​
  50. if (!wrapper) return;
  51. ​
  52. buttons.forEach(btn => {
  53. btn.classList.remove('active');
  54. if (btn.dataset.device === device) {
  55. btn.classList.add('active');
  56. }
  57. });
  58. ​
  59. wrapper.classList.remove('desktop', 'tablet', 'mobile');
  60. if (device !== 'desktop') {
  61. wrapper.classList.add(device);
  62. }
  63. }
  64. ​
  65. function refreshPreview() {
  66. const iframe = document.getElementById('previewFrame');
  67. if (iframe) {
  68. const wrapper = document.getElementById('previewFrameWrapper');
  69. if (wrapper) wrapper.classList.add('loading');
  70. ​
  71. iframe.src = iframe.src;
  72. ​
  73. iframe.onload = function () {
  74. if (wrapper) wrapper.classList.remove('loading');
  75. };
  76. }
  77. }
  78. ​
  79. function schedulePreviewUpdate() {
  80. if (previewTimeout) {
  81. clearTimeout(previewTimeout);
  82. }
  83. previewTimeout = setTimeout(function () {
  84. updateLivePreview();
  85. }, 1000);
  86. }
  87. ​
  88. function escapeHtml(text) {
  89. if (!text) return '';
  90. const div = document.createElement('div');
  91. div.textContent = text;
  92. return div.innerHTML;
  93. }
  94. ​
  95. function renderPreviewIcon(icon) {
  96. return ICONS[icon] || ICONS['bolt'];
  97. }
  98. ​
  99. function renderPreviewBlock(block) {
  100. const type = block.type || '';
  101. const data = block.data || {};
  102. const id = block.id ? ` id="${escapeHtml(block.id)}"` : '';
  103. ​
  104. switch (type) {
  105. case 'hero': return renderPreviewHero(data, id);
  106. case 'blog-hero': return renderPreviewBlogHero(data, id);
  107. case 'stats': return renderPreviewStats(data, id);
  108. case 'features': return renderPreviewFeatures(data, id);
  109. case 'testimonials': return renderPreviewTestimonials(data, id);
  110. case 'pricing': return renderPreviewPricing(data, id);
  111. case 'cta': return renderPreviewCta(data, id);
  112. case 'text': return renderPreviewText(data, id);
  113. case 'image': return renderPreviewImage(data, id);
  114. case 'image-text': return renderPreviewImageText(data, id);
  115. case 'product-cards': return renderPreviewProductCards(data, id);
  116. case 'video': return renderPreviewVideo(data, id);
  117. case 'gallery': return renderPreviewGallery(data, id);
  118. case 'faq': return renderPreviewFaq(data, id);
  119. case 'team': return renderPreviewTeam(data, id);
  120. case 'audio': return renderPreviewAudio(data, id);
  121. case 'countdown': return renderPreviewCountdown(data, id);
  122. case 'newsletter': return renderPreviewNewsletter(data, id);
  123. case 'html': return data.html || '';
  124. default: return '';
  125. }
  126. }
  127. ​
  128. function renderPreviewHero(d, id) {
  129. return `<section class="hero"${id}>
  130. <div class="hero-grid"></div>
  131. <div class="container hero-content">
  132. ${d.badge ? `<div class="badge"><span class="badge-dot"></span><span>${escapeHtml(d.badge)}</span></div>` : ''}
  133. <h1>${escapeHtml(d.title || '')}</h1>
  134. <p>${escapeHtml(d.subtitle || '')}</p>
  135. <div class="hero-buttons">
  136. ${d.button_primary ? `<a href="${escapeHtml(d.button_primary_url || '#')}" class="btn btn-primary">${escapeHtml(d.button_primary)}</a>` : ''}
  137. ${d.button_secondary ? `<a href="${escapeHtml(d.button_secondary_url || '#')}" class="btn btn-outline">${escapeHtml(d.button_secondary)}</a>` : ''}
  138. </div>
  139. </div>
  140. </section>`;
  141. }
  142. ​
  143. function renderPreviewBlogHero(d, id) {
  144. const anchor = id ? ` id="${id}"` : '';
  145. const title = escapeHtml(d.title || '');
  146. const content = escapeHtml(d.content || '');
  147. const image = d.image ? escapeHtml(d.image) : null;
  148. ​
  149. return `
  150. <section class="blog-hero-section"${anchor}>
  151. <div class="container">
  152. ${image ? `
  153. <div class="hero-image-wrapper" style="margin-bottom: 20px;">
  154. <img src="${image}" alt="${title}" style="max-width: 100%; height: auto; border-radius: 8px;">
  155. </div>` : ''}
  156. <div class="container">
  157. ${title ? `<h1 style="margin-bottom: 15px;" class="blog-h1">${title}</h1>` : ''}
  158. ${content ? `
  159. <div class="text-content" style="font-size: 1.1rem; line-height: 1.6; opacity: 0.9;">
  160. ${content.replace(/\n/g, '<br>')}
  161. </div>` : ''}
  162. </div>
  163. </div>
  164. </section>`;
  165. }
  166. ​
  167. function renderPreviewStats(d, id) {
  168. const items = d.items || [];
  169. if (!items.length) return '';
  170. return `<section class="stats"${id}>
  171. <div class="container">
  172. <div class="stats-grid">
  173. ${items.map(item => `<div class="stat-item"><h3>${escapeHtml(item.value || '')}</h3><p>${escapeHtml(item.label || '')}</p></div>`).join('')}
  174. </div>
  175. </div>
  176. </section>`;
  177. }
  178. ​
  179. function renderPreviewFeatures(d, id) {
  180. const items = d.items || [];
  181. return `<section class="features"${id}>
  182. <div class="container">
  183. ${d.title ? `<div class="section-header"><h2>${escapeHtml(d.title)}</h2>${d.subtitle ? `<p>${escapeHtml(d.subtitle)}</p>` : ''}</div>` : ''}
  184. ${items.length ? `<div class="features-grid">
  185. ${items.map(item => `<div class="feature-card">
  186. <div class="feature-icon">${renderPreviewIcon(item.icon || 'bolt')}</div>
  187. <h3>${escapeHtml(item.title || '')}</h3>
  188. <p>${escapeHtml(item.description || '')}</p>
  189. </div>`).join('')}
  190. </div>` : ''}
  191. </div>
  192. </section>`;
  193. }
  194. ​
  195. function renderPreviewTestimonials(d, id) {
  196. const items = d.items || [];
  197. return `<section class="testimonials"${id}>
  198. <div class="container">
  199. ${d.title ? `<div class="section-header"><h2>${escapeHtml(d.title)}</h2>${d.subtitle ? `<p>${escapeHtml(d.subtitle)}</p>` : ''}</div>` : ''}
  200. ${items.length ? `<div class="testimonials-grid">
  201. ${items.map(item => `<div class="testimonial-card">
  202. <p>"${escapeHtml(item.quote || '')}"</p>
  203. <div class="testimonial-author">
  204. <div class="testimonial-avatar">${escapeHtml(item.initials || '')}</div>
  205. <div class="testimonial-info"><h4>${escapeHtml(item.name || '')}</h4><span>${escapeHtml(item.role || '')}</span></div>
  206. </div>
  207. </div>`).join('')}
  208. </div>` : ''}
  209. </div>
  210. </section>`;
  211. }
  212. ​
  213. function renderPreviewPricing(d, id) {
  214. const items = d.items || [];
  215. return `<section class="pricing"${id}>
  216. <div class="container">
  217. ${d.title ? `<div class="section-header"><h2>${escapeHtml(d.title)}</h2>${d.subtitle ? `<p>${escapeHtml(d.subtitle)}</p>` : ''}</div>` : ''}
  218. ${items.length ? `<div class="pricing-grid">
  219. ${items.map(item => `<div class="pricing-card ${item.featured ? 'featured' : ''}">
  220. ${item.featured ? '<span class="pricing-popular">Most Popular</span>' : ''}
  221. <h3>${escapeHtml(item.name || '')}</h3>
  222. <div class="price">${escapeHtml(item.price || '')}<span>${escapeHtml(item.period || '')}</span></div>
  223. <p class="description">${escapeHtml(item.description || '')}</p>
  224. ${item.features && item.features.length ? `<ul class="pricing-features">
  225. ${item.features.map(f => `<li><svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>${escapeHtml(f)}</li>`).join('')}
  226. </ul>` : ''}
  227. <a href="${escapeHtml(item.button_url || '#')}" class="btn ${item.featured ? 'btn-primary' : 'btn-outline'}">${escapeHtml(item.button_text || item.button || 'Get Started')}</a>
  228. </div>`).join('')}
  229. </div>` : ''}
  230. </div>
  231. </section>`;
  232. }
  233. ​
  234. function renderPreviewCta(d, id) {
  235. return `<section class="cta"${id}>
  236. <div class="container">
  237. <div class="cta-inner">
  238. <h2>${escapeHtml(d.title || '')}</h2>
  239. <p>${escapeHtml(d.subtitle || '')}</p>
  240. <div class="cta-buttons">
  241. ${d.button_primary ? `<a href="${escapeHtml(d.button_primary_url || '#')}" class="btn btn-primary">${escapeHtml(d.button_primary)}</a>` : ''}
  242. ${d.button_secondary ? `<a href="${escapeHtml(d.button_secondary_url || '#')}" class="btn btn-outline">${escapeHtml(d.button_secondary)}</a>` : ''}
  243. </div>
  244. </div>
  245. </div>
  246. </section>`;
  247. }
  248. ​
  249. function renderPreviewText(d, id) {
  250. return `<section class="text-section"${id}>
  251. <div class="container">
  252. ${d.title ? `<h2>${escapeHtml(d.title)}</h2>` : ''}
  253. <div class="text-content">${escapeHtml(d.content || '').replace(/\n/g, '<br>')}</div>
  254. </div>
  255. </section>`;
  256. }
  257. ​
  258. function renderPreviewImage(d, id) {
  259. if (!d.url) return '';
  260. return `<section class="image-section"${id}>
  261. <div class="container">
  262. <figure>
  263. <img src="${escapeHtml(d.url)}" alt="${escapeHtml(d.alt || '')}" loading="lazy">
  264. ${d.caption ? `<figcaption>${escapeHtml(d.caption)}</figcaption>` : ''}
  265. </figure>
  266. </div>
  267. </section>`;
  268. }
  269. ​
  270. function renderPreviewImageText(d, id) {
  271. const imagePos = d.image_position || 'left';
  272. return `<section class="image-text"${id}>
  273. <div class="container">
  274. <div class="image-text-grid image-text-${escapeHtml(imagePos)}">
  275. <div class="image-text-image">
  276. <img src="${escapeHtml(d.image_url || '')}" alt="${escapeHtml(d.image_alt || '')}" loading="lazy">
  277. </div>
  278. <div class="image-text-content">
  279. ${d.subtitle ? `<p class="section-subtitle">${escapeHtml(d.subtitle)}</p>` : ''}
  280. ${d.title ? `<h2>${escapeHtml(d.title)}</h2>` : ''}
  281. <div class="text-content">${escapeHtml(d.content || '').replace(/\n/g, '<br>')}</div>
  282. ${d.button_text ? `<a href="${escapeHtml(d.button_url || '#')}" class="btn btn-primary">${escapeHtml(d.button_text)}</a>` : ''}
  283. </div>
  284. </div>
  285. </div>
  286. </section>`;
  287. }
  288. ​
  289. function renderPreviewProductCards(d, id) {
  290. const products = d.products || [];
  291. if (!products.length) return '';
  292. return `<section class="product-cards"${id}>
  293. <div class="container">
  294. ${d.title ? `<div class="section-header"><h2>${escapeHtml(d.title)}</h2>${d.subtitle ? `<p>${escapeHtml(d.subtitle)}</p>` : ''}</div>` : ''}
  295. <div class="products-grid">
  296. ${products.map(p => `<div class="product-card">
  297. ${p.image ? `<div class="product-image"><img src="${escapeHtml(p.image)}" alt="${escapeHtml(p.title || '')}" width="48" height="48" loading="lazy"></div>` : ''}
  298. <h3>${escapeHtml(p.title || '')}</h3>
  299. <p class="product-description">${escapeHtml(p.description || '')}</p>
  300. ${p.features && p.features.length ? `<ul class="product-features">${p.features.map(f => `<li>${escapeHtml(f)}</li>`).join('')}</ul>` : ''}
  301. ${p.button_text ? `<a href="${escapeHtml(p.button_url || '#')}" class="btn btn-primary btn-sm">${escapeHtml(p.button_text)}</a>` : ''}
  302. </div>`).join('')}
  303. </div>
  304. </div>
  305. </section>`;
  306. }
  307. ​
  308. function renderPreviewVideo(d, id) {
  309. if (!d.url) return '';
  310. const url = d.url;
  311. const type = d.type || 'url';
  312. const poster = d.posterUrl ? escapeHtml(d.posterUrl) : '';
  313. let videoHtml = '';
  314. ​
  315. if (type === 'youtube' || url.match(/youtube\.com|youtu\.be/i)) {
  316. const videoId = url.match(/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/i);
  317. if (videoId && videoId[1]) {
  318. videoHtml = `<iframe src="https://www.youtube.com/embed/${escapeHtml(videoId[1])}?autoplay=1" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen loading="lazy"></iframe>`;
  319. }
  320. } else if (type === 'facebook' || url.match(/facebook\.com|fb\.watch/i)) {
  321. videoHtml = `<iframe src="https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false&autoplay=true" frameborder="0" allow="autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share" allowfullscreen loading="lazy"></iframe>`;
  322. } else {
  323. videoHtml = `<video controls preload="metadata" ${poster ? `poster="${poster}"` : ''}><source src="${escapeHtml(url)}" type="${mediaMime(url, 'video/mp4')}">Your browser does not support the video tag.</video>`;
  324. }
  325. ​
  326. const overlayHtml = (poster && type !== 'url')
  327. ? `<div class="video-overlay" style="background-image: url('${poster}');" onclick="this.remove()">
  328. <div class="play-button"></div>
  329. </div>`
  330. : '';
  331. ​
  332. return `<section class="video-section"${id}>
  333. <div class="container">
  334. ${d.title ? `<div class="section-header"><h2>${escapeHtml(d.title)}</h2>${d.subtitle ? `<p>${escapeHtml(d.subtitle)}</p>` : ''}</div>` : ''}
  335. <div class="video-wrapper" style="position: relative;">
  336. ${overlayHtml}
  337. ${videoHtml}
  338. </div>
  339. ${d.caption ? `<p class="video-caption">${escapeHtml(d.caption)}</p>` : ''}
  340. </div>
  341. </section>`;
  342. }
  343. ​
  344. function renderPreviewGallery(d, id) {
  345. const images = d.images || [];
  346. if (!images.length) return '';
  347. return `<section class="gallery-section"${id}>
  348. <div class="container">
  349. ${d.title ? `<div class="section-header"><h2>${escapeHtml(d.title)}</h2>${d.subtitle ? `<p>${escapeHtml(d.subtitle)}</p>` : ''}</div>` : ''}
  350. <div class="gallery-grid">
  351. ${images.map(img => `<div class="gallery-item">
  352. <img src="${escapeHtml(img.url || '')}" alt="${escapeHtml(img.alt || '')}" loading="lazy">
  353. ${img.caption ? `<div class="gallery-caption">${escapeHtml(img.caption)}</div>` : ''}
  354. </div>`).join('')}
  355. </div>
  356. </div>
  357. </section>`;
  358. }
  359. ​
  360. function renderPreviewFaq(d, id) {
  361. const items = d.items || [];
  362. if (!items.length) return '';
  363. return `<section class="faq-section"${id}>
  364. <div class="container">
  365. ${d.title ? `<div class="section-header"><h2>${escapeHtml(d.title)}</h2>${d.subtitle ? `<p>${escapeHtml(d.subtitle)}</p>` : ''}</div>` : ''}
  366. <div class="faq-list">
  367. ${items.map((item, idx) => `<details class="faq-item" ${idx === 0 ? 'open' : ''}>
  368. <summary class="faq-question">${escapeHtml(item.question || '')}</summary>
  369. <div class="faq-answer">${escapeHtml(item.answer || '').replace(/\n/g, '<br>')}</div>
  370. </details>`).join('')}
  371. </div>
  372. </div>
  373. </section>`;
  374. }
  375. ​
  376. function renderPreviewTeam(d, id) {
  377. const members = d.members || [];
  378. if (!members.length) return '';
  379. return `<section class="team-section"${id}>
  380. <div class="container">
  381. ${d.title ? `<div class="section-header"><h2>${escapeHtml(d.title)}</h2>${d.subtitle ? `<p>${escapeHtml(d.subtitle)}</p>` : ''}</div>` : ''}
  382. <div class="team-grid">
  383. ${members.map(m => `<div class="team-member">
  384. ${m.image ? `<img src="${escapeHtml(m.image)}" alt="${escapeHtml(m.name || '')}" class="team-photo" loading="lazy">` : `<div class="team-avatar">${escapeHtml(m.initials || (m.name || '?').substring(0, 2).toUpperCase())}</div>`}
  385. <h3>${escapeHtml(m.name || '')}</h3>
  386. <p class="team-role">${escapeHtml(m.role || '')}</p>
  387. ${m.bio ? `<p class="team-bio">${escapeHtml(m.bio)}</p>` : ''}
  388. ${m.social && m.social.length ? `<div class="team-social">
  389. ${m.social.map(s => `<a href="${escapeHtml(s.url || '#')}" target="_blank" rel="noopener" class="team-social-link" title="${escapeHtml(s.platform || '')}">
  390. ${s.platform === 'twitter' ? '<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24"><path d="M23 3a10.9 10.9 0 0 1-3.14 1.53 4.48 4.48 0 0 0-7.86 3v1A10.66 10.66 0 0 1 3 4s-4 9 5 13a11.64 11.64 0 0 1-7 2c9 5 20 0 20-11.5a4.5 4.5 0 0 0-.08-.83A7.72 7.72 0 0 0 23 3z"/></svg>' : ''}
  391. ${s.platform === 'linkedin' ? '<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6zM2 9h4v12H2z"/><circle cx="4" cy="4" r="2"/></svg>' : ''}
  392. ${s.platform === 'github' ? '<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>' : ''}
  393. </a>`).join('')}
  394. </div>` : ''}
  395. </div>`).join('')}
  396. </div>
  397. </div>
  398. </section>`;
  399. }
  400. ​
  401. function renderPreviewAudio(d, id) {
  402. if (!d.url) return '';
  403. return `<section class="audio-section"${id}>
  404. <div class="container">
  405. ${d.title ? `<div class="section-header"><h2>${escapeHtml(d.title)}</h2>${d.subtitle ? `<p>${escapeHtml(d.subtitle)}</p>` : ''}</div>` : ''}
  406. <div class="audio-wrapper">
  407. <audio controls preload="metadata" class="audio-player"><source src="${escapeHtml(d.url)}" type="${mediaMime(d.url, 'audio/mpeg')}">Your browser does not support the audio element.</audio>
  408. ${d.music_link ? `<a href="${escapeHtml(d.music_link)}" class="btn btn-outline" target="_blank" rel="noopener">${escapeHtml(d.music_link_text || 'Download')}</a>` : ''}
  409. </div>
  410. </div>
  411. </section>`;
  412. }
  413. ​
  414. function renderPreviewCountdown(d, id) {
  415. if (!d.target_date) return '';
  416. return `<section class="countdown-section"${id}>
  417. <div class="container">
  418. ${d.title ? `<div class="section-header"><h2>${escapeHtml(d.title)}</h2>${d.subtitle ? `<p>${escapeHtml(d.subtitle)}</p>` : ''}</div>` : ''}
  419. <div class="countdown-timer">
  420. <div class="countdown-item"><div class="countdown-value">00</div><div class="countdown-label">Days</div></div>
  421. <div class="countdown-item"><div class="countdown-value">00</div><div class="countdown-label">Hours</div></div>
  422. <div class="countdown-item"><div class="countdown-value">00</div><div class="countdown-label">Minutes</div></div>
  423. <div class="countdown-item"><div class="countdown-value">00</div><div class="countdown-label">Seconds</div></div>
  424. </div>
  425. </div>
  426. </section>`;
  427. }
  428. ​
  429. function renderPreviewNewsletter(d, id) {
  430. return `<section class="newsletter-section"${id}>
  431. <div class="container">
  432. <div class="newsletter-inner">
  433. ${d.title ? `<h2>${escapeHtml(d.title)}</h2>` : ''}
  434. ${d.subtitle ? `<p>${escapeHtml(d.subtitle)}</p>` : ''}
  435. <form class="newsletter-form" onsubmit="event.preventDefault();">
  436. <input type="email" class="newsletter-input" placeholder="${escapeHtml(d.placeholder || 'Enter your email')}" required>
  437. <button type="submit" class="btn btn-primary">${escapeHtml(d.button_text || 'Subscribe')}</button>
  438. </form>
  439. </div>
  440. </div>
  441. </section>`;
  442. }
  443. ​
  444. function updateLivePreview() {
  445. const iframe = document.getElementById('previewFrame');
  446. if (!iframe) return;
  447. ​
  448. const wrapper = document.getElementById('previewFrameWrapper');
  449. if (wrapper) wrapper.classList.add('loading');
  450. ​
  451. const blocksHtml = blocks.map(block => renderPreviewBlock(block)).join('');
  452. ​
  453. const fullHtml = flatlyRewriteHtml(`<!DOCTYPE html>
  454. <html lang="en">
  455. <head>
  456. <meta charset="UTF-8">
  457. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  458. <title>Preview</title>
  459. ${PREVIEW_FONT_HEAD}
  460. <link rel="stylesheet" href="/css/styles.css?v=${Date.now()}">
  461. ${HAS_THEME ? `<link rel="stylesheet" href="/css/theme.css?v=${Date.now()}">` : ""}
  462. <style>
  463. ${PREVIEW_HEADINGS_CSS}
  464. a { pointer-events: none; }
  465. </style>
  466. <script src="/assets/js/admin-theme.js"><\/script>
  467. </head>
  468. <body>
  469. <main>${blocksHtml}</main>
  470. </body>
  471. </html>`);
  472. ​
  473. iframe.srcdoc = fullHtml
  474. .replace(/\[u\](.*?)\[\/u\]/gi, '<u>$1</u>')
  475. .replace(/\[b\](.*?)\[\/b\]/gi, '<b>$1</b>')
  476. .replace(/\[i\](.*?)\[\/i\]/gi, '<i>$1</i>')
  477. .replace(/\[s\](.*?)\[\/s\]/gi, '<del>$1</del>')
  478. .replace(/\[quote\](.*?)\[\/quote\]/gi, '<blockquote>$1</blockquote>'
  479. );
  480. ​
  481. iframe.onload = function () {
  482. if (wrapper) wrapper.classList.remove('loading');
  483. };
  484. }
  485. ​
  486. ​
  487. if (!footerData.columns) footerData.columns = [];
  488. if (!footerData.social_links) footerData.social_links = [];
  489. if (!footerData.bottom_links) footerData.bottom_links = [];
  490. ​
  491. document.addEventListener('DOMContentLoaded', function () {
  492. renderBlocks();
  493. ​
  494. setTimeout(updateLivePreview, 100);
  495. ​
  496. const previewFrameEdit = document.getElementById('previewFrame');
  497. if (previewFrameEdit) {
  498. const wrapperEdit = document.getElementById('previewFrameWrapper');
  499. if (wrapperEdit) wrapperEdit.classList.add('loading');
  500. previewFrameEdit.onload = function () {
  501. if (wrapperEdit) wrapperEdit.classList.remove('loading');
  502. };
  503. }
  504. renderNavLinks();
  505. renderNavButtons();
  506. renderFooterColumns();
  507. renderSocialLinks();
  508. renderBottomLinks();
  509. renderLanguages();
  510. renderInternalPages();
  511. ​
  512. updateBlockCount();
  513. ​
  514. const tabStorageKey = 'flatly-ui:settings-tab';
  515. ​
  516. const activateSettingsTab = (name) => {
  517. const target = document.querySelector('.settings-tab[data-tab="' + name + '"]');
  518. if (!target || !document.getElementById('panel-' + name)) return false;
  519. document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
  520. document.querySelectorAll('.settings-panel').forEach(p => p.classList.remove('active'));
  521. target.classList.add('active');
  522. document.getElementById('panel-' + name).classList.add('active');
  523. return true;
  524. };
  525. ​
  526. document.querySelectorAll('.settings-tab').forEach(tab => {
  527. tab.addEventListener('click', () => {
  528. activateSettingsTab(tab.dataset.tab);
  529. try { localStorage.setItem(tabStorageKey, tab.dataset.tab); } catch (e) {}
  530. });
  531. });
  532. ​
  533. try {
  534. const savedTab = localStorage.getItem(tabStorageKey);
  535. if (savedTab) activateSettingsTab(savedTab);
  536. } catch (e) {}
  537. ​
  538. const settingsForm = document.getElementById('settingsForm');
  539. if (settingsForm) {
  540. // A field in a hidden tab can't show its validation message, so the form would silently not submit.
  541. settingsForm.addEventListener('invalid', (event) => {
  542. const panel = event.target.closest('.settings-panel');
  543. if (panel && !panel.classList.contains('active')) activateSettingsTab(panel.id.replace(/^panel-/, ''));
  544. }, true);
  545. ​
  546. // Code fields are sent base64-encoded so hosting firewalls don't block posts containing <script>.
  547. settingsForm.addEventListener('submit', () => {
  548. settingsForm.querySelectorAll('.flatly-encoded').forEach(input => input.remove());
  549. settingsForm.querySelectorAll('textarea[data-encode]').forEach(area => {
  550. const name = area.name || area.dataset.encodeName;
  551. if (!name) return;
  552. area.dataset.encodeName = name;
  553. area.removeAttribute('name');
  554. const hidden = document.createElement('input');
  555. hidden.type = 'hidden';
  556. hidden.className = 'flatly-encoded';
  557. hidden.name = name.endsWith('[]') ? name.slice(0, -2) + '_b64[]' : name + '_b64';
  558. hidden.value = flatlyBase64Encode(area.value);
  559. settingsForm.appendChild(hidden);
  560. });
  561. });
  562. ​
  563. // Coming back to the page (bfcache) must give the fields their names back.
  564. window.addEventListener('pageshow', () => {
  565. settingsForm.querySelectorAll('.flatly-encoded').forEach(input => input.remove());
  566. settingsForm.querySelectorAll('textarea[data-encode-name]').forEach(area => { area.name = area.dataset.encodeName; });
  567. });
  568. }
  569. });
  570. ​
  571. function flatlyBase64Encode(text) {
  572. const bytes = new TextEncoder().encode(String(text));
  573. let binary = '';
  574. for (let i = 0; i < bytes.length; i += 0x8000) {
  575. binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  576. }
  577. return btoa(binary);
  578. }
  579. ​
  580. function flatlyBase64Decode(encoded) {
  581. const binary = atob(encoded);
  582. const bytes = new Uint8Array(binary.length);
  583. for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  584. return new TextDecoder().decode(bytes);
  585. }
  586. ​
  587. (function () {
  588. const scrollKey = 'flatly-ui:scroll:' + location.pathname + location.search;
  589. const pendingKey = 'flatly-ui:scroll-pending';
  590. const desktop = window.matchMedia('(min-width: 769px)');
  591. let timer = 0;
  592. ​
  593. const scroller = () => (desktop.matches ? document.querySelector('.main') : document.scrollingElement) || document.documentElement;
  594. ​
  595. const save = () => {
  596. try { sessionStorage.setItem(scrollKey, String(scroller().scrollTop || 0)); } catch (e) {}
  597. };
  598. ​
  599. const navigationType = () => {
  600. const entry = performance.getEntriesByType && performance.getEntriesByType('navigation')[0];
  601. return entry ? entry.type : '';
  602. };
  603. ​
  604. let shouldRestore = ['reload', 'back_forward'].includes(navigationType());
  605. try {
  606. if (sessionStorage.getItem(pendingKey) === scrollKey) {
  607. shouldRestore = true;
  608. }
  609. sessionStorage.removeItem(pendingKey);
  610. } catch (e) {}
  611. ​
  612. document.addEventListener('submit', () => {
  613. save();
  614. try { sessionStorage.setItem(pendingKey, scrollKey); } catch (e) {}
  615. }, true);
  616. ​
  617. document.addEventListener('scroll', () => {
  618. clearTimeout(timer);
  619. timer = setTimeout(save, 150);
  620. }, { capture: true, passive: true });
  621. ​
  622. window.addEventListener('pagehide', save);
  623. ​
  624. if (!shouldRestore) return;
  625. ​
  626. let saved = 0;
  627. try { saved = parseInt(sessionStorage.getItem(scrollKey) || '0', 10) || 0; } catch (e) {}
  628. if (!saved) return;
  629. ​
  630. const apply = () => { scroller().scrollTop = saved; };
  631. window.addEventListener('load', () => {
  632. apply();
  633. setTimeout(apply, 250);
  634. });
  635. })();
  636. ​
  637. document.getElementById('pageForm')?.addEventListener('submit', function (e) {
  638. document.getElementById('blocksData').value = JSON.stringify(blocks);
  639. });
  640. ​
  641. document.getElementById('settingsForm')?.addEventListener('submit', function (e) {
  642. document.getElementById('navLinksData').value = JSON.stringify(navLinks);
  643. document.getElementById('navButtonsData').value = JSON.stringify(navButtons);
  644. updateFooterData();
  645. document.getElementById('footerData').value = JSON.stringify(footerData);
  646. const languagesField = document.getElementById('languagesDataField');
  647. if (languagesField && typeof languagesData !== 'undefined') languagesField.value = JSON.stringify(languagesData);
  648. });
  649. ​
  650. ​
  651. function generateId() {
  652. return 'block-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
  653. }
  654. ​
  655. function updateBlockCount() {
  656. const countEl = document.getElementById('blockCount');
  657. if (countEl) countEl.textContent = blocks.length;
  658. }
  659. ​
  660. function getUniqueBlockId(baseId, currentIndex) {
  661. if (!baseId || baseId.trim() === '') return '';
  662. ​
  663. const cleanId = baseId.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
  664. ​
  665. let counter = 2;
  666. let finalId = cleanId;
  667. ​
  668. blocks.forEach((block, index) => {
  669. if (index !== currentIndex) {
  670. if (block.id === finalId) {
  671. ​
  672. while (blocks.some((b, i) => i !== currentIndex && b.id === `${cleanId}-${counter}`)) {
  673. counter++;
  674. }
  675. finalId = `${cleanId}-${counter}`;
  676. } else if (block.id === cleanId && counter === 2) {
  677. ​
  678. }
  679. }
  680. });
  681. ​
  682. return finalId;
  683. }
  684. ​
  685. function renderBlocks() {
  686. const container = document.getElementById('blocksContainer');
  687. if (!container) return;
  688. ​
  689. const blockTypes = {};
  690. blocks.forEach((block) => {
  691. if (!block.id) {
  692. const type = block.type;
  693. if (!blockTypes[type]) {
  694. blockTypes[type] = [];
  695. }
  696. blockTypes[type].push(block);
  697. }
  698. });
  699. ​
  700. Object.keys(blockTypes).forEach(type => {
  701. const existingIds = blocks
  702. .filter(b => b.type === type && b.id)
  703. .map(b => b.id);
  704. ​
  705. blockTypes[type].forEach((block) => {
  706. let counter = 1;
  707. let newId = type;
  708. ​
  709. if (existingIds.includes(newId)) {
  710. counter = 2;
  711. while (existingIds.includes(`${type}-${counter}`)) {
  712. counter++;
  713. }
  714. newId = `${type}-${counter}`;
  715. }
  716. ​
  717. block.id = newId;
  718. existingIds.push(newId);
  719. });
  720. });
  721. ​
  722. blocks.forEach((block, index) => {
  723. if (block.id) {
  724. block.id = getUniqueBlockId(block.id, index);
  725. }
  726. });
  727. ​
  728. const expandedItems = document.querySelectorAll('.block-item.expanded');
  729. expandedBlockIndices.clear();
  730. expandedItems.forEach(item => {
  731. const index = item.dataset.index;
  732. if (index !== undefined) expandedBlockIndices.add(parseInt(index));
  733. });
  734. ​
  735. container.innerHTML = '';
  736. ​
  737. blocks.forEach((block, index) => {
  738. const el = createBlockElement(block, index);
  739. container.appendChild(el);
  740. ​
  741. if (expandedBlockIndices.has(index)) {
  742. el.classList.add('expanded');
  743. }
  744. });
  745. ​
  746. updateBlockCount();
  747. }
  748. ​
  749. function createBlockElement(block, index) {
  750. const div = document.createElement('div');
  751. div.className = 'block-item';
  752. div.dataset.index = index;
  753. div.draggable = false;
  754. ​
  755. div.addEventListener('dragover', handleDragOver);
  756. div.addEventListener('drop', handleDrop);
  757. div.addEventListener('dragleave', handleDragLeave);
  758. ​
  759. const title = getBlockTitle(block);
  760. ​
  761. div.innerHTML = `
  762. <div class="block-header" onclick="toggleBlock(${index})">
  763. <div class="block-drag" draggable="true" onmousedown="event.stopPropagation()">
  764. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16"><circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/></svg>
  765. </div>
  766. <span class="block-type">${block.type}</span>
  767. <span class="block-title">${escapeHtml(title)}</span>
  768. <input type="text" class="block-id-input" value="${escapeHtml(block.id || block.type)}" onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); blocks[${index}].id = this.value;" onblur="renderBlocks();" style="max-width: 120px; font-size: 0.8125rem;">
  769. <div class="block-actions">
  770. <button type="button" class="block-toggle" onclick="event.stopPropagation(); moveBlockUp(${index})" title="Move Up">
  771. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="18 15 12 9 6 15"/></svg>
  772. </button>
  773. <button type="button" class="block-toggle" onclick="event.stopPropagation(); moveBlockDown(${index})" title="Move Down">
  774. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
  775. </button>
  776. <button type="button" class="block-toggle" onclick="event.stopPropagation(); duplicateBlock(${index})" title="Duplicate">
  777. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
  778. </button>
  779. <button type="button" class="block-toggle" onclick="event.stopPropagation(); deleteBlock(${index})" title="Delete" style="color: var(--error);">
  780. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><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"/></svg>
  781. </button>
  782. <button type="button" class="block-toggle block-toggle-arrow">
  783. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
  784. </button>
  785. </div>
  786. </div>
  787. <div class="block-content">
  788. ${renderBlockFields(block, index)}
  789. </div>
  790. `;
  791. ​
  792. setTimeout(() => {
  793. const dragHandle = div.querySelector('.block-drag');
  794. if (dragHandle) {
  795. dragHandle.addEventListener('dragstart', function (e) {
  796. e.stopPropagation();
  797. draggedBlock = div;
  798. div.classList.add('dragging');
  799. e.dataTransfer.effectAllowed = 'move';
  800. });
  801. ​
  802. dragHandle.addEventListener('dragend', function (e) {
  803. e.stopPropagation();
  804. div.classList.remove('dragging');
  805. document.querySelectorAll('.block-item').forEach(el => el.classList.remove('drag-over'));
  806. draggedBlock = null;
  807. });
  808. }
  809. }, 0);
  810. ​
  811. return div;
  812. }
  813. ​
  814. ​
  815. function getBlockTitle(block) {
  816. switch (block.type) {
  817. case 'hero': return block.data?.title || 'Hero Section';
  818. case 'blog-hero': return block.data?.title || 'Blog Hero Section';
  819. case 'stats': return 'Statistics';
  820. case 'features': return block.data?.title || 'Features';
  821. case 'testimonials': return block.data?.title || 'Testimonials';
  822. case 'pricing': return block.data?.title || 'Pricing';
  823. case 'cta': return block.data?.title || 'Call to Action';
  824. case 'text': return block.data?.title || 'Text Block';
  825. case 'image': return block.data?.alt || 'Image';
  826. case 'image-text': return block.data?.title || 'Image + Text';
  827. case 'product-cards': return block.data?.title || 'Product Cards';
  828. case 'video': return block.data?.title || 'Video';
  829. case 'gallery': return block.data?.title || 'Gallery';
  830. case 'faq': return block.data?.title || 'FAQ';
  831. case 'team': return block.data?.title || 'Team';
  832. case 'audio': return block.data?.title || 'Audio Player';
  833. case 'countdown': return block.data?.title || 'Countdown Timer';
  834. case 'newsletter': return block.data?.title || 'Newsletter';
  835. case 'html': return 'Custom HTML';
  836. default: return block.type;
  837. }
  838. }
  839. ​
  840. function renderBlockFields(block, blockIndex) {
  841. switch (block.type) {
  842. case 'hero': {
  843. const badgeVal = escapeHtml(block.data?.badge || '');
  844. const titleVal = escapeHtml(block.data?.title || '');
  845. const subtitleVal = escapeHtml(block.data?.subtitle || '');
  846. const btn1Val = escapeHtml(block.data?.button_primary || '');
  847. const btn1Url = block.data?.button_primary_url || '';
  848. const btn2Val = escapeHtml(block.data?.button_secondary || '');
  849. const btn2Url = block.data?.button_secondary_url || '';
  850. return '<div class="form-row">' +
  851. '<div class="block-field">' +
  852. '<label class="block-field-label">Badge Text</label>' +
  853. '<input type="text" class="block-field-input" value="' + badgeVal + '" oninput="updateBlockData(' + blockIndex + ', \'badge\', this.value)">' +
  854. '</div>' +
  855. '<div class="block-field">' +
  856. '<label class="block-field-label">Title</label>' +
  857. '<input type="text" class="block-field-input" value="' + titleVal + '" oninput="updateBlockData(' + blockIndex + ', \'title\', this.value)">' +
  858. '</div>' +
  859. '</div>' +
  860. '<div class="block-field">' +
  861. '<label class="block-field-label">Subtitle</label>' +
  862. '<textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateBlockData(' + blockIndex + ', \'subtitle\', this.value)">' + subtitleVal + '</textarea>' +
  863. '</div>' +
  864. '<div class="form-row">' +
  865. '<div class="block-field">' +
  866. '<label class="block-field-label">Primary Button Text</label>' +
  867. '<input type="text" class="block-field-input" value="' + btn1Val + '" oninput="updateBlockData(' + blockIndex + ', \'button_primary\', this.value)">' +
  868. '</div>' +
  869. '<div class="block-field">' +
  870. '<label class="block-field-label">Primary Button URL</label>' +
  871. renderLinkInput(btn1Url, 'updateBlockData(' + blockIndex + ', \'button_primary_url\', VALUE)') +
  872. '</div>' +
  873. '</div>' +
  874. '<div class="form-row">' +
  875. '<div class="block-field">' +
  876. '<label class="block-field-label">Secondary Button Text</label>' +
  877. '<input type="text" class="block-field-input" value="' + btn2Val + '" oninput="updateBlockData(' + blockIndex + ', \'button_secondary\', this.value)">' +
  878. '</div>' +
  879. '<div class="block-field">' +
  880. '<label class="block-field-label">Secondary Button URL</label>' +
  881. renderLinkInput(btn2Url, 'updateBlockData(' + blockIndex + ', \'button_secondary_url\', VALUE)') +
  882. '</div>' +
  883. '</div>';
  884. }
  885. ​
  886. case 'blog-hero': {
  887. const titleVal = escapeHtml(block.data?.title || '');
  888. const contentVal = escapeHtml(block.data?.content || '');
  889. const imageUrl = block.data?.image || '';
  890. ​
  891. return `
  892. <div class="block-field">
  893. <label class="block-field-label">Header image (URL)</label>
  894. <div class="image-input-group">
  895. <input type="text" data-media="image" class="block-field-input" value="${imageUrl}"
  896. oninput="updateBlockData(${blockIndex}, 'image', this.value)"
  897. placeholder="https://example.com/image.jpg">
  898. </div>
  899. </div>
  900. ​
  901. <div class="block-field">
  902. <label class="block-field-label">Blog Title</label>
  903. <input type="text" class="block-field-input" value="${titleVal}"
  904. oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  905. </div>
  906. ​
  907. <div class="block-field">
  908. <label class="block-field-label">Content / Introduction</label>
  909. <textarea data-rich="1" class="block-field-input block-field-textarea" rows="4"
  910. oninput="updateBlockData(${blockIndex}, 'content', this.value)">${contentVal}</textarea>
  911. </div>
  912. `;
  913. }
  914. ​
  915. case 'stats':
  916. return `
  917. <div class="block-field">
  918. <label class="block-field-label">Stats Items</label>
  919. <div class="repeater-items" id="stats-items-${blockIndex}">
  920. ${(block.data?.items || []).map((item, i) => `
  921. <div class="repeater-item expanded">
  922. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  923. <span class="repeater-item-title">${escapeHtml(item.value || 'New Stat')} - ${escapeHtml(item.label || '')}</span>
  924. <div class="repeater-item-actions">
  925. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'items', ${i})" style="color: var(--error);">Remove</button>
  926. </div>
  927. </div>
  928. <div class="repeater-item-content">
  929. <div class="form-row">
  930. <div class="block-field">
  931. <label class="block-field-label">Value</label>
  932. <input type="text" class="block-field-input" value="${escapeHtml(item.value || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'value', this.value)">
  933. </div>
  934. <div class="block-field">
  935. <label class="block-field-label">Label</label>
  936. <input type="text" class="block-field-input" value="${escapeHtml(item.label || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'label', this.value)">
  937. </div>
  938. </div>
  939. </div>
  940. </div>
  941. `).join('')}
  942. </div>
  943. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'items', {value: '', label: ''})">
  944. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
  945. Add Stat
  946. </button>
  947. </div>
  948. `;
  949. ​
  950. case 'features':
  951. return `
  952. <div class="form-row">
  953. <div class="block-field">
  954. <label class="block-field-label">Section Title</label>
  955. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  956. </div>
  957. <div class="block-field">
  958. <label class="block-field-label">Section Subtitle</label>
  959. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  960. </div>
  961. </div>
  962. <div class="block-field">
  963. <label class="block-field-label">Feature Items</label>
  964. <div class="repeater-items" id="features-items-${blockIndex}">
  965. ${(block.data?.items || []).map((item, i) => `
  966. <div class="repeater-item">
  967. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  968. <span class="repeater-item-title">${escapeHtml(item.title || 'New Feature')}</span>
  969. <div class="repeater-item-actions">
  970. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'items', ${i})" style="color: var(--error);">Remove</button>
  971. </div>
  972. </div>
  973. <div class="repeater-item-content">
  974. <div class="block-field">
  975. <label class="block-field-label">Icon</label>
  976. <div class="icon-select">
  977. ${Object.keys(ICONS).map(icon => `
  978. <div class="icon-option ${item.icon === icon ? 'selected' : ''}" onclick="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'icon', '${icon}'); renderBlocks();">
  979. ${ICONS[icon]}
  980. </div>
  981. `).join('')}
  982. </div>
  983. </div>
  984. <div class="block-field">
  985. <label class="block-field-label">Title</label>
  986. <input type="text" class="block-field-input" value="${escapeHtml(item.title || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'title', this.value)">
  987. </div>
  988. <div class="block-field">
  989. <label class="block-field-label">Description</label>
  990. <textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'description', this.value)">${escapeHtml(item.description || '')}</textarea>
  991. </div>
  992. </div>
  993. </div>
  994. `).join('')}
  995. </div>
  996. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'items', {icon: 'bolt', title: '', description: ''})">
  997. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
  998. Add Feature
  999. </button>
  1000. </div>
  1001. `;
  1002. ​
  1003. case 'testimonials':
  1004. return `
  1005. <div class="form-row">
  1006. <div class="block-field">
  1007. <label class="block-field-label">Section Title</label>
  1008. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1009. </div>
  1010. <div class="block-field">
  1011. <label class="block-field-label">Section Subtitle</label>
  1012. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1013. </div>
  1014. </div>
  1015. <div class="block-field">
  1016. <label class="block-field-label">Testimonials</label>
  1017. <div class="repeater-items" id="testimonials-items-${blockIndex}">
  1018. ${(block.data?.items || []).map((item, i) => `
  1019. <div class="repeater-item">
  1020. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  1021. <span class="repeater-item-title">${escapeHtml(item.name || 'New Testimonial')}</span>
  1022. <div class="repeater-item-actions">
  1023. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'items', ${i})" style="color: var(--error);">Remove</button>
  1024. </div>
  1025. </div>
  1026. <div class="repeater-item-content">
  1027. <div class="block-field">
  1028. <label class="block-field-label">Quote</label>
  1029. <textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'quote', this.value)">${escapeHtml(item.quote || '')}</textarea>
  1030. </div>
  1031. <div class="form-row-3">
  1032. <div class="block-field">
  1033. <label class="block-field-label">Name</label>
  1034. <input type="text" class="block-field-input" value="${escapeHtml(item.name || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'name', this.value)">
  1035. </div>
  1036. <div class="block-field">
  1037. <label class="block-field-label">Role</label>
  1038. <input type="text" class="block-field-input" value="${escapeHtml(item.role || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'role', this.value)">
  1039. </div>
  1040. <div class="block-field">
  1041. <label class="block-field-label">Initials</label>
  1042. <input type="text" class="block-field-input" value="${escapeHtml(item.initials || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'initials', this.value)" maxlength="2">
  1043. </div>
  1044. </div>
  1045. </div>
  1046. </div>
  1047. `).join('')}
  1048. </div>
  1049. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'items', {quote: '', name: '', role: '', initials: ''})">
  1050. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
  1051. Add Testimonial
  1052. </button>
  1053. </div>
  1054. `;
  1055. ​
  1056. case 'pricing': {
  1057. const titleVal = escapeHtml(block.data?.title || '');
  1058. const subtitleVal = escapeHtml(block.data?.subtitle || '');
  1059. let itemsHtml = '';
  1060. (block.data?.items || []).forEach((item, i) => {
  1061. const nameVal = escapeHtml(item.name || 'New Plan');
  1062. const priceVal = escapeHtml(item.price || '');
  1063. const descVal = escapeHtml(item.description || '');
  1064. const periodVal = escapeHtml(item.period || '');
  1065. const btnTextVal = escapeHtml(item.button_text || item.button || '');
  1066. const btnUrlVal = item.button_url || '#';
  1067. const featVal = item.featured ? 'checked' : '';
  1068. itemsHtml += '<div class="repeater-item">' +
  1069. '<div class="repeater-item-header" onclick="this.parentElement.classList.toggle(\'expanded\')">' +
  1070. '<span class="repeater-item-title">' + nameVal + ' - ' + priceVal + '</span>' +
  1071. '<div class="repeater-item-actions">' +
  1072. '<button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(' + blockIndex + ', \'items\', ' + i + ')" style="color: var(--error);">Remove</button>' +
  1073. '</div>' +
  1074. '</div>' +
  1075. '<div class="repeater-item-content">' +
  1076. '<div class="form-row-3">' +
  1077. '<div class="block-field">' +
  1078. '<label class="block-field-label">Plan Name</label>' +
  1079. '<input type="text" class="block-field-input" value="' + nameVal + '" oninput="updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'name\', this.value)">' +
  1080. '</div>' +
  1081. '<div class="block-field">' +
  1082. '<label class="block-field-label">Price</label>' +
  1083. '<input type="text" class="block-field-input" value="' + priceVal + '" oninput="updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'price\', this.value)">' +
  1084. '</div>' +
  1085. '<div class="block-field">' +
  1086. '<label class="block-field-label">Period</label>' +
  1087. '<input type="text" class="block-field-input" value="' + periodVal + '" oninput="updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'period\', this.value)" placeholder="/month">' +
  1088. '</div>' +
  1089. '</div>' +
  1090. '<div class="block-field">' +
  1091. '<label class="block-field-label">Description</label>' +
  1092. '<input type="text" class="block-field-input" value="' + descVal + '" oninput="updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'description\', this.value)">' +
  1093. '</div>' +
  1094. '<div class="block-field">' +
  1095. '<label class="block-field-label">Features (one per line)</label>' +
  1096. '<textarea class="block-field-input block-field-textarea" oninput="updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'features\', this.value.split(\'\\n\').filter(f => f.trim()))">' + (item.features || []).join('\n') + '</textarea>' +
  1097. '</div>' +
  1098. '<div class="form-row">' +
  1099. '<div class="block-field">' +
  1100. '<label class="block-field-label">Button Text</label>' +
  1101. '<input type="text" class="block-field-input" value="' + btnTextVal + '" oninput="updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'button_text\', this.value)">' +
  1102. '</div>' +
  1103. '<div class="block-field">' +
  1104. '<label class="block-field-label">Button URL</label>' +
  1105. renderLinkInput(btnUrlVal, 'updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'button_url\', VALUE)') +
  1106. '</div>' +
  1107. '</div>' +
  1108. '<div class="block-field">' +
  1109. '<label class="block-field-label" style="display: flex; align-items: center; gap: 8px;">' +
  1110. '<input type="checkbox" ' + featVal + ' onchange="updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'featured\', this.checked)">' +
  1111. 'Featured Plan (highlighted)' +
  1112. '</label>' +
  1113. '</div>' +
  1114. '</div>' +
  1115. '</div>';
  1116. });
  1117. return '<div class="form-row">' +
  1118. '<div class="block-field">' +
  1119. '<label class="block-field-label">Section Title</label>' +
  1120. '<input type="text" class="block-field-input" value="' + titleVal + '" oninput="updateBlockData(' + blockIndex + ', \'title\', this.value)">' +
  1121. '</div>' +
  1122. '<div class="block-field">' +
  1123. '<label class="block-field-label">Section Subtitle</label>' +
  1124. '<input type="text" class="block-field-input" value="' + subtitleVal + '" oninput="updateBlockData(' + blockIndex + ', \'subtitle\', this.value)">' +
  1125. '</div>' +
  1126. '</div>' +
  1127. '<div class="block-field">' +
  1128. '<label class="block-field-label">Pricing Plans</label>' +
  1129. '<div class="repeater-items" id="pricing-items-' + blockIndex + '">' +
  1130. itemsHtml +
  1131. '</div>' +
  1132. '<button type="button" class="repeater-add" onclick="addRepeaterItem(' + blockIndex + ', \'items\', {name: \'\', price: \'\', period: \'/month\', description: \'\', features: [], button_text: \'Get Started\', button_url: \'#\', featured: false})">' +
  1133. '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
  1134. 'Add Plan' +
  1135. '</button>' +
  1136. '</div>';
  1137. }
  1138. ​
  1139. case 'cta': {
  1140. const titleVal = escapeHtml(block.data?.title || '');
  1141. const subtitleVal = escapeHtml(block.data?.subtitle || '');
  1142. const btn1Val = escapeHtml(block.data?.button_primary || '');
  1143. const btn1Url = block.data?.button_primary_url || '';
  1144. const btn2Val = escapeHtml(block.data?.button_secondary || '');
  1145. const btn2Url = block.data?.button_secondary_url || '';
  1146. return '<div class="form-row">' +
  1147. '<div class="block-field">' +
  1148. '<label class="block-field-label">Title</label>' +
  1149. '<input type="text" class="block-field-input" value="' + titleVal + '" oninput="updateBlockData(' + blockIndex + ', \'title\', this.value)">' +
  1150. '</div>' +
  1151. '<div class="block-field">' +
  1152. '<label class="block-field-label">Subtitle</label>' +
  1153. '<input type="text" class="block-field-input" value="' + subtitleVal + '" oninput="updateBlockData(' + blockIndex + ', \'subtitle\', this.value)">' +
  1154. '</div>' +
  1155. '</div>' +
  1156. '<div class="form-row">' +
  1157. '<div class="block-field">' +
  1158. '<label class="block-field-label">Primary Button Text</label>' +
  1159. '<input type="text" class="block-field-input" value="' + btn1Val + '" oninput="updateBlockData(' + blockIndex + ', \'button_primary\', this.value)">' +
  1160. '</div>' +
  1161. '<div class="block-field">' +
  1162. '<label class="block-field-label">Primary Button URL</label>' +
  1163. renderLinkInput(btn1Url, 'updateBlockData(' + blockIndex + ', \'button_primary_url\', VALUE)') +
  1164. '</div>' +
  1165. '</div>' +
  1166. '<div class="form-row">' +
  1167. '<div class="block-field">' +
  1168. '<label class="block-field-label">Secondary Button Text</label>' +
  1169. '<input type="text" class="block-field-input" value="' + btn2Val + '" oninput="updateBlockData(' + blockIndex + ', \'button_secondary\', this.value)">' +
  1170. '</div>' +
  1171. '<div class="block-field">' +
  1172. '<label class="block-field-label">Secondary Button URL</label>' +
  1173. renderLinkInput(btn2Url, 'updateBlockData(' + blockIndex + ', \'button_secondary_url\', VALUE)') +
  1174. '</div>' +
  1175. '</div>';
  1176. }
  1177. ​
  1178. case 'text':
  1179. return `
  1180. <div class="block-field">
  1181. <label class="block-field-label">Title (optional)</label>
  1182. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1183. </div>
  1184. <div class="block-field">
  1185. <label class="block-field-label">Content</label>
  1186. <textarea data-rich="1" class="block-field-input block-field-textarea" style="min-height: 200px;" oninput="updateBlockData(${blockIndex}, 'content', this.value)">${escapeHtml(block.data?.content || '')}</textarea>
  1187. </div>
  1188. `;
  1189. ​
  1190. case 'image':
  1191. return `
  1192. <div class="block-field">
  1193. <label class="block-field-label">Image URL</label>
  1194. <input type="text" data-media="image" class="block-field-input" value="${escapeHtml(block.data?.url || '')}" oninput="updateBlockData(${blockIndex}, 'url', this.value)" placeholder="https://...">
  1195. </div>
  1196. <div class="form-row">
  1197. <div class="block-field">
  1198. <label class="block-field-label">Alt Text</label>
  1199. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.alt || '')}" oninput="updateBlockData(${blockIndex}, 'alt', this.value)">
  1200. </div>
  1201. <div class="block-field">
  1202. <label class="block-field-label">Caption (optional)</label>
  1203. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.caption || '')}" oninput="updateBlockData(${blockIndex}, 'caption', this.value)">
  1204. </div>
  1205. </div>
  1206. `;
  1207. ​
  1208. case 'audio':
  1209. return `
  1210. <div class="form-row">
  1211. <div class="block-field">
  1212. <label class="block-field-label">Title</label>
  1213. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1214. </div>
  1215. <div class="block-field">
  1216. <label class="block-field-label">Subtitle</label>
  1217. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1218. </div>
  1219. </div>
  1220. <div class="block-field">
  1221. <label class="block-field-label">Audio File URL</label>
  1222. <input type="text" data-media="audio" class="block-field-input" value="${escapeHtml(block.data?.url || '')}" oninput="updateBlockData(${blockIndex}, 'url', this.value)" placeholder="https://example.com/audio.mp3">
  1223. </div>
  1224. <div class="form-row">
  1225. <div class="block-field">
  1226. <label class="block-field-label">Music Link URL (Optional)</label>
  1227. <input type="url" class="block-field-input" value="${escapeHtml(block.data?.music_link || '')}" oninput="updateBlockData(${blockIndex}, 'music_link', this.value)" placeholder="https://example.com/download">
  1228. </div>
  1229. <div class="block-field">
  1230. <label class="block-field-label">Music Link Text</label>
  1231. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.music_link_text || 'Download')}" oninput="updateBlockData(${blockIndex}, 'music_link_text', this.value)">
  1232. </div>
  1233. </div>
  1234. `;
  1235. ​
  1236. case 'countdown':
  1237. return `
  1238. <div class="form-row">
  1239. <div class="block-field">
  1240. <label class="block-field-label">Title</label>
  1241. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1242. </div>
  1243. <div class="block-field">
  1244. <label class="block-field-label">Subtitle</label>
  1245. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1246. </div>
  1247. </div>
  1248. <div class="form-row">
  1249. <div class="block-field">
  1250. <label class="block-field-label">Target Date</label>
  1251. <input type="date" class="block-field-input" value="${escapeHtml(block.data?.target_date || '')}" oninput="updateBlockData(${blockIndex}, 'target_date', this.value)">
  1252. </div>
  1253. <div class="block-field">
  1254. <label class="block-field-label">Target Time</label>
  1255. <input type="time" class="block-field-input" value="${escapeHtml(block.data?.target_time || '00:00')}" oninput="updateBlockData(${blockIndex}, 'target_time', this.value)">
  1256. </div>
  1257. </div>
  1258. `;
  1259. ​
  1260. case 'newsletter':
  1261. return `
  1262. <div class="block-field">
  1263. <label class="block-field-label">Title</label>
  1264. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1265. </div>
  1266. <div class="block-field">
  1267. <label class="block-field-label">Subtitle</label>
  1268. <textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">${escapeHtml(block.data?.subtitle || '')}</textarea>
  1269. </div>
  1270. <div class="form-row">
  1271. <div class="block-field">
  1272. <label class="block-field-label">Input Placeholder</label>
  1273. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.placeholder || '')}" oninput="updateBlockData(${blockIndex}, 'placeholder', this.value)">
  1274. </div>
  1275. <div class="block-field">
  1276. <label class="block-field-label">Button Text</label>
  1277. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.button_text || '')}" oninput="updateBlockData(${blockIndex}, 'button_text', this.value)">
  1278. </div>
  1279. </div>
  1280. `;
  1281. ​
  1282. case 'html':
  1283. return `
  1284. <div class="block-field">
  1285. <label class="block-field-label">Custom HTML Code</label>
  1286. <textarea class="block-field-input block-field-textarea" style="min-height: 300px; font-family: ui-monospace, monospace; font-size: 0.8125rem;" oninput="updateBlockData(${blockIndex}, 'html', this.value)">${escapeHtml(block.data?.html || '')}</textarea>
  1287. <p style="font-size: 0.75rem; color: var(--text-subtle); margin-top: 8px;">You can use any HTML, CSS (in style tags), and JavaScript (in script tags).</p>
  1288. </div>
  1289. `;
  1290. ​
  1291. case 'image-text':
  1292. return `
  1293. <div class="form-row">
  1294. <div class="block-field">
  1295. <label class="block-field-label">Section Title</label>
  1296. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1297. </div>
  1298. <div class="block-field">
  1299. <label class="block-field-label">Section Subtitle</label>
  1300. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1301. </div>
  1302. </div>
  1303. <div class="block-field">
  1304. <label class="block-field-label">Image URL</label>
  1305. <input type="text" data-media="image" class="block-field-input" value="${escapeHtml(block.data?.image_url || '')}" oninput="updateBlockData(${blockIndex}, 'image_url', this.value)" placeholder="https://...">
  1306. </div>
  1307. <div class="form-row">
  1308. <div class="block-field">
  1309. <label class="block-field-label">Image Alt Text</label>
  1310. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.image_alt || '')}" oninput="updateBlockData(${blockIndex}, 'image_alt', this.value)">
  1311. </div>
  1312. <div class="block-field">
  1313. <label class="block-field-label">Image Position</label>
  1314. <select class="block-field-input" onchange="updateBlockData(${blockIndex}, 'image_position', this.value)">
  1315. <option value="left" ${block.data?.image_position === 'left' ? 'selected' : ''}>Left</option>
  1316. <option value="right" ${block.data?.image_position === 'right' ? 'selected' : ''}>Right</option>
  1317. </select>
  1318. </div>
  1319. </div>
  1320. <div class="block-field">
  1321. <label class="block-field-label">Content Text</label>
  1322. <textarea data-rich="1" class="block-field-input block-field-textarea" style="min-height: 150px;" oninput="updateBlockData(${blockIndex}, 'content', this.value)">${escapeHtml(block.data?.content || '')}</textarea>
  1323. </div>
  1324. <div class="form-row">
  1325. <div class="block-field">
  1326. <label class="block-field-label">Button Text (optional)</label>
  1327. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.button_text || '')}" oninput="updateBlockData(${blockIndex}, 'button_text', this.value)">
  1328. </div>
  1329. <div class="block-field">
  1330. <label class="block-field-label">Button URL</label>
  1331. ${renderLinkInput(block.data?.button_url || '#', 'updateBlockData(' + blockIndex + ', \'button_url\', VALUE)')}
  1332. </div>
  1333. </div>
  1334. `;
  1335. ​
  1336. case 'product-cards':
  1337. return `
  1338. <div class="form-row">
  1339. <div class="block-field">
  1340. <label class="block-field-label">Section Title</label>
  1341. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1342. </div>
  1343. <div class="block-field">
  1344. <label class="block-field-label">Section Subtitle</label>
  1345. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1346. </div>
  1347. </div>
  1348. <div class="block-field">
  1349. <label class="block-field-label">Products</label>
  1350. <div class="repeater-items" id="products-items-${blockIndex}">
  1351. ${(block.data?.products || []).map((item, i) => `
  1352. <div class="repeater-item">
  1353. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  1354. <span class="repeater-item-title">${escapeHtml(item.title || 'New Product')}</span>
  1355. <div class="repeater-item-actions">
  1356. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'products', ${i})" style="color: var(--error);">Remove</button>
  1357. </div>
  1358. </div>
  1359. <div class="repeater-item-content">
  1360. <div class="form-row">
  1361. <div class="block-field">
  1362. <label class="block-field-label">Product Title</label>
  1363. <input type="text" class="block-field-input" value="${escapeHtml(item.title || '')}" oninput="updateRepeaterItem(${blockIndex}, 'products', ${i}, 'title', this.value)">
  1364. </div>
  1365. <div class="block-field">
  1366. <label class="block-field-label">Product Image (48x48)</label>
  1367. <input type="text" data-media="image" class="block-field-input" value="${escapeHtml(item.image || '')}" oninput="updateRepeaterItem(${blockIndex}, 'products', ${i}, 'image', this.value)" placeholder="https://...">
  1368. </div>
  1369. </div>
  1370. <div class="block-field">
  1371. <label class="block-field-label">Description</label>
  1372. <textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateRepeaterItem(${blockIndex}, 'products', ${i}, 'description', this.value)">${escapeHtml(item.description || '')}</textarea>
  1373. </div>
  1374. <div class="block-field">
  1375. <label class="block-field-label">Features (one per line)</label>
  1376. <textarea class="block-field-input block-field-textarea" oninput="updateRepeaterItem(${blockIndex}, 'products', ${i}, 'features', this.value.split('\\n').filter(f => f.trim()))">${(item.features || []).join('\\n')}</textarea>
  1377. </div>
  1378. <div class="form-row">
  1379. <div class="block-field">
  1380. <label class="block-field-label">Button Text</label>
  1381. <input type="text" class="block-field-input" value="${escapeHtml(item.button_text || '')}" oninput="updateRepeaterItem(${blockIndex}, 'products', ${i}, 'button_text', this.value)">
  1382. </div>
  1383. <div class="block-field">
  1384. <label class="block-field-label">Button URL</label>
  1385. <input type="text" class="block-field-input" value="${escapeHtml(item.button_url || '#')}" oninput="updateRepeaterItem(${blockIndex}, 'products', ${i}, 'button_url', this.value)">
  1386. </div>
  1387. </div>
  1388. </div>
  1389. </div>
  1390. `).join('')}
  1391. </div>
  1392. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'products', {title: '', image: '', description: '', features: [], button_text: 'View', button_url: '#'})">
  1393. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
  1394. Add Product
  1395. </button>
  1396. </div>
  1397. `;
  1398. ​
  1399. case 'video':
  1400. return `
  1401. <div class="form-row">
  1402. <div class="block-field">
  1403. <label class="block-field-label">Section Title</label>
  1404. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1405. </div>
  1406. <div class="block-field">
  1407. <label class="block-field-label">Section Subtitle</label>
  1408. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1409. </div>
  1410. </div>
  1411. <div class="form-row">
  1412. <div class="block-field">
  1413. <label class="block-field-label">Video URL</label>
  1414. <input type="text" data-media="video" class="block-field-input" value="${escapeHtml(block.data?.url || '')}" oninput="updateBlockData(${blockIndex}, 'url', this.value)" placeholder="YouTube, Facebook, or direct video URL">
  1415. </div>
  1416. <div class="block-field">
  1417. <label class="block-field-label">Video Type</label>
  1418. <select class="block-field-input" onchange="updateBlockData(${blockIndex}, 'type', this.value)">
  1419. <option value="url" ${block.data?.type === 'url' ? 'selected' : ''}>Direct URL</option>
  1420. <option value="youtube" ${block.data?.type === 'youtube' ? 'selected' : ''}>YouTube</option>
  1421. <option value="facebook" ${block.data?.type === 'facebook' ? 'selected' : ''}>Facebook</option>
  1422. </select>
  1423. </div>
  1424. </div>
  1425. <div class="block-field">
  1426. <label class="block-field-label">Poster Image URL (Thumbnail)</label>
  1427. <input type="text" data-media="image" class="block-field-input" value="${escapeHtml(block.data?.posterUrl || '')}" oninput="updateBlockData(${blockIndex}, 'posterUrl', this.value)" placeholder="https://example.com/image.jpg">
  1428. </div>
  1429. <div class="block-field">
  1430. <label class="block-field-label">Caption</label>
  1431. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.caption || '')}" oninput="updateBlockData(${blockIndex}, 'caption', this.value)">
  1432. </div>
  1433. `;
  1434. ​
  1435. case 'gallery':
  1436. return `
  1437. <div class="form-row">
  1438. <div class="block-field">
  1439. <label class="block-field-label">Section Title</label>
  1440. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1441. </div>
  1442. <div class="block-field">
  1443. <label class="block-field-label">Section Subtitle</label>
  1444. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1445. </div>
  1446. </div>
  1447. <div class="block-field">
  1448. <label class="block-field-label">Images</label>
  1449. <div class="repeater-items" id="images-items-${blockIndex}">
  1450. ${(block.data?.images || []).map((item, i) => `
  1451. <div class="repeater-item">
  1452. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  1453. <span class="repeater-item-title">${escapeHtml(item.alt || 'Image ' + (i + 1))}</span>
  1454. <div class="repeater-item-actions">
  1455. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'images', ${i})" style="color: var(--error);">Remove</button>
  1456. </div>
  1457. </div>
  1458. <div class="repeater-item-content">
  1459. <div class="form-row">
  1460. <div class="block-field">
  1461. <label class="block-field-label">Image URL</label>
  1462. <input type="text" data-media="image" class="block-field-input" value="${escapeHtml(item.url || '')}" oninput="updateRepeaterItem(${blockIndex}, 'images', ${i}, 'url', this.value)" placeholder="https://...">
  1463. </div>
  1464. <div class="block-field">
  1465. <label class="block-field-label">Alt Text</label>
  1466. <input type="text" class="block-field-input" value="${escapeHtml(item.alt || '')}" oninput="updateRepeaterItem(${blockIndex}, 'images', ${i}, 'alt', this.value)">
  1467. </div>
  1468. </div>
  1469. <div class="block-field">
  1470. <label class="block-field-label">Caption</label>
  1471. <input type="text" class="block-field-input" value="${escapeHtml(item.caption || '')}" oninput="updateRepeaterItem(${blockIndex}, 'images', ${i}, 'caption', this.value)">
  1472. </div>
  1473. </div>
  1474. </div>
  1475. `).join('')}
  1476. </div>
  1477. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'images', {url: '', alt: '', caption: ''})">
  1478. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
  1479. Add Image
  1480. </button>
  1481. </div>
  1482. `;
  1483. ​
  1484. case 'faq':
  1485. return `
  1486. <div class="form-row">
  1487. <div class="block-field">
  1488. <label class="block-field-label">Section Title</label>
  1489. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1490. </div>
  1491. <div class="block-field">
  1492. <label class="block-field-label">Section Subtitle</label>
  1493. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1494. </div>
  1495. </div>
  1496. <div class="block-field">
  1497. <label class="block-field-label">FAQ Items</label>
  1498. <div class="repeater-items" id="items-items-${blockIndex}">
  1499. ${(block.data?.items || []).map((item, i) => `
  1500. <div class="repeater-item">
  1501. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  1502. <span class="repeater-item-title">${escapeHtml(item.question || 'Question ' + (i + 1))}</span>
  1503. <div class="repeater-item-actions">
  1504. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'items', ${i})" style="color: var(--error);">Remove</button>
  1505. </div>
  1506. </div>
  1507. <div class="repeater-item-content">
  1508. <div class="block-field">
  1509. <label class="block-field-label">Question</label>
  1510. <input type="text" class="block-field-input" value="${escapeHtml(item.question || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'question', this.value)">
  1511. </div>
  1512. <div class="block-field">
  1513. <label class="block-field-label">Answer</label>
  1514. <textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'answer', this.value)">${escapeHtml(item.answer || '')}</textarea>
  1515. </div>
  1516. </div>
  1517. </div>
  1518. `).join('')}
  1519. </div>
  1520. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'items', {question: '', answer: ''})">
  1521. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
  1522. Add Question
  1523. </button>
  1524. </div>
  1525. `;
  1526. ​
  1527. case 'team':
  1528. return `
  1529. <div class="form-row">
  1530. <div class="block-field">
  1531. <label class="block-field-label">Section Title</label>
  1532. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1533. </div>
  1534. <div class="block-field">
  1535. <label class="block-field-label">Section Subtitle</label>
  1536. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1537. </div>
  1538. </div>
  1539. <div class="block-field">
  1540. <label class="block-field-label">Team Members</label>
  1541. <div class="repeater-items" id="members-items-${blockIndex}">
  1542. ${(block.data?.members || []).map((member, i) => `
  1543. <div class="repeater-item">
  1544. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  1545. <span class="repeater-item-title">${escapeHtml(member.name || 'Team Member ' + (i + 1))}</span>
  1546. <div class="repeater-item-actions">
  1547. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'members', ${i})" style="color: var(--error);">Remove</button>
  1548. </div>
  1549. </div>
  1550. <div class="repeater-item-content">
  1551. <div class="form-row">
  1552. <div class="block-field">
  1553. <label class="block-field-label">Name</label>
  1554. <input type="text" class="block-field-input" value="${escapeHtml(member.name || '')}" oninput="updateRepeaterItem(${blockIndex}, 'members', ${i}, 'name', this.value)">
  1555. </div>
  1556. <div class="block-field">
  1557. <label class="block-field-label">Role</label>
  1558. <input type="text" class="block-field-input" value="${escapeHtml(member.role || '')}" oninput="updateRepeaterItem(${blockIndex}, 'members', ${i}, 'role', this.value)">
  1559. </div>
  1560. </div>
  1561. <div class="form-row">
  1562. <div class="block-field">
  1563. <label class="block-field-label">Photo URL</label>
  1564. <input type="text" data-media="image" class="block-field-input" value="${escapeHtml(member.image || '')}" oninput="updateRepeaterItem(${blockIndex}, 'members', ${i}, 'image', this.value)" placeholder="https://... (optional)">
  1565. </div>
  1566. <div class="block-field">
  1567. <label class="block-field-label">Initials (if no photo)</label>
  1568. <input type="text" class="block-field-input" value="${escapeHtml(member.initials || '')}" oninput="updateRepeaterItem(${blockIndex}, 'members', ${i}, 'initials', this.value)" maxlength="2">
  1569. </div>
  1570. </div>
  1571. <div class="block-field">
  1572. <label class="block-field-label">Bio</label>
  1573. <textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateRepeaterItem(${blockIndex}, 'members', ${i}, 'bio', this.value)">${escapeHtml(member.bio || '')}</textarea>
  1574. </div>
  1575. <div class="block-field">
  1576. <label class="block-field-label">Social Links (JSON)</label>
  1577. <textarea class="block-field-input block-field-textarea" oninput="try { updateRepeaterItem(${blockIndex}, 'members', ${i}, 'social', JSON.parse(this.value)); } catch(e) {}" placeholder='[{"platform": "twitter", "url": "#"}]'>${escapeHtml(JSON.stringify(member.social || []))}</textarea>
  1578. </div>
  1579. </div>
  1580. </div>
  1581. `).join('')}
  1582. </div>
  1583. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'members', {name: '', role: '', initials: '', image: '', bio: '', social: []})">
  1584. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
  1585. Add Team Member
  1586. </button>
  1587. </div>
  1588. `;
  1589. ​
  1590. default:
  1591. return '<p style="color: var(--text-muted);">Unknown block type</p>';
  1592. }
  1593. }
  1594. ​
  1595. function renderLinkInput(value, callback) {
  1596. const id = 'link-' + Math.random().toString(36).substr(2, 9);
  1597. return '<div class="link-input-wrapper">' +
  1598. '<input type="text" class="block-field-input" id="' + id + '" value="' + escapeHtml(value) + '" oninput="' + callback.replace('VALUE', 'this.value') + '">' +
  1599. '<button type="button" class="link-page-btn" onclick="showLinkSelector(\'' + id + '\', \'' + callback.replace(/'/g, "\\'") + '\')" title="Select page">' +
  1600. '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>' +
  1601. '</button>' +
  1602. '</div>';
  1603. }
  1604. ​
  1605. function updateBlockData(blockIndex, key, value) {
  1606. if (!blocks[blockIndex].data) blocks[blockIndex].data = {};
  1607. blocks[blockIndex].data[key] = value;
  1608. schedulePreviewUpdate();
  1609. }
  1610. ​
  1611. function updateRepeaterItem(blockIndex, key, itemIndex, field, value) {
  1612. if (!blocks[blockIndex].data) blocks[blockIndex].data = {};
  1613. if (!blocks[blockIndex].data[key]) blocks[blockIndex].data[key] = [];
  1614. if (!blocks[blockIndex].data[key][itemIndex]) blocks[blockIndex].data[key][itemIndex] = {};
  1615. blocks[blockIndex].data[key][itemIndex][field] = value;
  1616. schedulePreviewUpdate();
  1617. }
  1618. ​
  1619. function addRepeaterItem(blockIndex, key, defaultItem) {
  1620. if (!blocks[blockIndex].data) blocks[blockIndex].data = {};
  1621. if (!blocks[blockIndex].data[key]) blocks[blockIndex].data[key] = [];
  1622. blocks[blockIndex].data[key].push({ ...defaultItem });
  1623. schedulePreviewUpdate();
  1624. renderBlocks();
  1625. }
  1626. ​
  1627. function removeRepeaterItem(blockIndex, key, itemIndex) {
  1628. blocks[blockIndex].data[key].splice(itemIndex, 1);
  1629. renderBlocks();
  1630. schedulePreviewUpdate();
  1631. }
  1632. ​
  1633. function toggleBlock(index) {
  1634. const items = document.querySelectorAll('.block-item');
  1635. items[index]?.classList.toggle('expanded');
  1636. }
  1637. ​
  1638. function expandAllBlocks() {
  1639. document.querySelectorAll('.block-item').forEach(el => el.classList.add('expanded'));
  1640. }
  1641. ​
  1642. function collapseAllBlocks() {
  1643. document.querySelectorAll('.block-item').forEach(el => el.classList.remove('expanded'));
  1644. }
  1645. ​
  1646. function addBlock(type) {
  1647. const defaultId = getDefaultBlockId(type);
  1648. const newBlock = {
  1649. id: defaultId,
  1650. type: type,
  1651. data: getDefaultBlockData(type)
  1652. };
  1653. blocks.push(newBlock);
  1654. renderBlocks();
  1655. hideAddBlockModal();
  1656. schedulePreviewUpdate();
  1657. ​
  1658. setTimeout(() => {
  1659. const items = document.querySelectorAll('.block-item');
  1660. items[items.length - 1]?.classList.add('expanded');
  1661. items[items.length - 1]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
  1662. }, 50);
  1663. }
  1664. ​
  1665. function getDefaultBlockId(type) {
  1666. const baseName = type;
  1667. const existingIds = blocks
  1668. .filter(b => b.type === type && b.id)
  1669. .map(b => b.id);
  1670. ​
  1671. if (existingIds.length === 0) {
  1672. return baseName;
  1673. }
  1674. ​
  1675. let counter = 2;
  1676. while (existingIds.includes(baseName + '-' + counter)) {
  1677. counter++;
  1678. }
  1679. return baseName + '-' + counter;
  1680. }
  1681. ​
  1682. function getDefaultBlockData(type) {
  1683. switch (type) {
  1684. case 'hero':
  1685. return { badge: '', title: '', subtitle: '', button_primary: '', button_primary_url: '#', button_secondary: '', button_secondary_url: '#' };
  1686. case 'stats':
  1687. return { items: [] };
  1688. case 'features':
  1689. return { title: '', subtitle: '', items: [] };
  1690. case 'testimonials':
  1691. return { title: '', subtitle: '', items: [] };
  1692. case 'pricing':
  1693. return { title: '', subtitle: '', items: [] };
  1694. case 'cta':
  1695. return { title: '', subtitle: '', button_primary: '', button_primary_url: '#', button_secondary: '', button_secondary_url: '#' };
  1696. case 'text':
  1697. return { title: '', content: '' };
  1698. case 'image':
  1699. return { url: '', alt: '', caption: '' };
  1700. case 'image-text':
  1701. return { image_url: '', image_alt: '', title: '', subtitle: '', content: '', image_position: 'left', button_text: '', button_url: '#' };
  1702. case 'product-cards':
  1703. return { title: '', subtitle: '', products: [] };
  1704. case 'video':
  1705. return { title: 'Watch Our Video', subtitle: '', url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', type: 'youtube', caption: '' };
  1706. case 'gallery':
  1707. return {
  1708. title: 'Photo Gallery', subtitle: '', images: [
  1709. { url: 'https://placehold.co/800x600', alt: 'Gallery image 1', caption: '' },
  1710. { url: 'https://placehold.co/800x600', alt: 'Gallery image 2', caption: '' },
  1711. { url: 'https://placehold.co/800x600', alt: 'Gallery image 3', caption: '' }
  1712. ]
  1713. };
  1714. case 'faq':
  1715. return {
  1716. title: 'Frequently Asked Questions', subtitle: '', items: [
  1717. { question: 'What is this?', answer: 'This is a great product that helps you achieve your goals.' },
  1718. { question: 'How does it work?', answer: 'It works by combining cutting-edge technology with user-friendly design.' },
  1719. { question: 'How much does it cost?', answer: 'We offer both free and premium plans to suit your needs.' }
  1720. ]
  1721. };
  1722. case 'team':
  1723. return {
  1724. title: 'Meet Our Team', subtitle: '', members: [
  1725. { name: 'John Doe', role: 'CEO & Founder', initials: 'JD', image: '', bio: '', social: [{ platform: 'twitter', url: '#' }, { platform: 'linkedin', url: '#' }] },
  1726. { name: 'Jane Smith', role: 'Head of Design', initials: 'JS', image: '', bio: '', social: [{ platform: 'twitter', url: '#' }, { platform: 'linkedin', url: '#' }] }
  1727. ]
  1728. };
  1729. case 'audio':
  1730. return { title: 'Listen Now', subtitle: '', url: '', music_link: '', music_link_text: 'Download' };
  1731. case 'countdown':
  1732. return { title: 'Coming Soon', subtitle: '', target_date: '', target_time: '00:00' };
  1733. case 'newsletter':
  1734. return { title: 'Subscribe to Our Newsletter', subtitle: 'Get the latest updates delivered to your inbox.', button_text: 'Subscribe', placeholder: 'Enter your email' };
  1735. case 'html':
  1736. return { html: '' };
  1737. default:
  1738. return {};
  1739. }
  1740. }
  1741. ​
  1742. function duplicateBlock(index) {
  1743. const newBlock = JSON.parse(JSON.stringify(blocks[index]));
  1744. const baseType = blocks[index].type;
  1745. newBlock.id = getDefaultBlockId(baseType);
  1746. blocks.splice(index + 1, 0, newBlock);
  1747. renderBlocks();
  1748. }
  1749. ​
  1750. function deleteBlock(index) {
  1751. if (confirm('Are you sure you want to delete this block?')) {
  1752. blocks.splice(index, 1);
  1753. renderBlocks();
  1754. schedulePreviewUpdate();
  1755. }
  1756. }
  1757. ​
  1758. function moveBlockUp(index) {
  1759. if (index > 0) {
  1760. [blocks[index], blocks[index - 1]] = [blocks[index - 1], blocks[index]];
  1761. renderBlocks();
  1762. }
  1763. }
  1764. ​
  1765. function moveBlockDown(index) {
  1766. if (index < blocks.length - 1) {
  1767. [blocks[index], blocks[index + 1]] = [blocks[index + 1], blocks[index]];
  1768. renderBlocks();
  1769. }
  1770. }
  1771. ​
  1772. function handleDragStart(e) {
  1773. draggedBlock = this;
  1774. this.classList.add('dragging');
  1775. e.dataTransfer.effectAllowed = 'move';
  1776. }
  1777. ​
  1778. function handleDragEnd(e) {
  1779. this.classList.remove('dragging');
  1780. document.querySelectorAll('.block-item').forEach(el => el.classList.remove('drag-over'));
  1781. draggedBlock = null;
  1782. }
  1783. ​
  1784. function handleDragOver(e) {
  1785. e.preventDefault();
  1786. e.dataTransfer.dropEffect = 'move';
  1787. this.classList.add('drag-over');
  1788. }
  1789. ​
  1790. function handleDragLeave(e) {
  1791. this.classList.remove('drag-over');
  1792. }
  1793. ​
  1794. function handleDrop(e) {
  1795. e.preventDefault();
  1796. this.classList.remove('drag-over');
  1797. ​
  1798. if (draggedBlock && draggedBlock !== this) {
  1799. const fromIndex = parseInt(draggedBlock.dataset.index);
  1800. const toIndex = parseInt(this.dataset.index);
  1801. ​
  1802. const [movedBlock] = blocks.splice(fromIndex, 1);
  1803. blocks.splice(toIndex, 0, movedBlock);
  1804. renderBlocks();
  1805. schedulePreviewUpdate();
  1806. }
  1807. }
  1808. ​
  1809. ​
  1810. const NE_SVG = {
  1811. plus: '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" viewBox="0 0 24 24" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
  1812. up: '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" width="16" height="16"><polyline points="18 15 12 9 6 15"/></svg>',
  1813. down: '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" width="16" height="16"><polyline points="6 9 12 15 18 9"/></svg>',
  1814. trash: '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" width="16" height="16"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>',
  1815. image: '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" width="18" height="18"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>'
  1816. };
  1817. ​
  1818. const SOCIAL_PLATFORMS = [
  1819. ['twitter', 'Twitter/X'], ['github', 'GitHub'], ['linkedin', 'LinkedIn'], ['facebook', 'Facebook'],
  1820. ['instagram', 'Instagram'], ['youtube', 'YouTube'], ['tiktok', 'TikTok'], ['pinterest', 'Pinterest'],
  1821. ['reddit', 'Reddit'], ['discord', 'Discord'], ['snapchat', 'Snapchat'], ['whatsapp', 'Whatsapp'],
  1822. ['telegram', 'Telegram'], ['twitch', 'Twitch'], ['spotify', 'Spotify'], ['medium', 'Medium'],
  1823. ['slack', 'Slack'], ['dribbble', 'Dribbble'], ['mastodon', 'Mastodon'], ['patreon', 'Patreon'],
  1824. ['kofi', 'Ko-Fi'], ['vimeo', 'Vimeo'], ['tumblr', 'Tumblr'], ['stackoverflow', 'Stack Overflow'],
  1825. ['gitlab', 'GitLab'], ['bluesky', 'Bluesky'], ['line', 'Line'], ['wechat', 'WeChat'],
  1826. ['flatlypage', 'FlatlyPage CMS'], ['xing', 'Xing']
  1827. ];
  1828. ​
  1829. let navIconPickerIndex = -1;
  1830. let navButtonIconPickerIndex = -1;
  1831. ​
  1832. function neIconSvg(id) {
  1833. return `<svg class="ne-icon-svg" aria-hidden="true"><use href="${FLATLY_BASE_URL}/assets/icons.svg#${id}"></use></svg>`;
  1834. }
  1835. ​
  1836. function neActions(moveFn, removeFn, index, count) {
  1837. return `
  1838. <div class="ne-actions">
  1839. <button type="button" class="ne-btn" title="Move up" ${index === 0 ? 'disabled' : ''} onclick="${moveFn}(${index}, -1)">${NE_SVG.up}</button>
  1840. <button type="button" class="ne-btn" title="Move down" ${index >= count - 1 ? 'disabled' : ''} onclick="${moveFn}(${index}, 1)">${NE_SVG.down}</button>
  1841. <button type="button" class="ne-btn ne-btn-danger" title="Remove" onclick="${removeFn}(${index})">${NE_SVG.trash}</button>
  1842. </div>`;
  1843. }
  1844. ​
  1845. function neMove(list, index, dir) {
  1846. const target = index + dir;
  1847. if (!list || target < 0 || target >= list.length) return false;
  1848. [list[index], list[target]] = [list[target], list[index]];
  1849. return true;
  1850. }
  1851. ​
  1852. function neEmpty(text) {
  1853. return `<div class="ne-empty">${text}</div>`;
  1854. }
  1855. ​
  1856. function neFocusLast(container, selector) {
  1857. if (!container) return;
  1858. const fields = container.querySelectorAll(selector);
  1859. if (fields.length) fields[fields.length - 1].focus();
  1860. }
  1861. ​
  1862. function renderNavLinks() {
  1863. const container = document.getElementById('navLinksContainer');
  1864. if (!container) return;
  1865. ​
  1866. const hasIcons = Array.isArray(ICON_IDS) && ICON_IDS.length > 0;
  1867. ​
  1868. container.innerHTML = navLinks.length ? `<div class="ne-list">${navLinks.map((link, i) => `
  1869. <div class="ne-item">
  1870. <div class="ne-row">
  1871. ${hasIcons ? `<button type="button" class="ne-icon-btn ${navIconPickerIndex === i ? 'active' : ''}" title="Choose icon" onclick="toggleNavIconPicker(${i})">${link.icon ? neIconSvg(escapeHtml(link.icon)) : NE_SVG.image}</button>` : ''}
  1872. <input type="text" class="ne-input ne-nav-label" value="${escapeHtml(link.label || '')}" placeholder="Label" oninput="navLinks[${i}].label = this.value">
  1873. <input type="text" class="ne-input" value="${escapeHtml(link.url || '')}" placeholder="URL or #anchor" oninput="navLinks[${i}].url = this.value">
  1874. ${neActions('moveNavLink', 'removeNavLink', i, navLinks.length)}
  1875. </div>
  1876. ${hasIcons && navIconPickerIndex === i ? `
  1877. <div class="ne-picker">
  1878. <div class="ne-picker-head">
  1879. <input type="text" class="ne-input" placeholder="Search icons" oninput="filterNavIcons(this.value, this)">
  1880. <button type="button" class="ne-chip" onclick="setNavLinkIcon(${i}, '')">No icon</button>
  1881. </div>
  1882. <div class="ne-picker-grid">
  1883. ${ICON_IDS.map(id => `<button type="button" class="ne-picker-item ${link.icon === id ? 'selected' : ''}" data-icon="${id}" title="${id.replace('icon-', '')}" onclick="setNavLinkIcon(${i}, '${id}')">${neIconSvg(id)}</button>`).join('')}
  1884. </div>
  1885. </div>` : ''}
  1886. </div>`).join('')}</div>` : neEmpty('No links yet. Add your first navigation link.');
  1887. ​
  1888. if (navIconPickerIndex >= 0) {
  1889. const search = container.querySelector('.ne-picker .ne-input');
  1890. if (search) search.focus();
  1891. }
  1892. }
  1893. ​
  1894. function toggleNavIconPicker(index) {
  1895. navIconPickerIndex = navIconPickerIndex === index ? -1 : index;
  1896. renderNavLinks();
  1897. }
  1898. ​
  1899. function filterNavIcons(query, input) {
  1900. const q = query.trim().toLowerCase();
  1901. const scope = input && input.closest(".ne-picker") ? input.closest(".ne-picker") : document;
  1902. scope.querySelectorAll(".ne-picker-item").forEach(btn => {
  1903. btn.style.display = !q || btn.dataset.icon.includes(q) ? "" : "none";
  1904. });
  1905. }
  1906. ​
  1907. function setNavLinkIcon(index, icon) {
  1908. if (!navLinks[index]) return;
  1909. if (icon) {
  1910. navLinks[index].icon = icon;
  1911. } else {
  1912. delete navLinks[index].icon;
  1913. }
  1914. navIconPickerIndex = -1;
  1915. renderNavLinks();
  1916. }
  1917. ​
  1918. function addNavLink() {
  1919. navLinks.push({ label: '', url: '#' });
  1920. navIconPickerIndex = -1;
  1921. renderNavLinks();
  1922. neFocusLast(document.getElementById('navLinksContainer'), '.ne-nav-label');
  1923. }
  1924. ​
  1925. function removeNavLink(index) {
  1926. navLinks.splice(index, 1);
  1927. navIconPickerIndex = -1;
  1928. renderNavLinks();
  1929. }
  1930. ​
  1931. function moveNavLink(index, dir) {
  1932. if (neMove(navLinks, index, dir)) {
  1933. navIconPickerIndex = -1;
  1934. renderNavLinks();
  1935. }
  1936. }
  1937. ​
  1938. function renderNavButtons() {
  1939. const container = document.getElementById('navButtonsContainer');
  1940. if (!container) return;
  1941. ​
  1942. const hasIcons = Array.isArray(ICON_IDS) && ICON_IDS.length > 0;
  1943. ​
  1944. container.innerHTML = navButtons.length ? `<div class="ne-list">${navButtons.map((btn, i) => `
  1945. <div class="ne-item">
  1946. <div class="ne-row">
  1947. ${hasIcons ? `<button type="button" class="ne-icon-btn ${navButtonIconPickerIndex === i ? 'active' : ''}" title="Choose icon" onclick="toggleNavButtonIconPicker(${i})">${btn.icon ? neIconSvg(escapeHtml(btn.icon)) : NE_SVG.image}</button>` : ''}
  1948. <input type="text" class="ne-input ne-btn-label" value="${escapeHtml(btn.label || '')}" placeholder="Label" oninput="navButtons[${i}].label = this.value">
  1949. <input type="text" class="ne-input" value="${escapeHtml(btn.url || '')}" placeholder="URL" oninput="navButtons[${i}].url = this.value">
  1950. <select class="ne-input ne-select" onchange="navButtons[${i}].style = this.value">
  1951. <option value="ghost" ${btn.style === 'ghost' ? 'selected' : ''}>Ghost</option>
  1952. <option value="primary" ${btn.style === 'primary' ? 'selected' : ''}>Primary</option>
  1953. <option value="outline" ${btn.style === 'outline' ? 'selected' : ''}>Outline</option>
  1954. </select>
  1955. ${neActions('moveNavButton', 'removeNavButton', i, navButtons.length)}
  1956. </div>
  1957. ${hasIcons && navButtonIconPickerIndex === i ? `
  1958. <div class="ne-picker">
  1959. <div class="ne-picker-head">
  1960. <input type="text" class="ne-input" placeholder="Search icons" oninput="filterNavIcons(this.value, this)">
  1961. <button type="button" class="ne-chip" onclick="setNavButtonIcon(${i}, '')">No icon</button>
  1962. </div>
  1963. <div class="ne-picker-grid">
  1964. ${ICON_IDS.map(id => `<button type="button" class="ne-picker-item ${btn.icon === id ? 'selected' : ''}" data-icon="${id}" title="${id.replace('icon-', '')}" onclick="setNavButtonIcon(${i}, '${id}')">${neIconSvg(id)}</button>`).join('')}
  1965. </div>
  1966. </div>` : ''}
  1967. </div>`).join('')}</div>` : neEmpty('No buttons yet.');
  1968. ​
  1969. if (navButtonIconPickerIndex >= 0) {
  1970. const search = container.querySelector('.ne-picker .ne-input');
  1971. if (search) search.focus();
  1972. }
  1973. }
  1974. ​
  1975. function toggleNavButtonIconPicker(index) {
  1976. navButtonIconPickerIndex = navButtonIconPickerIndex === index ? -1 : index;
  1977. renderNavButtons();
  1978. }
  1979. ​
  1980. function setNavButtonIcon(index, icon) {
  1981. if (!navButtons[index]) return;
  1982. if (icon) {
  1983. navButtons[index].icon = icon;
  1984. } else {
  1985. delete navButtons[index].icon;
  1986. }
  1987. navButtonIconPickerIndex = -1;
  1988. renderNavButtons();
  1989. }
  1990. ​
  1991. function addNavButton() {
  1992. navButtons.push({ label: '', url: '#', style: 'ghost' });
  1993. navButtonIconPickerIndex = -1;
  1994. renderNavButtons();
  1995. neFocusLast(document.getElementById('navButtonsContainer'), '.ne-btn-label');
  1996. }
  1997. ​
  1998. function removeNavButton(index) {
  1999. navButtons.splice(index, 1);
  2000. navButtonIconPickerIndex = -1;
  2001. renderNavButtons();
  2002. }
  2003. ​
  2004. function moveNavButton(index, dir) {
  2005. if (neMove(navButtons, index, dir)) {
  2006. navButtonIconPickerIndex = -1;
  2007. renderNavButtons();
  2008. }
  2009. }
  2010. ​
  2011. function ensureFooterColumn(colIndex) {
  2012. if (!footerData.columns) footerData.columns = [];
  2013. if (!footerData.columns[colIndex]) footerData.columns[colIndex] = { title: '', links: [] };
  2014. if (!footerData.columns[colIndex].links) footerData.columns[colIndex].links = [];
  2015. return footerData.columns[colIndex];
  2016. }
  2017. ​
  2018. function updateFooterColumnTitle(colIndex, value) {
  2019. ensureFooterColumn(colIndex).title = value;
  2020. updateFooterData();
  2021. }
  2022. ​
  2023. function updateFooterColumnLinkLabel(colIndex, linkIndex, value) {
  2024. const col = ensureFooterColumn(colIndex);
  2025. if (!col.links[linkIndex]) col.links[linkIndex] = { label: '', url: '' };
  2026. col.links[linkIndex].label = value;
  2027. updateFooterData();
  2028. }
  2029. ​
  2030. function updateFooterColumnLinkUrl(colIndex, linkIndex, value) {
  2031. const col = ensureFooterColumn(colIndex);
  2032. if (!col.links[linkIndex]) col.links[linkIndex] = { label: '', url: '' };
  2033. col.links[linkIndex].url = value;
  2034. updateFooterData();
  2035. }
  2036. ​
  2037. function renderFooterColumns() {
  2038. const container = document.getElementById('footerColumnsContainer');
  2039. if (!container) return;
  2040. if (!footerData.columns) footerData.columns = [];
  2041. ​
  2042. container.innerHTML = footerData.columns.length ? `<div class="ne-columns">${footerData.columns.map((col, i) => {
  2043. if (!col) col = { title: '', links: [] };
  2044. const links = col.links || [];
  2045. return `
  2046. <div class="ne-card">
  2047. <div class="ne-card-head">
  2048. <input type="text" class="ne-input ne-title ne-col-title" value="${escapeHtml(col.title || '')}" placeholder="Column title" oninput="updateFooterColumnTitle(${i}, this.value)">
  2049. ${neActions('moveFooterColumn', 'removeFooterColumn', i, footerData.columns.length)}
  2050. </div>
  2051. <div class="ne-card-body">
  2052. ${links.length ? links.map((link, j) => `
  2053. <div class="ne-row ne-row-compact">
  2054. <input type="text" class="ne-input ne-link-label" value="${escapeHtml(link.label || '')}" placeholder="Label" oninput="updateFooterColumnLinkLabel(${i}, ${j}, this.value)">
  2055. <input type="text" class="ne-input" value="${escapeHtml(link.url || '')}" placeholder="URL" oninput="updateFooterColumnLinkUrl(${i}, ${j}, this.value)">
  2056. <button type="button" class="ne-btn ne-btn-danger" title="Remove link" onclick="removeFooterColumnLink(${i}, ${j})">${NE_SVG.trash}</button>
  2057. </div>`).join('') : neEmpty('No links in this column.')}
  2058. <button type="button" class="ne-add" onclick="addFooterColumnLink(${i})">${NE_SVG.plus} Add Link</button>
  2059. </div>
  2060. </div>`;
  2061. }).join('')}</div>` : neEmpty('No columns yet. Add your first footer column.');
  2062. }
  2063. ​
  2064. function addFooterColumn() {
  2065. if (!footerData.columns) footerData.columns = [];
  2066. footerData.columns.push({ title: '', links: [] });
  2067. renderFooterColumns();
  2068. updateFooterData();
  2069. neFocusLast(document.getElementById('footerColumnsContainer'), '.ne-col-title');
  2070. }
  2071. ​
  2072. function removeFooterColumn(index) {
  2073. footerData.columns.splice(index, 1);
  2074. renderFooterColumns();
  2075. updateFooterData();
  2076. }
  2077. ​
  2078. function moveFooterColumn(index, dir) {
  2079. if (neMove(footerData.columns, index, dir)) {
  2080. renderFooterColumns();
  2081. updateFooterData();
  2082. }
  2083. }
  2084. ​
  2085. function addFooterColumnLink(colIndex) {
  2086. ensureFooterColumn(colIndex).links.push({ label: '', url: '#' });
  2087. renderFooterColumns();
  2088. updateFooterData();
  2089. const cards = document.querySelectorAll('#footerColumnsContainer .ne-card');
  2090. if (cards[colIndex]) neFocusLast(cards[colIndex], '.ne-link-label');
  2091. }
  2092. ​
  2093. function removeFooterColumnLink(colIndex, linkIndex) {
  2094. footerData.columns[colIndex].links.splice(linkIndex, 1);
  2095. renderFooterColumns();
  2096. updateFooterData();
  2097. }
  2098. ​
  2099. function renderSocialLinks() {
  2100. const container = document.getElementById('socialLinksContainer');
  2101. if (!container) return;
  2102. if (!footerData.social_links) footerData.social_links = [];
  2103. ​
  2104. container.innerHTML = footerData.social_links.length ? `<div class="ne-list">${footerData.social_links.map((link, i) => `
  2105. <div class="ne-item">
  2106. <div class="ne-row">
  2107. <select class="ne-input ne-select" onchange="updateSocialLink(${i}, 'platform', this.value)">
  2108. ${SOCIAL_PLATFORMS.map(([value, label]) => `<option value="${value}" ${link.platform === value ? 'selected' : ''}>${label}</option>`).join('')}
  2109. </select>
  2110. <input type="text" class="ne-input ne-social-url" value="${escapeHtml(link.url || '')}" placeholder="URL" oninput="updateSocialLink(${i}, 'url', this.value)">
  2111. ${neActions('moveSocialLink', 'removeSocialLink', i, footerData.social_links.length)}
  2112. </div>
  2113. </div>`).join('')}</div>` : neEmpty('No social links yet.');
  2114. }
  2115. ​
  2116. function updateSocialLink(index, field, value) {
  2117. if (!footerData.social_links[index]) return;
  2118. footerData.social_links[index][field] = value;
  2119. updateFooterData();
  2120. }
  2121. ​
  2122. function addSocialLink() {
  2123. if (!footerData.social_links) footerData.social_links = [];
  2124. footerData.social_links.push({ platform: 'twitter', url: '#' });
  2125. renderSocialLinks();
  2126. updateFooterData();
  2127. neFocusLast(document.getElementById('socialLinksContainer'), '.ne-social-url');
  2128. }
  2129. ​
  2130. function removeSocialLink(index) {
  2131. footerData.social_links.splice(index, 1);
  2132. renderSocialLinks();
  2133. updateFooterData();
  2134. }
  2135. ​
  2136. function moveSocialLink(index, dir) {
  2137. if (neMove(footerData.social_links, index, dir)) {
  2138. renderSocialLinks();
  2139. updateFooterData();
  2140. }
  2141. }
  2142. ​
  2143. function renderBottomLinks() {
  2144. const container = document.getElementById('bottomLinksContainer');
  2145. if (!container) return;
  2146. if (!footerData.bottom_links) footerData.bottom_links = [];
  2147. ​
  2148. container.innerHTML = footerData.bottom_links.length ? `<div class="ne-list">${footerData.bottom_links.map((link, i) => `
  2149. <div class="ne-item">
  2150. <div class="ne-row">
  2151. <input type="text" class="ne-input ne-bottom-label" value="${escapeHtml(link.label || '')}" placeholder="Label" oninput="updateBottomLink(${i}, 'label', this.value)">
  2152. <input type="text" class="ne-input" value="${escapeHtml(link.url || '')}" placeholder="URL" oninput="updateBottomLink(${i}, 'url', this.value)">
  2153. ${neActions('moveBottomLink', 'removeBottomLink', i, footerData.bottom_links.length)}
  2154. </div>
  2155. </div>`).join('')}</div>` : neEmpty('No bottom links yet.');
  2156. }
  2157. ​
  2158. function updateBottomLink(index, field, value) {
  2159. if (!footerData.bottom_links[index]) return;
  2160. footerData.bottom_links[index][field] = value;
  2161. updateFooterData();
  2162. }
  2163. ​
  2164. function addBottomLink() {
  2165. if (!footerData.bottom_links) footerData.bottom_links = [];
  2166. footerData.bottom_links.push({ label: '', url: '#' });
  2167. renderBottomLinks();
  2168. updateFooterData();
  2169. neFocusLast(document.getElementById('bottomLinksContainer'), '.ne-bottom-label');
  2170. }
  2171. ​
  2172. function removeBottomLink(index) {
  2173. footerData.bottom_links.splice(index, 1);
  2174. renderBottomLinks();
  2175. updateFooterData();
  2176. }
  2177. ​
  2178. function moveBottomLink(index, dir) {
  2179. if (neMove(footerData.bottom_links, index, dir)) {
  2180. renderBottomLinks();
  2181. updateFooterData();
  2182. }
  2183. }
  2184. ​
  2185. function updateFooterData() {
  2186. footerData.brand_description = document.getElementById('footerBrandDesc')?.value || '';
  2187. footerData.copyright = document.getElementById('footerCopyright')?.value || '';
  2188. ​
  2189. if (!footerData.columns) footerData.columns = [];
  2190. if (!footerData.social_links) footerData.social_links = [];
  2191. if (!footerData.bottom_links) footerData.bottom_links = [];
  2192. }
  2193. ​
  2194. function updateAnchorsList() {
  2195. const anchorsList = document.getElementById('anchorsList');
  2196. if (!anchorsList) return;
  2197. ​
  2198. const anchors = new Set(['#']); // Always include top
  2199. ​
  2200. blocks.forEach(block => {
  2201. if (block.id && ['features', 'testimonials', 'pricing', 'faq', 'team', 'stats', 'cta', 'video', 'gallery', 'audio', 'countdown', 'newsletter'].includes(block.type)) {
  2202. anchors.add(`#${block.id}`);
  2203. }
  2204. });
  2205. ​
  2206. anchorsList.innerHTML = '';
  2207. anchors.forEach(anchor => {
  2208. const btn = document.createElement('button');
  2209. btn.type = 'button';
  2210. btn.className = 'btn btn-secondary btn-sm';
  2211. btn.onclick = () => selectLink(anchor);
  2212. btn.textContent = anchor === '#' ? '#top' : anchor;
  2213. btn.style.margin = '0 8px 8px 0';
  2214. anchorsList.appendChild(btn);
  2215. });
  2216. }
  2217. ​
  2218. function renderInternalPages() {
  2219. const container = document.getElementById('internalPages');
  2220. if (!container) return;
  2221. ​
  2222. container.innerHTML = AVAILABLE_PAGES.map(page => `
  2223. <button type="button" class="btn btn-secondary btn-sm" style="margin: 0 8px 8px 0;" onclick="selectLink('${escapeHtml(page.url)}')">${escapeHtml(page.label)}</button>
  2224. `).join('');
  2225. }
  2226. ​
  2227. function showLinkSelector(inputId, callback) {
  2228. currentLinkCallback = { inputId, callback };
  2229. updateAnchorsList();
  2230. document.getElementById('linkSelectorModal').classList.add('active');
  2231. }
  2232. ​
  2233. function hideLinkSelector() {
  2234. document.getElementById('linkSelectorModal').classList.remove('active');
  2235. currentLinkCallback = null;
  2236. }
  2237. ​
  2238. function selectLink(url) {
  2239. if (currentLinkCallback) {
  2240. const input = document.getElementById(currentLinkCallback.inputId);
  2241. if (input) {
  2242. input.value = url;
  2243. input.dispatchEvent(new Event('input'));
  2244. }
  2245. }
  2246. hideLinkSelector();
  2247. }
  2248. ​
  2249. ​
  2250. function showAddBlockModal() {
  2251. document.getElementById('addBlockModal').classList.add('active');
  2252. }
  2253. ​
  2254. function hideAddBlockModal() {
  2255. document.getElementById('addBlockModal').classList.remove('active');
  2256. }
  2257. ​
  2258. function confirmDelete(productId, productName) {
  2259. document.getElementById('deleteProductId').value = productId;
  2260. document.getElementById('deleteProductName').textContent = productName;
  2261. document.getElementById('deleteModal').classList.add('active');
  2262. }
  2263. ​
  2264. function hideDeleteModal() {
  2265. document.getElementById('deleteModal').classList.remove('active');
  2266. }
  2267. ​
  2268. ​
  2269. function escapeHtml(text) {
  2270. if (!text) return '';
  2271. const div = document.createElement('div');
  2272. div.textContent = text;
  2273. return div.innerHTML;
  2274. }
  2275. ​
  2276. document.addEventListener('keydown', function (e) {
  2277. if (e.key === 'Escape') {
  2278. hideAddBlockModal();
  2279. hideDeleteModal();
  2280. hideLinkSelector();
  2281. }
  2282. });
  2283. ​
  2284. document.querySelectorAll('.modal-overlay').forEach(overlay => {
  2285. overlay.addEventListener('click', function (e) {
  2286. if (e.target === this) {
  2287. this.classList.remove('active');
  2288. }
  2289. });
  2290. });
  2291. ​
  2292. ​
  2293. ​
  2294. ​
  2295. ​
  2296. ​
  2297. ​
  2298. ​
  2299. ​
  2300. ​
  2301. ​
  2302. ​
  2303. ​
  2304. ​
  2305. ​
  2306. ​
  2307. ​
  2308. ​
  2309. ​
  2310. ​
  2311. ​
  2312. function renderLanguages() {
  2313. const container = document.getElementById('languagesContainer');
  2314. if (!container || typeof languagesData === 'undefined') return;
  2315. ​
  2316. container.innerHTML = languagesData.length ? `<div class="ne-list">${languagesData.map((lang, i) => `
  2317. <div class="ne-item">
  2318. <div class="ne-row">
  2319. <input type="text" class="ne-input ne-lang-name" value="${escapeHtml(lang.name || '')}" placeholder="Language name (e.g. English, Polski, 中文)" maxlength="50" oninput="languagesData[${i}].name = this.value">
  2320. <input type="text" class="ne-input ne-lang-code" value="${escapeHtml(lang.code || '')}" placeholder="Code (en, pl, de)" maxlength="11" style="max-width: 140px;" oninput="languagesData[${i}].code = this.value.toLowerCase().trim()">
  2321. ${neActions('moveLanguage', 'removeLanguage', i, languagesData.length)}
  2322. </div>
  2323. </div>`).join('')}</div>` : neEmpty('No languages yet. Add your main language and the languages you want to offer.');
  2324. }
  2325. ​
  2326. function addLanguage() {
  2327. languagesData.push({ name: '', code: '' });
  2328. renderLanguages();
  2329. neFocusLast(document.getElementById('languagesContainer'), '.ne-lang-name');
  2330. }
  2331. ​
  2332. function removeLanguage(index) {
  2333. languagesData.splice(index, 1);
  2334. renderLanguages();
  2335. }
  2336. ​
  2337. function moveLanguage(index, dir) {
  2338. if (neMove(languagesData, index, dir)) {
  2339. renderLanguages();
  2340. }
  2341. }(function () {
  2342. const form = document.getElementById('pageForm') || document.getElementById('settingsForm');
  2343. if (!form) return;
  2344. ​
  2345. const isSettings = form.id === 'settingsForm';
  2346. const params = new URLSearchParams(location.search);
  2347. const draftKey = 'flatly-draft:' + location.pathname + ':' + (params.get('tab') || 'index') + ':' + (params.get('id') || '') + ':' + (params.get('lang') || '');
  2348. const pendingKey = 'flatly-draft-pending';
  2349. const skipFields = new Set(['csrf_token', 'action', 'blocks_data', 'nav_links', 'nav_buttons', 'footer_data', 'product_id', 'languages_data', 'analytics_scripts', 'custom_html_location[]', 'custom_html_code[]']);
  2350. let baseline = '';
  2351. let timer = 0;
  2352. let submitting = false;
  2353. ​
  2354. const storage = {
  2355. get(key) {
  2356. try { return localStorage.getItem(key); } catch (e) { return null; }
  2357. },
  2358. set(key, value) {
  2359. try { localStorage.setItem(key, value); } catch (e) {}
  2360. },
  2361. remove(key) {
  2362. try { localStorage.removeItem(key); } catch (e) {}
  2363. }
  2364. };
  2365. ​
  2366. function snapshot() {
  2367. const fields = {};
  2368. Array.from(form.elements).forEach(el => {
  2369. if (!el.name || skipFields.has(el.name) || el.type === 'file' || el.type === 'submit' || el.type === 'button') return;
  2370. fields[el.name] = el.type === 'checkbox' ? el.checked : el.value;
  2371. });
  2372. ​
  2373. const data = { fields };
  2374. if (isSettings) {
  2375. updateFooterData();
  2376. data.navLinks = navLinks;
  2377. data.navButtons = navButtons;
  2378. data.footerData = footerData;
  2379. if (typeof languagesData !== 'undefined') data.languages = languagesData;
  2380. } else {
  2381. data.blocks = blocks;
  2382. }
  2383. return data;
  2384. }
  2385. ​
  2386. function serialize() {
  2387. try {
  2388. return JSON.stringify(snapshot());
  2389. } catch (e) {
  2390. return '';
  2391. }
  2392. }
  2393. ​
  2394. function saveDraft() {
  2395. if (submitting) return;
  2396. const current = serialize();
  2397. if (!current) return;
  2398. if (current === baseline) {
  2399. storage.remove(draftKey);
  2400. return;
  2401. }
  2402. storage.set(draftKey, JSON.stringify({ savedAt: Date.now(), data: JSON.parse(current) }));
  2403. }
  2404. ​
  2405. function scheduleSave() {
  2406. clearTimeout(timer);
  2407. timer = setTimeout(saveDraft, 700);
  2408. }
  2409. ​
  2410. function applyDraft(data) {
  2411. Array.from(form.elements).forEach(el => {
  2412. if (!el.name || !(el.name in data.fields)) return;
  2413. if (el.type === 'checkbox') {
  2414. el.checked = !!data.fields[el.name];
  2415. } else {
  2416. el.value = data.fields[el.name];
  2417. }
  2418. });
  2419. ​
  2420. if (isSettings) {
  2421. navLinks.splice(0, navLinks.length, ...(data.navLinks || []));
  2422. navButtons.splice(0, navButtons.length, ...(data.navButtons || []));
  2423. Object.keys(footerData).forEach(k => delete footerData[k]);
  2424. Object.assign(footerData, data.footerData || {});
  2425. const desc = document.getElementById('footerBrandDesc');
  2426. const copy = document.getElementById('footerCopyright');
  2427. if (desc) desc.value = footerData.brand_description || '';
  2428. if (copy) copy.value = footerData.copyright || '';
  2429. renderNavLinks();
  2430. renderNavButtons();
  2431. renderFooterColumns();
  2432. renderSocialLinks();
  2433. renderBottomLinks();
  2434. if (typeof languagesData !== 'undefined') languagesData.splice(0, languagesData.length, ...(data.languages || languagesData));
  2435. if (typeof renderLanguages === 'function') renderLanguages();
  2436. updateFooterData();
  2437. } else {
  2438. blocks.splice(0, blocks.length, ...(data.blocks || []));
  2439. renderBlocks();
  2440. updateBlockCount();
  2441. if (typeof schedulePreviewUpdate === 'function') schedulePreviewUpdate();
  2442. }
  2443. }
  2444. ​
  2445. function showBanner(draft) {
  2446. const host = document.querySelector('.main-content');
  2447. if (!host) return;
  2448. ​
  2449. const banner = document.createElement('div');
  2450. banner.className = 'draft-banner';
  2451. banner.innerHTML = `
  2452. <div class="draft-banner-text">
  2453. <strong>Unsaved draft found</strong>
  2454. <span>Autosaved ${new Date(draft.savedAt).toLocaleString()}. Restore it to continue where you left off.</span>
  2455. </div>
  2456. <div class="draft-banner-actions">
  2457. <button type="button" class="btn btn-secondary btn-sm" data-draft="discard">Discard</button>
  2458. <button type="button" class="btn btn-primary btn-sm" data-draft="restore">Restore</button>
  2459. </div>`;
  2460. ​
  2461. banner.addEventListener('click', e => {
  2462. const action = e.target.closest('[data-draft]')?.dataset.draft;
  2463. if (!action) return;
  2464. if (action === 'restore') {
  2465. applyDraft(draft.data);
  2466. saveDraft();
  2467. } else {
  2468. storage.remove(draftKey);
  2469. }
  2470. banner.remove();
  2471. });
  2472. ​
  2473. host.insertBefore(banner, host.firstChild);
  2474. }
  2475. ​
  2476. try {
  2477. const pending = sessionStorage.getItem(pendingKey);
  2478. if (pending) {
  2479. sessionStorage.removeItem(pendingKey);
  2480. if (document.querySelector('.message.success')) storage.remove(pending);
  2481. }
  2482. } catch (e) {}
  2483. ​
  2484. form.addEventListener('submit', () => {
  2485. submitting = true;
  2486. clearTimeout(timer);
  2487. try { sessionStorage.setItem(pendingKey, draftKey); } catch (e) {}
  2488. });
  2489. ​
  2490. document.addEventListener('DOMContentLoaded', () => {
  2491. setTimeout(() => {
  2492. baseline = serialize();
  2493. ​
  2494. const raw = storage.get(draftKey);
  2495. if (raw) {
  2496. try {
  2497. const draft = JSON.parse(raw);
  2498. if (draft && draft.data && JSON.stringify(draft.data) !== baseline) {
  2499. showBanner(draft);
  2500. } else {
  2501. storage.remove(draftKey);
  2502. }
  2503. } catch (e) {
  2504. storage.remove(draftKey);
  2505. }
  2506. }
  2507. ​
  2508. ['input', 'change', 'click'].forEach(evt => document.addEventListener(evt, scheduleSave, true));
  2509. window.addEventListener('pagehide', saveDraft);
  2510. document.addEventListener('visibilitychange', () => {
  2511. if (document.visibilityState === 'hidden') saveDraft();
  2512. });
  2513. }, 400);
  2514. });
  2515. })();
  2516. ​
  2517. const MEDIA_ACCEPT = {
  2518. image: 'image/jpeg,image/png,image/gif,image/webp,image/avif,image/x-icon,.jpg,.jpeg,.png,.gif,.webp,.avif,.ico',
  2519. video: 'video/mp4,video/webm,video/ogg,video/quicktime,.mp4,.m4v,.webm,.ogv,.mov',
  2520. audio: 'audio/mpeg,audio/ogg,audio/wav,audio/mp4,audio/aac,audio/flac,audio/webm,.mp3,.ogg,.oga,.opus,.wav,.m4a,.aac,.flac,.weba'
  2521. };
  2522. ​
  2523. const MEDIA_MIME = {
  2524. mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', mov: 'video/mp4',
  2525. mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav',
  2526. m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', weba: 'audio/webm'
  2527. };
  2528. ​
  2529. const MEDIA_ICONS = {
  2530. upload: '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" width="15" height="15"><path d="M12 16V4M6 10l6-6 6 6"/><path d="M4 20h16"/></svg>',
  2531. library: '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" width="15" height="15"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>',
  2532. close: '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" viewBox="0 0 24 24" width="16" height="16"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
  2533. video: '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" width="28" height="28"><rect x="3" y="5" width="14" height="14" rx="2"/><path d="M17 9.5l4-2.5v10l-4-2.5"/></svg>',
  2534. audio: '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" width="28" height="28"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>'
  2535. };
  2536. ​
  2537. function mediaMime(url, fallback) {
  2538. const path = String(url || '').split('?')[0].split('#')[0];
  2539. const ext = path.includes('.') ? path.split('.').pop().toLowerCase() : '';
  2540. return MEDIA_MIME[ext] || fallback;
  2541. }
  2542. ​
  2543. function mediaFieldFor(node) {
  2544. return node.closest('.media-field');
  2545. }
  2546. ​
  2547. function mediaSetStatus(field, text, state) {
  2548. const status = field.querySelector('.media-status');
  2549. if (!status) return;
  2550. status.textContent = text || '';
  2551. status.className = 'media-status' + (state ? ' ' + state : '');
  2552. clearTimeout(field._statusTimer);
  2553. if (state === 'success' || state === 'error') {
  2554. field._statusTimer = setTimeout(() => {
  2555. status.textContent = '';
  2556. status.className = 'media-status';
  2557. }, state === 'error' ? 8000 : 3000);
  2558. }
  2559. }
  2560. ​
  2561. function mediaApply(field, url) {
  2562. const input = field.querySelector('input[data-media]');
  2563. if (!input) return;
  2564. input.value = url;
  2565. input.dispatchEvent(new Event('input', { bubbles: true }));
  2566. input.dispatchEvent(new Event('change', { bubbles: true }));
  2567. }
  2568. ​
  2569. function mediaUploadFile(field, file) {
  2570. const kind = field.dataset.kind;
  2571. if (field.classList.contains('uploading')) return;
  2572. ​
  2573. const body = new FormData();
  2574. body.append('csrf_token', CSRF_TOKEN);
  2575. body.append('kind', kind);
  2576. body.append('file', file);
  2577. ​
  2578. const bar = field.querySelector('.media-progress-bar');
  2579. field.classList.add('uploading');
  2580. if (bar) bar.style.width = '0%';
  2581. mediaSetStatus(field, 'Uploading ' + file.name + '...', 'busy');
  2582. ​
  2583. const xhr = new XMLHttpRequest();
  2584. xhr.open('POST', FLATLY_BASE_URL + '/admin/upload');
  2585. xhr.upload.onprogress = e => {
  2586. if (e.lengthComputable && bar) bar.style.width = Math.round((e.loaded / e.total) * 100) + '%';
  2587. };
  2588. xhr.onerror = () => {
  2589. field.classList.remove('uploading');
  2590. mediaSetStatus(field, 'Network error. Upload failed.', 'error');
  2591. };
  2592. xhr.onload = () => {
  2593. field.classList.remove('uploading');
  2594. let response = null;
  2595. try { response = JSON.parse(xhr.responseText); } catch (e) {}
  2596. if (response && response.success) {
  2597. mediaApply(field, response.url);
  2598. mediaSetStatus(field, 'Uploaded (' + response.size + ')', 'success');
  2599. } else {
  2600. mediaSetStatus(field, response && response.error ? response.error : 'Upload failed (HTTP ' + xhr.status + ').', 'error');
  2601. }
  2602. };
  2603. xhr.send(body);
  2604. }
  2605. ​
  2606. function mediaPickFile(button) {
  2607. const field = mediaFieldFor(button);
  2608. if (!field) return;
  2609. const picker = document.createElement('input');
  2610. picker.type = 'file';
  2611. picker.accept = MEDIA_ACCEPT[field.dataset.kind] || '';
  2612. picker.onchange = () => {
  2613. if (picker.files && picker.files[0]) mediaUploadFile(field, picker.files[0]);
  2614. };
  2615. picker.click();
  2616. }
  2617. ​
  2618. function mediaLibraryKey(e) {
  2619. if (e.key === 'Escape') mediaCloseLibrary();
  2620. }
  2621. ​
  2622. function mediaCloseLibrary() {
  2623. document.querySelector('.media-modal')?.remove();
  2624. document.removeEventListener('keydown', mediaLibraryKey);
  2625. }
  2626. ​
  2627. function mediaOpenLibrary(button) {
  2628. const field = mediaFieldFor(button);
  2629. if (!field) return;
  2630. const kind = field.dataset.kind;
  2631. ​
  2632. mediaCloseLibrary();
  2633. const modal = document.createElement('div');
  2634. modal.className = 'media-modal';
  2635. modal.innerHTML = `
  2636. <div class="media-modal-box">
  2637. <div class="media-modal-head">
  2638. <h3>Media library</h3>
  2639. <input type="text" class="ne-input" placeholder="Search files" data-media-search>
  2640. <button type="button" class="ne-btn" data-media-close title="Close">${MEDIA_ICONS.close}</button>
  2641. </div>
  2642. <div class="media-modal-body"><div class="media-empty">Loading...</div></div>
  2643. </div>`;
  2644. document.body.appendChild(modal);
  2645. document.addEventListener('keydown', mediaLibraryKey);
  2646. ​
  2647. const body = modal.querySelector('.media-modal-body');
  2648. let items = [];
  2649. ​
  2650. const draw = query => {
  2651. const q = (query || '').trim().toLowerCase();
  2652. const visible = items.filter(item => !q || item.name.toLowerCase().includes(q));
  2653. if (!visible.length) {
  2654. body.innerHTML = `<div class="media-empty">${items.length ? 'No matching files.' : 'No files uploaded yet.'}</div>`;
  2655. return;
  2656. }
  2657. body.innerHTML = `<div class="media-grid">${visible.map(item => `
  2658. <button type="button" class="media-item" data-url="${escapeHtml(item.url)}" title="${escapeHtml(item.name)}">
  2659. <span class="media-thumb">${kind === 'image' ? `<img src="${escapeHtml(flatlyUrl(item.url))}" alt="" loading="lazy">` : MEDIA_ICONS[kind] || ''}</span>
  2660. <span class="media-name">${escapeHtml(item.name)}</span>
  2661. <span class="media-size">${escapeHtml(item.size)}</span>
  2662. </button>`).join('')}</div>`;
  2663. };
  2664. ​
  2665. modal.addEventListener('click', e => {
  2666. if (e.target === modal || e.target.closest('[data-media-close]')) {
  2667. mediaCloseLibrary();
  2668. return;
  2669. }
  2670. const item = e.target.closest('.media-item');
  2671. if (item) {
  2672. mediaApply(field, item.dataset.url);
  2673. mediaCloseLibrary();
  2674. }
  2675. });
  2676. modal.querySelector('[data-media-search]').addEventListener('input', e => draw(e.target.value));
  2677. ​
  2678. fetch(FLATLY_BASE_URL + '/admin/upload?list=' + encodeURIComponent(kind), { credentials: 'same-origin' })
  2679. .then(r => r.json())
  2680. .then(data => {
  2681. if (!data.success) throw new Error(data.error || 'Could not load media.');
  2682. items = data.items || [];
  2683. draw('');
  2684. })
  2685. .catch(err => {
  2686. body.innerHTML = `<div class="media-empty">${escapeHtml(err.message)}</div>`;
  2687. });
  2688. }
  2689. ​
  2690. function enhanceMediaInputs(root) {
  2691. (root || document).querySelectorAll('input[data-media]:not([data-media-ready])').forEach(input => {
  2692. const kind = input.dataset.media;
  2693. if (!MEDIA_ACCEPT[kind]) return;
  2694. input.dataset.mediaReady = '1';
  2695. ​
  2696. const field = document.createElement('div');
  2697. field.className = 'media-field';
  2698. field.dataset.kind = kind;
  2699. input.parentNode.insertBefore(field, input);
  2700. ​
  2701. const row = document.createElement('div');
  2702. row.className = 'media-row';
  2703. field.appendChild(row);
  2704. row.appendChild(input);
  2705. ​
  2706. const actions = document.createElement('div');
  2707. actions.className = 'media-actions';
  2708. actions.innerHTML = `
  2709. <button type="button" class="btn btn-secondary btn-sm" onclick="mediaPickFile(this)">${MEDIA_ICONS.upload} Upload</button>
  2710. <button type="button" class="btn btn-secondary btn-sm" onclick="mediaOpenLibrary(this)">${MEDIA_ICONS.library} Library</button>`;
  2711. row.appendChild(actions);
  2712. ​
  2713. field.insertAdjacentHTML('beforeend', '<div class="media-progress"><div class="media-progress-bar"></div></div><div class="media-status"></div>');
  2714. });
  2715. }
  2716. ​
  2717. (function () {
  2718. let frame = 0;
  2719. const schedule = () => {
  2720. if (frame) return;
  2721. frame = requestAnimationFrame(() => {
  2722. frame = 0;
  2723. enhanceMediaInputs(document);
  2724. });
  2725. };
  2726. ​
  2727. const start = () => {
  2728. enhanceMediaInputs(document);
  2729. const host = document.querySelector('.main-content') || document.body;
  2730. new MutationObserver(schedule).observe(host, { childList: true, subtree: true });
  2731. };
  2732. ​
  2733. if (document.readyState === 'loading') {
  2734. document.addEventListener('DOMContentLoaded', start);
  2735. } else {
  2736. start();
  2737. }
  2738. ​
  2739. document.addEventListener('dragover', e => {
  2740. const field = e.target.closest && e.target.closest('.media-field');
  2741. if (!field || !e.dataTransfer || !Array.from(e.dataTransfer.types || []).includes('Files')) return;
  2742. e.preventDefault();
  2743. field.classList.add('dragging');
  2744. });
  2745. ​
  2746. document.addEventListener('dragleave', e => {
  2747. const field = e.target.closest && e.target.closest('.media-field');
  2748. if (field && !field.contains(e.relatedTarget)) field.classList.remove('dragging');
  2749. });
  2750. ​
  2751. document.addEventListener('drop', e => {
  2752. const field = e.target.closest && e.target.closest('.media-field');
  2753. if (!field) return;
  2754. field.classList.remove('dragging');
  2755. const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
  2756. if (!file) return;
  2757. e.preventDefault();
  2758. mediaUploadFile(field, file);
  2759. });
  2760. })();
  2761. ​
  2762. function switchEditLang(code) {
  2763. const url = new URL(location.href);
  2764. if (code) {
  2765. url.searchParams.set('lang', code);
  2766. } else {
  2767. url.searchParams.delete('lang');
  2768. }
  2769. location.href = url.toString();
  2770. }