WebOrbiton
v3.0.0.2

FlatlyPage

2,769 lines · 143.4 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. settingsForm.addEventListener('invalid', (event) => {
  541. const panel = event.target.closest('.settings-panel');
  542. if (panel && !panel.classList.contains('active')) activateSettingsTab(panel.id.replace(/^panel-/, ''));
  543. }, true);
  544. ​
  545. settingsForm.addEventListener('submit', () => {
  546. settingsForm.querySelectorAll('.flatly-encoded').forEach(input => input.remove());
  547. settingsForm.querySelectorAll('textarea[data-encode]').forEach(area => {
  548. const name = area.name || area.dataset.encodeName;
  549. if (!name) return;
  550. area.dataset.encodeName = name;
  551. area.removeAttribute('name');
  552. const hidden = document.createElement('input');
  553. hidden.type = 'hidden';
  554. hidden.className = 'flatly-encoded';
  555. hidden.name = name.endsWith('[]') ? name.slice(0, -2) + '_b64[]' : name + '_b64';
  556. hidden.value = flatlyBase64Encode(area.value);
  557. settingsForm.appendChild(hidden);
  558. });
  559. });
  560. ​
  561. window.addEventListener('pageshow', () => {
  562. settingsForm.querySelectorAll('.flatly-encoded').forEach(input => input.remove());
  563. settingsForm.querySelectorAll('textarea[data-encode-name]').forEach(area => { area.name = area.dataset.encodeName; });
  564. });
  565. }
  566. });
  567. ​
  568. function flatlyBase64Encode(text) {
  569. const bytes = new TextEncoder().encode(String(text));
  570. let binary = '';
  571. for (let i = 0; i < bytes.length; i += 0x8000) {
  572. binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  573. }
  574. return btoa(binary);
  575. }
  576. ​
  577. function flatlyBase64Decode(encoded) {
  578. const binary = atob(encoded);
  579. const bytes = new Uint8Array(binary.length);
  580. for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  581. return new TextDecoder().decode(bytes);
  582. }
  583. ​
  584. (function () {
  585. const scrollKey = 'flatly-ui:scroll:' + location.pathname + location.search;
  586. const pendingKey = 'flatly-ui:scroll-pending';
  587. const desktop = window.matchMedia('(min-width: 769px)');
  588. let timer = 0;
  589. ​
  590. const scroller = () => (desktop.matches ? document.querySelector('.main') : document.scrollingElement) || document.documentElement;
  591. ​
  592. const save = () => {
  593. try { sessionStorage.setItem(scrollKey, String(scroller().scrollTop || 0)); } catch (e) {}
  594. };
  595. ​
  596. const navigationType = () => {
  597. const entry = performance.getEntriesByType && performance.getEntriesByType('navigation')[0];
  598. return entry ? entry.type : '';
  599. };
  600. ​
  601. let shouldRestore = ['reload', 'back_forward'].includes(navigationType());
  602. try {
  603. if (sessionStorage.getItem(pendingKey) === scrollKey) {
  604. shouldRestore = true;
  605. }
  606. sessionStorage.removeItem(pendingKey);
  607. } catch (e) {}
  608. ​
  609. document.addEventListener('submit', () => {
  610. save();
  611. try { sessionStorage.setItem(pendingKey, scrollKey); } catch (e) {}
  612. }, true);
  613. ​
  614. document.addEventListener('scroll', () => {
  615. clearTimeout(timer);
  616. timer = setTimeout(save, 150);
  617. }, { capture: true, passive: true });
  618. ​
  619. window.addEventListener('pagehide', save);
  620. ​
  621. if (!shouldRestore) return;
  622. ​
  623. let saved = 0;
  624. try { saved = parseInt(sessionStorage.getItem(scrollKey) || '0', 10) || 0; } catch (e) {}
  625. if (!saved) return;
  626. ​
  627. const apply = () => { scroller().scrollTop = saved; };
  628. window.addEventListener('load', () => {
  629. apply();
  630. setTimeout(apply, 250);
  631. });
  632. })();
  633. ​
  634. document.getElementById('pageForm')?.addEventListener('submit', function (e) {
  635. document.getElementById('blocksData').value = JSON.stringify(blocks);
  636. });
  637. ​
  638. document.getElementById('settingsForm')?.addEventListener('submit', function (e) {
  639. document.getElementById('navLinksData').value = JSON.stringify(navLinks);
  640. document.getElementById('navButtonsData').value = JSON.stringify(navButtons);
  641. updateFooterData();
  642. document.getElementById('footerData').value = JSON.stringify(footerData);
  643. const languagesField = document.getElementById('languagesDataField');
  644. if (languagesField && typeof languagesData !== 'undefined') languagesField.value = JSON.stringify(languagesData);
  645. });
  646. ​
  647. ​
  648. function generateId() {
  649. return 'block-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
  650. }
  651. ​
  652. function updateBlockCount() {
  653. const countEl = document.getElementById('blockCount');
  654. if (countEl) countEl.textContent = blocks.length;
  655. }
  656. ​
  657. function getUniqueBlockId(baseId, currentIndex) {
  658. if (!baseId || baseId.trim() === '') return '';
  659. ​
  660. const cleanId = baseId.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
  661. ​
  662. let counter = 2;
  663. let finalId = cleanId;
  664. ​
  665. blocks.forEach((block, index) => {
  666. if (index !== currentIndex) {
  667. if (block.id === finalId) {
  668. ​
  669. while (blocks.some((b, i) => i !== currentIndex && b.id === `${cleanId}-${counter}`)) {
  670. counter++;
  671. }
  672. finalId = `${cleanId}-${counter}`;
  673. } else if (block.id === cleanId && counter === 2) {
  674. ​
  675. }
  676. }
  677. });
  678. ​
  679. return finalId;
  680. }
  681. ​
  682. function renderBlocks() {
  683. const container = document.getElementById('blocksContainer');
  684. if (!container) return;
  685. ​
  686. const blockTypes = {};
  687. blocks.forEach((block) => {
  688. if (!block.id) {
  689. const type = block.type;
  690. if (!blockTypes[type]) {
  691. blockTypes[type] = [];
  692. }
  693. blockTypes[type].push(block);
  694. }
  695. });
  696. ​
  697. Object.keys(blockTypes).forEach(type => {
  698. const existingIds = blocks
  699. .filter(b => b.type === type && b.id)
  700. .map(b => b.id);
  701. ​
  702. blockTypes[type].forEach((block) => {
  703. let counter = 1;
  704. let newId = type;
  705. ​
  706. if (existingIds.includes(newId)) {
  707. counter = 2;
  708. while (existingIds.includes(`${type}-${counter}`)) {
  709. counter++;
  710. }
  711. newId = `${type}-${counter}`;
  712. }
  713. ​
  714. block.id = newId;
  715. existingIds.push(newId);
  716. });
  717. });
  718. ​
  719. blocks.forEach((block, index) => {
  720. if (block.id) {
  721. block.id = getUniqueBlockId(block.id, index);
  722. }
  723. });
  724. ​
  725. const expandedItems = document.querySelectorAll('.block-item.expanded');
  726. expandedBlockIndices.clear();
  727. expandedItems.forEach(item => {
  728. const index = item.dataset.index;
  729. if (index !== undefined) expandedBlockIndices.add(parseInt(index));
  730. });
  731. ​
  732. container.innerHTML = '';
  733. ​
  734. blocks.forEach((block, index) => {
  735. const el = createBlockElement(block, index);
  736. container.appendChild(el);
  737. ​
  738. if (expandedBlockIndices.has(index)) {
  739. el.classList.add('expanded');
  740. }
  741. });
  742. ​
  743. updateBlockCount();
  744. }
  745. ​
  746. function createBlockElement(block, index) {
  747. const div = document.createElement('div');
  748. div.className = 'block-item';
  749. div.dataset.index = index;
  750. div.draggable = false;
  751. ​
  752. div.addEventListener('dragover', handleDragOver);
  753. div.addEventListener('drop', handleDrop);
  754. div.addEventListener('dragleave', handleDragLeave);
  755. ​
  756. const title = getBlockTitle(block);
  757. ​
  758. div.innerHTML = `
  759. <div class="block-header" onclick="toggleBlock(${index})">
  760. <div class="block-drag" draggable="true" onmousedown="event.stopPropagation()">
  761. <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>
  762. </div>
  763. <span class="block-type">${block.type}</span>
  764. <span class="block-title">${escapeHtml(title)}</span>
  765. <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;">
  766. <div class="block-actions">
  767. <button type="button" class="block-toggle" onclick="event.stopPropagation(); moveBlockUp(${index})" title="Move Up">
  768. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="18 15 12 9 6 15"/></svg>
  769. </button>
  770. <button type="button" class="block-toggle" onclick="event.stopPropagation(); moveBlockDown(${index})" title="Move Down">
  771. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
  772. </button>
  773. <button type="button" class="block-toggle" onclick="event.stopPropagation(); duplicateBlock(${index})" title="Duplicate">
  774. <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>
  775. </button>
  776. <button type="button" class="block-toggle" onclick="event.stopPropagation(); deleteBlock(${index})" title="Delete" style="color: var(--error);">
  777. <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>
  778. </button>
  779. <button type="button" class="block-toggle block-toggle-arrow">
  780. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
  781. </button>
  782. </div>
  783. </div>
  784. <div class="block-content">
  785. ${renderBlockFields(block, index)}
  786. </div>
  787. `;
  788. ​
  789. setTimeout(() => {
  790. const dragHandle = div.querySelector('.block-drag');
  791. if (dragHandle) {
  792. dragHandle.addEventListener('dragstart', function (e) {
  793. e.stopPropagation();
  794. draggedBlock = div;
  795. div.classList.add('dragging');
  796. e.dataTransfer.effectAllowed = 'move';
  797. });
  798. ​
  799. dragHandle.addEventListener('dragend', function (e) {
  800. e.stopPropagation();
  801. div.classList.remove('dragging');
  802. document.querySelectorAll('.block-item').forEach(el => el.classList.remove('drag-over'));
  803. draggedBlock = null;
  804. });
  805. }
  806. }, 0);
  807. ​
  808. return div;
  809. }
  810. ​
  811. ​
  812. function getBlockTitle(block) {
  813. switch (block.type) {
  814. case 'hero': return block.data?.title || 'Hero Section';
  815. case 'blog-hero': return block.data?.title || 'Blog Hero Section';
  816. case 'stats': return 'Statistics';
  817. case 'features': return block.data?.title || 'Features';
  818. case 'testimonials': return block.data?.title || 'Testimonials';
  819. case 'pricing': return block.data?.title || 'Pricing';
  820. case 'cta': return block.data?.title || 'Call to Action';
  821. case 'text': return block.data?.title || 'Text Block';
  822. case 'image': return block.data?.alt || 'Image';
  823. case 'image-text': return block.data?.title || 'Image + Text';
  824. case 'product-cards': return block.data?.title || 'Product Cards';
  825. case 'video': return block.data?.title || 'Video';
  826. case 'gallery': return block.data?.title || 'Gallery';
  827. case 'faq': return block.data?.title || 'FAQ';
  828. case 'team': return block.data?.title || 'Team';
  829. case 'audio': return block.data?.title || 'Audio Player';
  830. case 'countdown': return block.data?.title || 'Countdown Timer';
  831. case 'newsletter': return block.data?.title || 'Newsletter';
  832. case 'html': return 'Custom HTML';
  833. default: return block.type;
  834. }
  835. }
  836. ​
  837. function renderBlockFields(block, blockIndex) {
  838. switch (block.type) {
  839. case 'hero': {
  840. const badgeVal = escapeHtml(block.data?.badge || '');
  841. const titleVal = escapeHtml(block.data?.title || '');
  842. const subtitleVal = escapeHtml(block.data?.subtitle || '');
  843. const btn1Val = escapeHtml(block.data?.button_primary || '');
  844. const btn1Url = block.data?.button_primary_url || '';
  845. const btn2Val = escapeHtml(block.data?.button_secondary || '');
  846. const btn2Url = block.data?.button_secondary_url || '';
  847. return '<div class="form-row">' +
  848. '<div class="block-field">' +
  849. '<label class="block-field-label">Badge Text</label>' +
  850. '<input type="text" class="block-field-input" value="' + badgeVal + '" oninput="updateBlockData(' + blockIndex + ', \'badge\', this.value)">' +
  851. '</div>' +
  852. '<div class="block-field">' +
  853. '<label class="block-field-label">Title</label>' +
  854. '<input type="text" class="block-field-input" value="' + titleVal + '" oninput="updateBlockData(' + blockIndex + ', \'title\', this.value)">' +
  855. '</div>' +
  856. '</div>' +
  857. '<div class="block-field">' +
  858. '<label class="block-field-label">Subtitle</label>' +
  859. '<textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateBlockData(' + blockIndex + ', \'subtitle\', this.value)">' + subtitleVal + '</textarea>' +
  860. '</div>' +
  861. '<div class="form-row">' +
  862. '<div class="block-field">' +
  863. '<label class="block-field-label">Primary Button Text</label>' +
  864. '<input type="text" class="block-field-input" value="' + btn1Val + '" oninput="updateBlockData(' + blockIndex + ', \'button_primary\', this.value)">' +
  865. '</div>' +
  866. '<div class="block-field">' +
  867. '<label class="block-field-label">Primary Button URL</label>' +
  868. renderLinkInput(btn1Url, 'updateBlockData(' + blockIndex + ', \'button_primary_url\', VALUE)') +
  869. '</div>' +
  870. '</div>' +
  871. '<div class="form-row">' +
  872. '<div class="block-field">' +
  873. '<label class="block-field-label">Secondary Button Text</label>' +
  874. '<input type="text" class="block-field-input" value="' + btn2Val + '" oninput="updateBlockData(' + blockIndex + ', \'button_secondary\', this.value)">' +
  875. '</div>' +
  876. '<div class="block-field">' +
  877. '<label class="block-field-label">Secondary Button URL</label>' +
  878. renderLinkInput(btn2Url, 'updateBlockData(' + blockIndex + ', \'button_secondary_url\', VALUE)') +
  879. '</div>' +
  880. '</div>';
  881. }
  882. ​
  883. case 'blog-hero': {
  884. const titleVal = escapeHtml(block.data?.title || '');
  885. const contentVal = escapeHtml(block.data?.content || '');
  886. const imageUrl = block.data?.image || '';
  887. ​
  888. return `
  889. <div class="block-field">
  890. <label class="block-field-label">Header image (URL)</label>
  891. <div class="image-input-group">
  892. <input type="text" data-media="image" class="block-field-input" value="${imageUrl}"
  893. oninput="updateBlockData(${blockIndex}, 'image', this.value)"
  894. placeholder="https://example.com/image.jpg">
  895. </div>
  896. </div>
  897. ​
  898. <div class="block-field">
  899. <label class="block-field-label">Blog Title</label>
  900. <input type="text" class="block-field-input" value="${titleVal}"
  901. oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  902. </div>
  903. ​
  904. <div class="block-field">
  905. <label class="block-field-label">Content / Introduction</label>
  906. <textarea data-rich="1" class="block-field-input block-field-textarea" rows="4"
  907. oninput="updateBlockData(${blockIndex}, 'content', this.value)">${contentVal}</textarea>
  908. </div>
  909. `;
  910. }
  911. ​
  912. case 'stats':
  913. return `
  914. <div class="block-field">
  915. <label class="block-field-label">Stats Items</label>
  916. <div class="repeater-items" id="stats-items-${blockIndex}">
  917. ${(block.data?.items || []).map((item, i) => `
  918. <div class="repeater-item expanded">
  919. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  920. <span class="repeater-item-title">${escapeHtml(item.value || 'New Stat')} - ${escapeHtml(item.label || '')}</span>
  921. <div class="repeater-item-actions">
  922. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'items', ${i})" style="color: var(--error);">Remove</button>
  923. </div>
  924. </div>
  925. <div class="repeater-item-content">
  926. <div class="form-row">
  927. <div class="block-field">
  928. <label class="block-field-label">Value</label>
  929. <input type="text" class="block-field-input" value="${escapeHtml(item.value || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'value', this.value)">
  930. </div>
  931. <div class="block-field">
  932. <label class="block-field-label">Label</label>
  933. <input type="text" class="block-field-input" value="${escapeHtml(item.label || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'label', this.value)">
  934. </div>
  935. </div>
  936. </div>
  937. </div>
  938. `).join('')}
  939. </div>
  940. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'items', {value: '', label: ''})">
  941. <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>
  942. Add Stat
  943. </button>
  944. </div>
  945. `;
  946. ​
  947. case 'features':
  948. return `
  949. <div class="form-row">
  950. <div class="block-field">
  951. <label class="block-field-label">Section Title</label>
  952. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  953. </div>
  954. <div class="block-field">
  955. <label class="block-field-label">Section Subtitle</label>
  956. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  957. </div>
  958. </div>
  959. <div class="block-field">
  960. <label class="block-field-label">Feature Items</label>
  961. <div class="repeater-items" id="features-items-${blockIndex}">
  962. ${(block.data?.items || []).map((item, i) => `
  963. <div class="repeater-item">
  964. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  965. <span class="repeater-item-title">${escapeHtml(item.title || 'New Feature')}</span>
  966. <div class="repeater-item-actions">
  967. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'items', ${i})" style="color: var(--error);">Remove</button>
  968. </div>
  969. </div>
  970. <div class="repeater-item-content">
  971. <div class="block-field">
  972. <label class="block-field-label">Icon</label>
  973. <div class="icon-select">
  974. ${Object.keys(ICONS).map(icon => `
  975. <div class="icon-option ${item.icon === icon ? 'selected' : ''}" onclick="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'icon', '${icon}'); renderBlocks();">
  976. ${ICONS[icon]}
  977. </div>
  978. `).join('')}
  979. </div>
  980. </div>
  981. <div class="block-field">
  982. <label class="block-field-label">Title</label>
  983. <input type="text" class="block-field-input" value="${escapeHtml(item.title || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'title', this.value)">
  984. </div>
  985. <div class="block-field">
  986. <label class="block-field-label">Description</label>
  987. <textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'description', this.value)">${escapeHtml(item.description || '')}</textarea>
  988. </div>
  989. </div>
  990. </div>
  991. `).join('')}
  992. </div>
  993. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'items', {icon: 'bolt', title: '', description: ''})">
  994. <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>
  995. Add Feature
  996. </button>
  997. </div>
  998. `;
  999. ​
  1000. case 'testimonials':
  1001. return `
  1002. <div class="form-row">
  1003. <div class="block-field">
  1004. <label class="block-field-label">Section Title</label>
  1005. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1006. </div>
  1007. <div class="block-field">
  1008. <label class="block-field-label">Section Subtitle</label>
  1009. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1010. </div>
  1011. </div>
  1012. <div class="block-field">
  1013. <label class="block-field-label">Testimonials</label>
  1014. <div class="repeater-items" id="testimonials-items-${blockIndex}">
  1015. ${(block.data?.items || []).map((item, i) => `
  1016. <div class="repeater-item">
  1017. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  1018. <span class="repeater-item-title">${escapeHtml(item.name || 'New Testimonial')}</span>
  1019. <div class="repeater-item-actions">
  1020. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'items', ${i})" style="color: var(--error);">Remove</button>
  1021. </div>
  1022. </div>
  1023. <div class="repeater-item-content">
  1024. <div class="block-field">
  1025. <label class="block-field-label">Quote</label>
  1026. <textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'quote', this.value)">${escapeHtml(item.quote || '')}</textarea>
  1027. </div>
  1028. <div class="form-row-3">
  1029. <div class="block-field">
  1030. <label class="block-field-label">Name</label>
  1031. <input type="text" class="block-field-input" value="${escapeHtml(item.name || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'name', this.value)">
  1032. </div>
  1033. <div class="block-field">
  1034. <label class="block-field-label">Role</label>
  1035. <input type="text" class="block-field-input" value="${escapeHtml(item.role || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'role', this.value)">
  1036. </div>
  1037. <div class="block-field">
  1038. <label class="block-field-label">Initials</label>
  1039. <input type="text" class="block-field-input" value="${escapeHtml(item.initials || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'initials', this.value)" maxlength="2">
  1040. </div>
  1041. </div>
  1042. </div>
  1043. </div>
  1044. `).join('')}
  1045. </div>
  1046. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'items', {quote: '', name: '', role: '', initials: ''})">
  1047. <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>
  1048. Add Testimonial
  1049. </button>
  1050. </div>
  1051. `;
  1052. ​
  1053. case 'pricing': {
  1054. const titleVal = escapeHtml(block.data?.title || '');
  1055. const subtitleVal = escapeHtml(block.data?.subtitle || '');
  1056. let itemsHtml = '';
  1057. (block.data?.items || []).forEach((item, i) => {
  1058. const nameVal = escapeHtml(item.name || 'New Plan');
  1059. const priceVal = escapeHtml(item.price || '');
  1060. const descVal = escapeHtml(item.description || '');
  1061. const periodVal = escapeHtml(item.period || '');
  1062. const btnTextVal = escapeHtml(item.button_text || item.button || '');
  1063. const btnUrlVal = item.button_url || '#';
  1064. const featVal = item.featured ? 'checked' : '';
  1065. itemsHtml += '<div class="repeater-item">' +
  1066. '<div class="repeater-item-header" onclick="this.parentElement.classList.toggle(\'expanded\')">' +
  1067. '<span class="repeater-item-title">' + nameVal + ' - ' + priceVal + '</span>' +
  1068. '<div class="repeater-item-actions">' +
  1069. '<button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(' + blockIndex + ', \'items\', ' + i + ')" style="color: var(--error);">Remove</button>' +
  1070. '</div>' +
  1071. '</div>' +
  1072. '<div class="repeater-item-content">' +
  1073. '<div class="form-row-3">' +
  1074. '<div class="block-field">' +
  1075. '<label class="block-field-label">Plan Name</label>' +
  1076. '<input type="text" class="block-field-input" value="' + nameVal + '" oninput="updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'name\', this.value)">' +
  1077. '</div>' +
  1078. '<div class="block-field">' +
  1079. '<label class="block-field-label">Price</label>' +
  1080. '<input type="text" class="block-field-input" value="' + priceVal + '" oninput="updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'price\', this.value)">' +
  1081. '</div>' +
  1082. '<div class="block-field">' +
  1083. '<label class="block-field-label">Period</label>' +
  1084. '<input type="text" class="block-field-input" value="' + periodVal + '" oninput="updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'period\', this.value)" placeholder="/month">' +
  1085. '</div>' +
  1086. '</div>' +
  1087. '<div class="block-field">' +
  1088. '<label class="block-field-label">Description</label>' +
  1089. '<input type="text" class="block-field-input" value="' + descVal + '" oninput="updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'description\', this.value)">' +
  1090. '</div>' +
  1091. '<div class="block-field">' +
  1092. '<label class="block-field-label">Features (one per line)</label>' +
  1093. '<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>' +
  1094. '</div>' +
  1095. '<div class="form-row">' +
  1096. '<div class="block-field">' +
  1097. '<label class="block-field-label">Button Text</label>' +
  1098. '<input type="text" class="block-field-input" value="' + btnTextVal + '" oninput="updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'button_text\', this.value)">' +
  1099. '</div>' +
  1100. '<div class="block-field">' +
  1101. '<label class="block-field-label">Button URL</label>' +
  1102. renderLinkInput(btnUrlVal, 'updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'button_url\', VALUE)') +
  1103. '</div>' +
  1104. '</div>' +
  1105. '<div class="block-field">' +
  1106. '<label class="block-field-label" style="display: flex; align-items: center; gap: 8px;">' +
  1107. '<input type="checkbox" ' + featVal + ' onchange="updateRepeaterItem(' + blockIndex + ', \'items\', ' + i + ', \'featured\', this.checked)">' +
  1108. 'Featured Plan (highlighted)' +
  1109. '</label>' +
  1110. '</div>' +
  1111. '</div>' +
  1112. '</div>';
  1113. });
  1114. return '<div class="form-row">' +
  1115. '<div class="block-field">' +
  1116. '<label class="block-field-label">Section Title</label>' +
  1117. '<input type="text" class="block-field-input" value="' + titleVal + '" oninput="updateBlockData(' + blockIndex + ', \'title\', this.value)">' +
  1118. '</div>' +
  1119. '<div class="block-field">' +
  1120. '<label class="block-field-label">Section Subtitle</label>' +
  1121. '<input type="text" class="block-field-input" value="' + subtitleVal + '" oninput="updateBlockData(' + blockIndex + ', \'subtitle\', this.value)">' +
  1122. '</div>' +
  1123. '</div>' +
  1124. '<div class="block-field">' +
  1125. '<label class="block-field-label">Pricing Plans</label>' +
  1126. '<div class="repeater-items" id="pricing-items-' + blockIndex + '">' +
  1127. itemsHtml +
  1128. '</div>' +
  1129. '<button type="button" class="repeater-add" onclick="addRepeaterItem(' + blockIndex + ', \'items\', {name: \'\', price: \'\', period: \'/month\', description: \'\', features: [], button_text: \'Get Started\', button_url: \'#\', featured: false})">' +
  1130. '<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>' +
  1131. 'Add Plan' +
  1132. '</button>' +
  1133. '</div>';
  1134. }
  1135. ​
  1136. case 'cta': {
  1137. const titleVal = escapeHtml(block.data?.title || '');
  1138. const subtitleVal = escapeHtml(block.data?.subtitle || '');
  1139. const btn1Val = escapeHtml(block.data?.button_primary || '');
  1140. const btn1Url = block.data?.button_primary_url || '';
  1141. const btn2Val = escapeHtml(block.data?.button_secondary || '');
  1142. const btn2Url = block.data?.button_secondary_url || '';
  1143. return '<div class="form-row">' +
  1144. '<div class="block-field">' +
  1145. '<label class="block-field-label">Title</label>' +
  1146. '<input type="text" class="block-field-input" value="' + titleVal + '" oninput="updateBlockData(' + blockIndex + ', \'title\', this.value)">' +
  1147. '</div>' +
  1148. '<div class="block-field">' +
  1149. '<label class="block-field-label">Subtitle</label>' +
  1150. '<input type="text" class="block-field-input" value="' + subtitleVal + '" oninput="updateBlockData(' + blockIndex + ', \'subtitle\', this.value)">' +
  1151. '</div>' +
  1152. '</div>' +
  1153. '<div class="form-row">' +
  1154. '<div class="block-field">' +
  1155. '<label class="block-field-label">Primary Button Text</label>' +
  1156. '<input type="text" class="block-field-input" value="' + btn1Val + '" oninput="updateBlockData(' + blockIndex + ', \'button_primary\', this.value)">' +
  1157. '</div>' +
  1158. '<div class="block-field">' +
  1159. '<label class="block-field-label">Primary Button URL</label>' +
  1160. renderLinkInput(btn1Url, 'updateBlockData(' + blockIndex + ', \'button_primary_url\', VALUE)') +
  1161. '</div>' +
  1162. '</div>' +
  1163. '<div class="form-row">' +
  1164. '<div class="block-field">' +
  1165. '<label class="block-field-label">Secondary Button Text</label>' +
  1166. '<input type="text" class="block-field-input" value="' + btn2Val + '" oninput="updateBlockData(' + blockIndex + ', \'button_secondary\', this.value)">' +
  1167. '</div>' +
  1168. '<div class="block-field">' +
  1169. '<label class="block-field-label">Secondary Button URL</label>' +
  1170. renderLinkInput(btn2Url, 'updateBlockData(' + blockIndex + ', \'button_secondary_url\', VALUE)') +
  1171. '</div>' +
  1172. '</div>';
  1173. }
  1174. ​
  1175. case 'text':
  1176. return `
  1177. <div class="block-field">
  1178. <label class="block-field-label">Title (optional)</label>
  1179. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1180. </div>
  1181. <div class="block-field">
  1182. <label class="block-field-label">Content</label>
  1183. <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>
  1184. </div>
  1185. `;
  1186. ​
  1187. case 'image':
  1188. return `
  1189. <div class="block-field">
  1190. <label class="block-field-label">Image URL</label>
  1191. <input type="text" data-media="image" class="block-field-input" value="${escapeHtml(block.data?.url || '')}" oninput="updateBlockData(${blockIndex}, 'url', this.value)" placeholder="https://...">
  1192. </div>
  1193. <div class="form-row">
  1194. <div class="block-field">
  1195. <label class="block-field-label">Alt Text</label>
  1196. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.alt || '')}" oninput="updateBlockData(${blockIndex}, 'alt', this.value)">
  1197. </div>
  1198. <div class="block-field">
  1199. <label class="block-field-label">Caption (optional)</label>
  1200. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.caption || '')}" oninput="updateBlockData(${blockIndex}, 'caption', this.value)">
  1201. </div>
  1202. </div>
  1203. `;
  1204. ​
  1205. case 'audio':
  1206. return `
  1207. <div class="form-row">
  1208. <div class="block-field">
  1209. <label class="block-field-label">Title</label>
  1210. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1211. </div>
  1212. <div class="block-field">
  1213. <label class="block-field-label">Subtitle</label>
  1214. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1215. </div>
  1216. </div>
  1217. <div class="block-field">
  1218. <label class="block-field-label">Audio File URL</label>
  1219. <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">
  1220. </div>
  1221. <div class="form-row">
  1222. <div class="block-field">
  1223. <label class="block-field-label">Music Link URL (Optional)</label>
  1224. <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">
  1225. </div>
  1226. <div class="block-field">
  1227. <label class="block-field-label">Music Link Text</label>
  1228. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.music_link_text || 'Download')}" oninput="updateBlockData(${blockIndex}, 'music_link_text', this.value)">
  1229. </div>
  1230. </div>
  1231. `;
  1232. ​
  1233. case 'countdown':
  1234. return `
  1235. <div class="form-row">
  1236. <div class="block-field">
  1237. <label class="block-field-label">Title</label>
  1238. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1239. </div>
  1240. <div class="block-field">
  1241. <label class="block-field-label">Subtitle</label>
  1242. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1243. </div>
  1244. </div>
  1245. <div class="form-row">
  1246. <div class="block-field">
  1247. <label class="block-field-label">Target Date</label>
  1248. <input type="date" class="block-field-input" value="${escapeHtml(block.data?.target_date || '')}" oninput="updateBlockData(${blockIndex}, 'target_date', this.value)">
  1249. </div>
  1250. <div class="block-field">
  1251. <label class="block-field-label">Target Time</label>
  1252. <input type="time" class="block-field-input" value="${escapeHtml(block.data?.target_time || '00:00')}" oninput="updateBlockData(${blockIndex}, 'target_time', this.value)">
  1253. </div>
  1254. </div>
  1255. `;
  1256. ​
  1257. case 'newsletter':
  1258. return `
  1259. <div class="block-field">
  1260. <label class="block-field-label">Title</label>
  1261. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1262. </div>
  1263. <div class="block-field">
  1264. <label class="block-field-label">Subtitle</label>
  1265. <textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">${escapeHtml(block.data?.subtitle || '')}</textarea>
  1266. </div>
  1267. <div class="form-row">
  1268. <div class="block-field">
  1269. <label class="block-field-label">Input Placeholder</label>
  1270. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.placeholder || '')}" oninput="updateBlockData(${blockIndex}, 'placeholder', this.value)">
  1271. </div>
  1272. <div class="block-field">
  1273. <label class="block-field-label">Button Text</label>
  1274. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.button_text || '')}" oninput="updateBlockData(${blockIndex}, 'button_text', this.value)">
  1275. </div>
  1276. </div>
  1277. `;
  1278. ​
  1279. case 'html':
  1280. return `
  1281. <div class="block-field">
  1282. <label class="block-field-label">Custom HTML Code</label>
  1283. <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>
  1284. <p style="font-size: 0.75rem; color: var(--text-subtle); margin-top: 8px;">You can use any HTML, plus CSS in style tags and JavaScript in script tags.</p>
  1285. </div>
  1286. `;
  1287. ​
  1288. case 'image-text':
  1289. return `
  1290. <div class="form-row">
  1291. <div class="block-field">
  1292. <label class="block-field-label">Section Title</label>
  1293. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1294. </div>
  1295. <div class="block-field">
  1296. <label class="block-field-label">Section Subtitle</label>
  1297. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1298. </div>
  1299. </div>
  1300. <div class="block-field">
  1301. <label class="block-field-label">Image URL</label>
  1302. <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://...">
  1303. </div>
  1304. <div class="form-row">
  1305. <div class="block-field">
  1306. <label class="block-field-label">Image Alt Text</label>
  1307. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.image_alt || '')}" oninput="updateBlockData(${blockIndex}, 'image_alt', this.value)">
  1308. </div>
  1309. <div class="block-field">
  1310. <label class="block-field-label">Image Position</label>
  1311. <select class="block-field-input" onchange="updateBlockData(${blockIndex}, 'image_position', this.value)">
  1312. <option value="left" ${block.data?.image_position === 'left' ? 'selected' : ''}>Left</option>
  1313. <option value="right" ${block.data?.image_position === 'right' ? 'selected' : ''}>Right</option>
  1314. </select>
  1315. </div>
  1316. </div>
  1317. <div class="block-field">
  1318. <label class="block-field-label">Content Text</label>
  1319. <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>
  1320. </div>
  1321. <div class="form-row">
  1322. <div class="block-field">
  1323. <label class="block-field-label">Button Text (optional)</label>
  1324. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.button_text || '')}" oninput="updateBlockData(${blockIndex}, 'button_text', this.value)">
  1325. </div>
  1326. <div class="block-field">
  1327. <label class="block-field-label">Button URL</label>
  1328. ${renderLinkInput(block.data?.button_url || '#', 'updateBlockData(' + blockIndex + ', \'button_url\', VALUE)')}
  1329. </div>
  1330. </div>
  1331. `;
  1332. ​
  1333. case 'product-cards':
  1334. return `
  1335. <div class="form-row">
  1336. <div class="block-field">
  1337. <label class="block-field-label">Section Title</label>
  1338. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1339. </div>
  1340. <div class="block-field">
  1341. <label class="block-field-label">Section Subtitle</label>
  1342. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1343. </div>
  1344. </div>
  1345. <div class="block-field">
  1346. <label class="block-field-label">Products</label>
  1347. <div class="repeater-items" id="products-items-${blockIndex}">
  1348. ${(block.data?.products || []).map((item, i) => `
  1349. <div class="repeater-item">
  1350. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  1351. <span class="repeater-item-title">${escapeHtml(item.title || 'New Product')}</span>
  1352. <div class="repeater-item-actions">
  1353. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'products', ${i})" style="color: var(--error);">Remove</button>
  1354. </div>
  1355. </div>
  1356. <div class="repeater-item-content">
  1357. <div class="form-row">
  1358. <div class="block-field">
  1359. <label class="block-field-label">Product Title</label>
  1360. <input type="text" class="block-field-input" value="${escapeHtml(item.title || '')}" oninput="updateRepeaterItem(${blockIndex}, 'products', ${i}, 'title', this.value)">
  1361. </div>
  1362. <div class="block-field">
  1363. <label class="block-field-label">Product Image (48x48)</label>
  1364. <input type="text" data-media="image" class="block-field-input" value="${escapeHtml(item.image || '')}" oninput="updateRepeaterItem(${blockIndex}, 'products', ${i}, 'image', this.value)" placeholder="https://...">
  1365. </div>
  1366. </div>
  1367. <div class="block-field">
  1368. <label class="block-field-label">Description</label>
  1369. <textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateRepeaterItem(${blockIndex}, 'products', ${i}, 'description', this.value)">${escapeHtml(item.description || '')}</textarea>
  1370. </div>
  1371. <div class="block-field">
  1372. <label class="block-field-label">Features (one per line)</label>
  1373. <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>
  1374. </div>
  1375. <div class="form-row">
  1376. <div class="block-field">
  1377. <label class="block-field-label">Button Text</label>
  1378. <input type="text" class="block-field-input" value="${escapeHtml(item.button_text || '')}" oninput="updateRepeaterItem(${blockIndex}, 'products', ${i}, 'button_text', this.value)">
  1379. </div>
  1380. <div class="block-field">
  1381. <label class="block-field-label">Button URL</label>
  1382. <input type="text" class="block-field-input" value="${escapeHtml(item.button_url || '#')}" oninput="updateRepeaterItem(${blockIndex}, 'products', ${i}, 'button_url', this.value)">
  1383. </div>
  1384. </div>
  1385. </div>
  1386. </div>
  1387. `).join('')}
  1388. </div>
  1389. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'products', {title: '', image: '', description: '', features: [], button_text: 'View', button_url: '#'})">
  1390. <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>
  1391. Add Product
  1392. </button>
  1393. </div>
  1394. `;
  1395. ​
  1396. case 'video':
  1397. return `
  1398. <div class="form-row">
  1399. <div class="block-field">
  1400. <label class="block-field-label">Section Title</label>
  1401. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1402. </div>
  1403. <div class="block-field">
  1404. <label class="block-field-label">Section Subtitle</label>
  1405. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1406. </div>
  1407. </div>
  1408. <div class="form-row">
  1409. <div class="block-field">
  1410. <label class="block-field-label">Video URL</label>
  1411. <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">
  1412. </div>
  1413. <div class="block-field">
  1414. <label class="block-field-label">Video Type</label>
  1415. <select class="block-field-input" onchange="updateBlockData(${blockIndex}, 'type', this.value)">
  1416. <option value="url" ${block.data?.type === 'url' ? 'selected' : ''}>Direct URL</option>
  1417. <option value="youtube" ${block.data?.type === 'youtube' ? 'selected' : ''}>YouTube</option>
  1418. <option value="facebook" ${block.data?.type === 'facebook' ? 'selected' : ''}>Facebook</option>
  1419. </select>
  1420. </div>
  1421. </div>
  1422. <div class="block-field">
  1423. <label class="block-field-label">Poster Image URL (Thumbnail)</label>
  1424. <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">
  1425. </div>
  1426. <div class="block-field">
  1427. <label class="block-field-label">Caption</label>
  1428. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.caption || '')}" oninput="updateBlockData(${blockIndex}, 'caption', this.value)">
  1429. </div>
  1430. `;
  1431. ​
  1432. case 'gallery':
  1433. return `
  1434. <div class="form-row">
  1435. <div class="block-field">
  1436. <label class="block-field-label">Section Title</label>
  1437. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1438. </div>
  1439. <div class="block-field">
  1440. <label class="block-field-label">Section Subtitle</label>
  1441. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1442. </div>
  1443. </div>
  1444. <div class="block-field">
  1445. <label class="block-field-label">Images</label>
  1446. <div class="repeater-items" id="images-items-${blockIndex}">
  1447. ${(block.data?.images || []).map((item, i) => `
  1448. <div class="repeater-item">
  1449. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  1450. <span class="repeater-item-title">${escapeHtml(item.alt || 'Image ' + (i + 1))}</span>
  1451. <div class="repeater-item-actions">
  1452. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'images', ${i})" style="color: var(--error);">Remove</button>
  1453. </div>
  1454. </div>
  1455. <div class="repeater-item-content">
  1456. <div class="form-row">
  1457. <div class="block-field">
  1458. <label class="block-field-label">Image URL</label>
  1459. <input type="text" data-media="image" class="block-field-input" value="${escapeHtml(item.url || '')}" oninput="updateRepeaterItem(${blockIndex}, 'images', ${i}, 'url', this.value)" placeholder="https://...">
  1460. </div>
  1461. <div class="block-field">
  1462. <label class="block-field-label">Alt Text</label>
  1463. <input type="text" class="block-field-input" value="${escapeHtml(item.alt || '')}" oninput="updateRepeaterItem(${blockIndex}, 'images', ${i}, 'alt', this.value)">
  1464. </div>
  1465. </div>
  1466. <div class="block-field">
  1467. <label class="block-field-label">Caption</label>
  1468. <input type="text" class="block-field-input" value="${escapeHtml(item.caption || '')}" oninput="updateRepeaterItem(${blockIndex}, 'images', ${i}, 'caption', this.value)">
  1469. </div>
  1470. </div>
  1471. </div>
  1472. `).join('')}
  1473. </div>
  1474. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'images', {url: '', alt: '', caption: ''})">
  1475. <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>
  1476. Add Image
  1477. </button>
  1478. </div>
  1479. `;
  1480. ​
  1481. case 'faq':
  1482. return `
  1483. <div class="form-row">
  1484. <div class="block-field">
  1485. <label class="block-field-label">Section Title</label>
  1486. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1487. </div>
  1488. <div class="block-field">
  1489. <label class="block-field-label">Section Subtitle</label>
  1490. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1491. </div>
  1492. </div>
  1493. <div class="block-field">
  1494. <label class="block-field-label">FAQ Items</label>
  1495. <div class="repeater-items" id="items-items-${blockIndex}">
  1496. ${(block.data?.items || []).map((item, i) => `
  1497. <div class="repeater-item">
  1498. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  1499. <span class="repeater-item-title">${escapeHtml(item.question || 'Question ' + (i + 1))}</span>
  1500. <div class="repeater-item-actions">
  1501. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'items', ${i})" style="color: var(--error);">Remove</button>
  1502. </div>
  1503. </div>
  1504. <div class="repeater-item-content">
  1505. <div class="block-field">
  1506. <label class="block-field-label">Question</label>
  1507. <input type="text" class="block-field-input" value="${escapeHtml(item.question || '')}" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'question', this.value)">
  1508. </div>
  1509. <div class="block-field">
  1510. <label class="block-field-label">Answer</label>
  1511. <textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateRepeaterItem(${blockIndex}, 'items', ${i}, 'answer', this.value)">${escapeHtml(item.answer || '')}</textarea>
  1512. </div>
  1513. </div>
  1514. </div>
  1515. `).join('')}
  1516. </div>
  1517. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'items', {question: '', answer: ''})">
  1518. <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>
  1519. Add Question
  1520. </button>
  1521. </div>
  1522. `;
  1523. ​
  1524. case 'team':
  1525. return `
  1526. <div class="form-row">
  1527. <div class="block-field">
  1528. <label class="block-field-label">Section Title</label>
  1529. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.title || '')}" oninput="updateBlockData(${blockIndex}, 'title', this.value)">
  1530. </div>
  1531. <div class="block-field">
  1532. <label class="block-field-label">Section Subtitle</label>
  1533. <input type="text" class="block-field-input" value="${escapeHtml(block.data?.subtitle || '')}" oninput="updateBlockData(${blockIndex}, 'subtitle', this.value)">
  1534. </div>
  1535. </div>
  1536. <div class="block-field">
  1537. <label class="block-field-label">Team Members</label>
  1538. <div class="repeater-items" id="members-items-${blockIndex}">
  1539. ${(block.data?.members || []).map((member, i) => `
  1540. <div class="repeater-item">
  1541. <div class="repeater-item-header" onclick="this.parentElement.classList.toggle('expanded')">
  1542. <span class="repeater-item-title">${escapeHtml(member.name || 'Team Member ' + (i + 1))}</span>
  1543. <div class="repeater-item-actions">
  1544. <button type="button" class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); removeRepeaterItem(${blockIndex}, 'members', ${i})" style="color: var(--error);">Remove</button>
  1545. </div>
  1546. </div>
  1547. <div class="repeater-item-content">
  1548. <div class="form-row">
  1549. <div class="block-field">
  1550. <label class="block-field-label">Name</label>
  1551. <input type="text" class="block-field-input" value="${escapeHtml(member.name || '')}" oninput="updateRepeaterItem(${blockIndex}, 'members', ${i}, 'name', this.value)">
  1552. </div>
  1553. <div class="block-field">
  1554. <label class="block-field-label">Role</label>
  1555. <input type="text" class="block-field-input" value="${escapeHtml(member.role || '')}" oninput="updateRepeaterItem(${blockIndex}, 'members', ${i}, 'role', this.value)">
  1556. </div>
  1557. </div>
  1558. <div class="form-row">
  1559. <div class="block-field">
  1560. <label class="block-field-label">Photo URL</label>
  1561. <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)">
  1562. </div>
  1563. <div class="block-field">
  1564. <label class="block-field-label">Initials (if no photo)</label>
  1565. <input type="text" class="block-field-input" value="${escapeHtml(member.initials || '')}" oninput="updateRepeaterItem(${blockIndex}, 'members', ${i}, 'initials', this.value)" maxlength="2">
  1566. </div>
  1567. </div>
  1568. <div class="block-field">
  1569. <label class="block-field-label">Bio</label>
  1570. <textarea data-rich="1" class="block-field-input block-field-textarea" oninput="updateRepeaterItem(${blockIndex}, 'members', ${i}, 'bio', this.value)">${escapeHtml(member.bio || '')}</textarea>
  1571. </div>
  1572. <div class="block-field">
  1573. <label class="block-field-label">Social Links (JSON)</label>
  1574. <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>
  1575. </div>
  1576. </div>
  1577. </div>
  1578. `).join('')}
  1579. </div>
  1580. <button type="button" class="repeater-add" onclick="addRepeaterItem(${blockIndex}, 'members', {name: '', role: '', initials: '', image: '', bio: '', social: []})">
  1581. <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>
  1582. Add Team Member
  1583. </button>
  1584. </div>
  1585. `;
  1586. ​
  1587. default:
  1588. return '<p style="color: var(--text-muted);">This block type isn\'t supported.</p>';
  1589. }
  1590. }
  1591. ​
  1592. function renderLinkInput(value, callback) {
  1593. const id = 'link-' + Math.random().toString(36).substr(2, 9);
  1594. return '<div class="link-input-wrapper">' +
  1595. '<input type="text" class="block-field-input" id="' + id + '" value="' + escapeHtml(value) + '" oninput="' + callback.replace('VALUE', 'this.value') + '">' +
  1596. '<button type="button" class="link-page-btn" onclick="showLinkSelector(\'' + id + '\', \'' + callback.replace(/'/g, "\\'") + '\')" title="Select page">' +
  1597. '<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>' +
  1598. '</button>' +
  1599. '</div>';
  1600. }
  1601. ​
  1602. function updateBlockData(blockIndex, key, value) {
  1603. if (!blocks[blockIndex].data) blocks[blockIndex].data = {};
  1604. blocks[blockIndex].data[key] = value;
  1605. schedulePreviewUpdate();
  1606. }
  1607. ​
  1608. function updateRepeaterItem(blockIndex, key, itemIndex, field, value) {
  1609. if (!blocks[blockIndex].data) blocks[blockIndex].data = {};
  1610. if (!blocks[blockIndex].data[key]) blocks[blockIndex].data[key] = [];
  1611. if (!blocks[blockIndex].data[key][itemIndex]) blocks[blockIndex].data[key][itemIndex] = {};
  1612. blocks[blockIndex].data[key][itemIndex][field] = value;
  1613. schedulePreviewUpdate();
  1614. }
  1615. ​
  1616. function addRepeaterItem(blockIndex, key, defaultItem) {
  1617. if (!blocks[blockIndex].data) blocks[blockIndex].data = {};
  1618. if (!blocks[blockIndex].data[key]) blocks[blockIndex].data[key] = [];
  1619. blocks[blockIndex].data[key].push({ ...defaultItem });
  1620. schedulePreviewUpdate();
  1621. renderBlocks();
  1622. }
  1623. ​
  1624. function removeRepeaterItem(blockIndex, key, itemIndex) {
  1625. blocks[blockIndex].data[key].splice(itemIndex, 1);
  1626. renderBlocks();
  1627. schedulePreviewUpdate();
  1628. }
  1629. ​
  1630. function toggleBlock(index) {
  1631. const items = document.querySelectorAll('.block-item');
  1632. items[index]?.classList.toggle('expanded');
  1633. }
  1634. ​
  1635. function expandAllBlocks() {
  1636. document.querySelectorAll('.block-item').forEach(el => el.classList.add('expanded'));
  1637. }
  1638. ​
  1639. function collapseAllBlocks() {
  1640. document.querySelectorAll('.block-item').forEach(el => el.classList.remove('expanded'));
  1641. }
  1642. ​
  1643. function addBlock(type) {
  1644. const defaultId = getDefaultBlockId(type);
  1645. const newBlock = {
  1646. id: defaultId,
  1647. type: type,
  1648. data: getDefaultBlockData(type)
  1649. };
  1650. blocks.push(newBlock);
  1651. renderBlocks();
  1652. hideAddBlockModal();
  1653. schedulePreviewUpdate();
  1654. ​
  1655. setTimeout(() => {
  1656. const items = document.querySelectorAll('.block-item');
  1657. items[items.length - 1]?.classList.add('expanded');
  1658. items[items.length - 1]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
  1659. }, 50);
  1660. }
  1661. ​
  1662. function getDefaultBlockId(type) {
  1663. const baseName = type;
  1664. const existingIds = blocks
  1665. .filter(b => b.type === type && b.id)
  1666. .map(b => b.id);
  1667. ​
  1668. if (existingIds.length === 0) {
  1669. return baseName;
  1670. }
  1671. ​
  1672. let counter = 2;
  1673. while (existingIds.includes(baseName + '-' + counter)) {
  1674. counter++;
  1675. }
  1676. return baseName + '-' + counter;
  1677. }
  1678. ​
  1679. function getDefaultBlockData(type) {
  1680. switch (type) {
  1681. case 'hero':
  1682. return { badge: '', title: '', subtitle: '', button_primary: '', button_primary_url: '#', button_secondary: '', button_secondary_url: '#' };
  1683. case 'stats':
  1684. return { items: [] };
  1685. case 'features':
  1686. return { title: '', subtitle: '', items: [] };
  1687. case 'testimonials':
  1688. return { title: '', subtitle: '', items: [] };
  1689. case 'pricing':
  1690. return { title: '', subtitle: '', items: [] };
  1691. case 'cta':
  1692. return { title: '', subtitle: '', button_primary: '', button_primary_url: '#', button_secondary: '', button_secondary_url: '#' };
  1693. case 'text':
  1694. return { title: '', content: '' };
  1695. case 'image':
  1696. return { url: '', alt: '', caption: '' };
  1697. case 'image-text':
  1698. return { image_url: '', image_alt: '', title: '', subtitle: '', content: '', image_position: 'left', button_text: '', button_url: '#' };
  1699. case 'product-cards':
  1700. return { title: '', subtitle: '', products: [] };
  1701. case 'video':
  1702. return { title: 'Watch Our Video', subtitle: '', url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', type: 'youtube', caption: '' };
  1703. case 'gallery':
  1704. return {
  1705. title: 'Photo Gallery', subtitle: '', images: [
  1706. { url: 'https://placehold.co/800x600', alt: 'Gallery image 1', caption: '' },
  1707. { url: 'https://placehold.co/800x600', alt: 'Gallery image 2', caption: '' },
  1708. { url: 'https://placehold.co/800x600', alt: 'Gallery image 3', caption: '' }
  1709. ]
  1710. };
  1711. case 'faq':
  1712. return {
  1713. title: 'Frequently Asked Questions', subtitle: '', items: [
  1714. { question: 'What do you offer?', answer: 'Write a short, clear answer here.' },
  1715. { question: 'How does it work?', answer: 'Explain the steps in a sentence or two.' },
  1716. { question: 'How much does it cost?', answer: 'Mention your prices, or where to find them.' }
  1717. ]
  1718. };
  1719. case 'team':
  1720. return {
  1721. title: 'Meet Our Team', subtitle: '', members: [
  1722. { name: 'John Doe', role: 'CEO & Founder', initials: 'JD', image: '', bio: '', social: [{ platform: 'twitter', url: '#' }, { platform: 'linkedin', url: '#' }] },
  1723. { name: 'Jane Smith', role: 'Head of Design', initials: 'JS', image: '', bio: '', social: [{ platform: 'twitter', url: '#' }, { platform: 'linkedin', url: '#' }] }
  1724. ]
  1725. };
  1726. case 'audio':
  1727. return { title: 'Listen Now', subtitle: '', url: '', music_link: '', music_link_text: 'Download' };
  1728. case 'countdown':
  1729. return { title: 'Coming Soon', subtitle: '', target_date: '', target_time: '00:00' };
  1730. case 'newsletter':
  1731. return { title: 'Subscribe to Our Newsletter', subtitle: 'Get new posts and updates by email.', button_text: 'Subscribe', placeholder: 'Enter your email' };
  1732. case 'html':
  1733. return { html: '' };
  1734. default:
  1735. return {};
  1736. }
  1737. }
  1738. ​
  1739. function duplicateBlock(index) {
  1740. const newBlock = JSON.parse(JSON.stringify(blocks[index]));
  1741. const baseType = blocks[index].type;
  1742. newBlock.id = getDefaultBlockId(baseType);
  1743. blocks.splice(index + 1, 0, newBlock);
  1744. renderBlocks();
  1745. }
  1746. ​
  1747. function deleteBlock(index) {
  1748. if (confirm('Delete this block?')) {
  1749. blocks.splice(index, 1);
  1750. renderBlocks();
  1751. schedulePreviewUpdate();
  1752. }
  1753. }
  1754. ​
  1755. function moveBlockUp(index) {
  1756. if (index > 0) {
  1757. [blocks[index], blocks[index - 1]] = [blocks[index - 1], blocks[index]];
  1758. renderBlocks();
  1759. }
  1760. }
  1761. ​
  1762. function moveBlockDown(index) {
  1763. if (index < blocks.length - 1) {
  1764. [blocks[index], blocks[index + 1]] = [blocks[index + 1], blocks[index]];
  1765. renderBlocks();
  1766. }
  1767. }
  1768. ​
  1769. function handleDragStart(e) {
  1770. draggedBlock = this;
  1771. this.classList.add('dragging');
  1772. e.dataTransfer.effectAllowed = 'move';
  1773. }
  1774. ​
  1775. function handleDragEnd(e) {
  1776. this.classList.remove('dragging');
  1777. document.querySelectorAll('.block-item').forEach(el => el.classList.remove('drag-over'));
  1778. draggedBlock = null;
  1779. }
  1780. ​
  1781. function handleDragOver(e) {
  1782. e.preventDefault();
  1783. e.dataTransfer.dropEffect = 'move';
  1784. this.classList.add('drag-over');
  1785. }
  1786. ​
  1787. function handleDragLeave(e) {
  1788. this.classList.remove('drag-over');
  1789. }
  1790. ​
  1791. function handleDrop(e) {
  1792. e.preventDefault();
  1793. this.classList.remove('drag-over');
  1794. ​
  1795. if (draggedBlock && draggedBlock !== this) {
  1796. const fromIndex = parseInt(draggedBlock.dataset.index);
  1797. const toIndex = parseInt(this.dataset.index);
  1798. ​
  1799. const [movedBlock] = blocks.splice(fromIndex, 1);
  1800. blocks.splice(toIndex, 0, movedBlock);
  1801. renderBlocks();
  1802. schedulePreviewUpdate();
  1803. }
  1804. }
  1805. ​
  1806. ​
  1807. const NE_SVG = {
  1808. 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>',
  1809. 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>',
  1810. 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>',
  1811. 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>',
  1812. 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>'
  1813. };
  1814. ​
  1815. const SOCIAL_PLATFORMS = [
  1816. ['twitter', 'Twitter/X'], ['github', 'GitHub'], ['linkedin', 'LinkedIn'], ['facebook', 'Facebook'],
  1817. ['instagram', 'Instagram'], ['youtube', 'YouTube'], ['tiktok', 'TikTok'], ['pinterest', 'Pinterest'],
  1818. ['reddit', 'Reddit'], ['discord', 'Discord'], ['snapchat', 'Snapchat'], ['whatsapp', 'Whatsapp'],
  1819. ['telegram', 'Telegram'], ['twitch', 'Twitch'], ['spotify', 'Spotify'], ['medium', 'Medium'],
  1820. ['slack', 'Slack'], ['dribbble', 'Dribbble'], ['mastodon', 'Mastodon'], ['patreon', 'Patreon'],
  1821. ['kofi', 'Ko-Fi'], ['vimeo', 'Vimeo'], ['tumblr', 'Tumblr'], ['stackoverflow', 'Stack Overflow'],
  1822. ['gitlab', 'GitLab'], ['bluesky', 'Bluesky'], ['line', 'Line'], ['wechat', 'WeChat'],
  1823. ['flatlypage', 'FlatlyPage CMS'], ['xing', 'Xing']
  1824. ];
  1825. ​
  1826. let navIconPickerIndex = -1;
  1827. let navButtonIconPickerIndex = -1;
  1828. ​
  1829. function neIconSvg(id) {
  1830. return `<svg class="ne-icon-svg" aria-hidden="true"><use href="${FLATLY_BASE_URL}/assets/icons.svg#${id}"></use></svg>`;
  1831. }
  1832. ​
  1833. function neActions(moveFn, removeFn, index, count) {
  1834. return `
  1835. <div class="ne-actions">
  1836. <button type="button" class="ne-btn" title="Move up" ${index === 0 ? 'disabled' : ''} onclick="${moveFn}(${index}, -1)">${NE_SVG.up}</button>
  1837. <button type="button" class="ne-btn" title="Move down" ${index >= count - 1 ? 'disabled' : ''} onclick="${moveFn}(${index}, 1)">${NE_SVG.down}</button>
  1838. <button type="button" class="ne-btn ne-btn-danger" title="Remove" onclick="${removeFn}(${index})">${NE_SVG.trash}</button>
  1839. </div>`;
  1840. }
  1841. ​
  1842. function neMove(list, index, dir) {
  1843. const target = index + dir;
  1844. if (!list || target < 0 || target >= list.length) return false;
  1845. [list[index], list[target]] = [list[target], list[index]];
  1846. return true;
  1847. }
  1848. ​
  1849. function neEmpty(text) {
  1850. return `<div class="ne-empty">${text}</div>`;
  1851. }
  1852. ​
  1853. function neFocusLast(container, selector) {
  1854. if (!container) return;
  1855. const fields = container.querySelectorAll(selector);
  1856. if (fields.length) fields[fields.length - 1].focus();
  1857. }
  1858. ​
  1859. function renderNavLinks() {
  1860. const container = document.getElementById('navLinksContainer');
  1861. if (!container) return;
  1862. ​
  1863. const hasIcons = Array.isArray(ICON_IDS) && ICON_IDS.length > 0;
  1864. ​
  1865. container.innerHTML = navLinks.length ? `<div class="ne-list">${navLinks.map((link, i) => `
  1866. <div class="ne-item">
  1867. <div class="ne-row">
  1868. ${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>` : ''}
  1869. <input type="text" class="ne-input ne-nav-label" value="${escapeHtml(link.label || '')}" placeholder="Label" oninput="navLinks[${i}].label = this.value">
  1870. <input type="text" class="ne-input" value="${escapeHtml(link.url || '')}" placeholder="URL or #anchor" oninput="navLinks[${i}].url = this.value">
  1871. ${neActions('moveNavLink', 'removeNavLink', i, navLinks.length)}
  1872. </div>
  1873. ${hasIcons && navIconPickerIndex === i ? `
  1874. <div class="ne-picker">
  1875. <div class="ne-picker-head">
  1876. <input type="text" class="ne-input" placeholder="Search icons" oninput="filterNavIcons(this.value, this)">
  1877. <button type="button" class="ne-chip" onclick="setNavLinkIcon(${i}, '')">No icon</button>
  1878. </div>
  1879. <div class="ne-picker-grid">
  1880. ${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('')}
  1881. </div>
  1882. </div>` : ''}
  1883. </div>`).join('')}</div>` : neEmpty('No links yet. Add your first menu link.');
  1884. ​
  1885. if (navIconPickerIndex >= 0) {
  1886. const search = container.querySelector('.ne-picker .ne-input');
  1887. if (search) search.focus();
  1888. }
  1889. }
  1890. ​
  1891. function toggleNavIconPicker(index) {
  1892. navIconPickerIndex = navIconPickerIndex === index ? -1 : index;
  1893. renderNavLinks();
  1894. }
  1895. ​
  1896. function filterNavIcons(query, input) {
  1897. const q = query.trim().toLowerCase();
  1898. const scope = input && input.closest(".ne-picker") ? input.closest(".ne-picker") : document;
  1899. scope.querySelectorAll(".ne-picker-item").forEach(btn => {
  1900. btn.style.display = !q || btn.dataset.icon.includes(q) ? "" : "none";
  1901. });
  1902. }
  1903. ​
  1904. function setNavLinkIcon(index, icon) {
  1905. if (!navLinks[index]) return;
  1906. if (icon) {
  1907. navLinks[index].icon = icon;
  1908. } else {
  1909. delete navLinks[index].icon;
  1910. }
  1911. navIconPickerIndex = -1;
  1912. renderNavLinks();
  1913. }
  1914. ​
  1915. function addNavLink() {
  1916. navLinks.push({ label: '', url: '#' });
  1917. navIconPickerIndex = -1;
  1918. renderNavLinks();
  1919. neFocusLast(document.getElementById('navLinksContainer'), '.ne-nav-label');
  1920. }
  1921. ​
  1922. function removeNavLink(index) {
  1923. navLinks.splice(index, 1);
  1924. navIconPickerIndex = -1;
  1925. renderNavLinks();
  1926. }
  1927. ​
  1928. function moveNavLink(index, dir) {
  1929. if (neMove(navLinks, index, dir)) {
  1930. navIconPickerIndex = -1;
  1931. renderNavLinks();
  1932. }
  1933. }
  1934. ​
  1935. function renderNavButtons() {
  1936. const container = document.getElementById('navButtonsContainer');
  1937. if (!container) return;
  1938. ​
  1939. const hasIcons = Array.isArray(ICON_IDS) && ICON_IDS.length > 0;
  1940. ​
  1941. container.innerHTML = navButtons.length ? `<div class="ne-list">${navButtons.map((btn, i) => `
  1942. <div class="ne-item">
  1943. <div class="ne-row">
  1944. ${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>` : ''}
  1945. <input type="text" class="ne-input ne-btn-label" value="${escapeHtml(btn.label || '')}" placeholder="Label" oninput="navButtons[${i}].label = this.value">
  1946. <input type="text" class="ne-input" value="${escapeHtml(btn.url || '')}" placeholder="URL" oninput="navButtons[${i}].url = this.value">
  1947. <select class="ne-input ne-select" onchange="navButtons[${i}].style = this.value">
  1948. <option value="ghost" ${btn.style === 'ghost' ? 'selected' : ''}>Ghost</option>
  1949. <option value="primary" ${btn.style === 'primary' ? 'selected' : ''}>Primary</option>
  1950. <option value="outline" ${btn.style === 'outline' ? 'selected' : ''}>Outline</option>
  1951. </select>
  1952. ${neActions('moveNavButton', 'removeNavButton', i, navButtons.length)}
  1953. </div>
  1954. ${hasIcons && navButtonIconPickerIndex === i ? `
  1955. <div class="ne-picker">
  1956. <div class="ne-picker-head">
  1957. <input type="text" class="ne-input" placeholder="Search icons" oninput="filterNavIcons(this.value, this)">
  1958. <button type="button" class="ne-chip" onclick="setNavButtonIcon(${i}, '')">No icon</button>
  1959. </div>
  1960. <div class="ne-picker-grid">
  1961. ${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('')}
  1962. </div>
  1963. </div>` : ''}
  1964. </div>`).join('')}</div>` : neEmpty('No buttons yet.');
  1965. ​
  1966. if (navButtonIconPickerIndex >= 0) {
  1967. const search = container.querySelector('.ne-picker .ne-input');
  1968. if (search) search.focus();
  1969. }
  1970. }
  1971. ​
  1972. function toggleNavButtonIconPicker(index) {
  1973. navButtonIconPickerIndex = navButtonIconPickerIndex === index ? -1 : index;
  1974. renderNavButtons();
  1975. }
  1976. ​
  1977. function setNavButtonIcon(index, icon) {
  1978. if (!navButtons[index]) return;
  1979. if (icon) {
  1980. navButtons[index].icon = icon;
  1981. } else {
  1982. delete navButtons[index].icon;
  1983. }
  1984. navButtonIconPickerIndex = -1;
  1985. renderNavButtons();
  1986. }
  1987. ​
  1988. function addNavButton() {
  1989. navButtons.push({ label: '', url: '#', style: 'ghost' });
  1990. navButtonIconPickerIndex = -1;
  1991. renderNavButtons();
  1992. neFocusLast(document.getElementById('navButtonsContainer'), '.ne-btn-label');
  1993. }
  1994. ​
  1995. function removeNavButton(index) {
  1996. navButtons.splice(index, 1);
  1997. navButtonIconPickerIndex = -1;
  1998. renderNavButtons();
  1999. }
  2000. ​
  2001. function moveNavButton(index, dir) {
  2002. if (neMove(navButtons, index, dir)) {
  2003. navButtonIconPickerIndex = -1;
  2004. renderNavButtons();
  2005. }
  2006. }
  2007. ​
  2008. function ensureFooterColumn(colIndex) {
  2009. if (!footerData.columns) footerData.columns = [];
  2010. if (!footerData.columns[colIndex]) footerData.columns[colIndex] = { title: '', links: [] };
  2011. if (!footerData.columns[colIndex].links) footerData.columns[colIndex].links = [];
  2012. return footerData.columns[colIndex];
  2013. }
  2014. ​
  2015. function updateFooterColumnTitle(colIndex, value) {
  2016. ensureFooterColumn(colIndex).title = value;
  2017. updateFooterData();
  2018. }
  2019. ​
  2020. function updateFooterColumnLinkLabel(colIndex, linkIndex, value) {
  2021. const col = ensureFooterColumn(colIndex);
  2022. if (!col.links[linkIndex]) col.links[linkIndex] = { label: '', url: '' };
  2023. col.links[linkIndex].label = value;
  2024. updateFooterData();
  2025. }
  2026. ​
  2027. function updateFooterColumnLinkUrl(colIndex, linkIndex, value) {
  2028. const col = ensureFooterColumn(colIndex);
  2029. if (!col.links[linkIndex]) col.links[linkIndex] = { label: '', url: '' };
  2030. col.links[linkIndex].url = value;
  2031. updateFooterData();
  2032. }
  2033. ​
  2034. function renderFooterColumns() {
  2035. const container = document.getElementById('footerColumnsContainer');
  2036. if (!container) return;
  2037. if (!footerData.columns) footerData.columns = [];
  2038. ​
  2039. container.innerHTML = footerData.columns.length ? `<div class="ne-columns">${footerData.columns.map((col, i) => {
  2040. if (!col) col = { title: '', links: [] };
  2041. const links = col.links || [];
  2042. return `
  2043. <div class="ne-card">
  2044. <div class="ne-card-head">
  2045. <input type="text" class="ne-input ne-title ne-col-title" value="${escapeHtml(col.title || '')}" placeholder="Column title" oninput="updateFooterColumnTitle(${i}, this.value)">
  2046. ${neActions('moveFooterColumn', 'removeFooterColumn', i, footerData.columns.length)}
  2047. </div>
  2048. <div class="ne-card-body">
  2049. ${links.length ? links.map((link, j) => `
  2050. <div class="ne-row ne-row-compact">
  2051. <input type="text" class="ne-input ne-link-label" value="${escapeHtml(link.label || '')}" placeholder="Label" oninput="updateFooterColumnLinkLabel(${i}, ${j}, this.value)">
  2052. <input type="text" class="ne-input" value="${escapeHtml(link.url || '')}" placeholder="URL" oninput="updateFooterColumnLinkUrl(${i}, ${j}, this.value)">
  2053. <button type="button" class="ne-btn ne-btn-danger" title="Remove link" onclick="removeFooterColumnLink(${i}, ${j})">${NE_SVG.trash}</button>
  2054. </div>`).join('') : neEmpty('No links in this column.')}
  2055. <button type="button" class="ne-add" onclick="addFooterColumnLink(${i})">${NE_SVG.plus} Add Link</button>
  2056. </div>
  2057. </div>`;
  2058. }).join('')}</div>` : neEmpty('No columns yet. Add your first footer column.');
  2059. }
  2060. ​
  2061. function addFooterColumn() {
  2062. if (!footerData.columns) footerData.columns = [];
  2063. footerData.columns.push({ title: '', links: [] });
  2064. renderFooterColumns();
  2065. updateFooterData();
  2066. neFocusLast(document.getElementById('footerColumnsContainer'), '.ne-col-title');
  2067. }
  2068. ​
  2069. function removeFooterColumn(index) {
  2070. footerData.columns.splice(index, 1);
  2071. renderFooterColumns();
  2072. updateFooterData();
  2073. }
  2074. ​
  2075. function moveFooterColumn(index, dir) {
  2076. if (neMove(footerData.columns, index, dir)) {
  2077. renderFooterColumns();
  2078. updateFooterData();
  2079. }
  2080. }
  2081. ​
  2082. function addFooterColumnLink(colIndex) {
  2083. ensureFooterColumn(colIndex).links.push({ label: '', url: '#' });
  2084. renderFooterColumns();
  2085. updateFooterData();
  2086. const cards = document.querySelectorAll('#footerColumnsContainer .ne-card');
  2087. if (cards[colIndex]) neFocusLast(cards[colIndex], '.ne-link-label');
  2088. }
  2089. ​
  2090. function removeFooterColumnLink(colIndex, linkIndex) {
  2091. footerData.columns[colIndex].links.splice(linkIndex, 1);
  2092. renderFooterColumns();
  2093. updateFooterData();
  2094. }
  2095. ​
  2096. function renderSocialLinks() {
  2097. const container = document.getElementById('socialLinksContainer');
  2098. if (!container) return;
  2099. if (!footerData.social_links) footerData.social_links = [];
  2100. ​
  2101. container.innerHTML = footerData.social_links.length ? `<div class="ne-list">${footerData.social_links.map((link, i) => `
  2102. <div class="ne-item">
  2103. <div class="ne-row">
  2104. <select class="ne-input ne-select" onchange="updateSocialLink(${i}, 'platform', this.value)">
  2105. ${SOCIAL_PLATFORMS.map(([value, label]) => `<option value="${value}" ${link.platform === value ? 'selected' : ''}>${label}</option>`).join('')}
  2106. </select>
  2107. <input type="text" class="ne-input ne-social-url" value="${escapeHtml(link.url || '')}" placeholder="URL" oninput="updateSocialLink(${i}, 'url', this.value)">
  2108. ${neActions('moveSocialLink', 'removeSocialLink', i, footerData.social_links.length)}
  2109. </div>
  2110. </div>`).join('')}</div>` : neEmpty('No social links yet.');
  2111. }
  2112. ​
  2113. function updateSocialLink(index, field, value) {
  2114. if (!footerData.social_links[index]) return;
  2115. footerData.social_links[index][field] = value;
  2116. updateFooterData();
  2117. }
  2118. ​
  2119. function addSocialLink() {
  2120. if (!footerData.social_links) footerData.social_links = [];
  2121. footerData.social_links.push({ platform: 'twitter', url: '#' });
  2122. renderSocialLinks();
  2123. updateFooterData();
  2124. neFocusLast(document.getElementById('socialLinksContainer'), '.ne-social-url');
  2125. }
  2126. ​
  2127. function removeSocialLink(index) {
  2128. footerData.social_links.splice(index, 1);
  2129. renderSocialLinks();
  2130. updateFooterData();
  2131. }
  2132. ​
  2133. function moveSocialLink(index, dir) {
  2134. if (neMove(footerData.social_links, index, dir)) {
  2135. renderSocialLinks();
  2136. updateFooterData();
  2137. }
  2138. }
  2139. ​
  2140. function renderBottomLinks() {
  2141. const container = document.getElementById('bottomLinksContainer');
  2142. if (!container) return;
  2143. if (!footerData.bottom_links) footerData.bottom_links = [];
  2144. ​
  2145. container.innerHTML = footerData.bottom_links.length ? `<div class="ne-list">${footerData.bottom_links.map((link, i) => `
  2146. <div class="ne-item">
  2147. <div class="ne-row">
  2148. <input type="text" class="ne-input ne-bottom-label" value="${escapeHtml(link.label || '')}" placeholder="Label" oninput="updateBottomLink(${i}, 'label', this.value)">
  2149. <input type="text" class="ne-input" value="${escapeHtml(link.url || '')}" placeholder="URL" oninput="updateBottomLink(${i}, 'url', this.value)">
  2150. ${neActions('moveBottomLink', 'removeBottomLink', i, footerData.bottom_links.length)}
  2151. </div>
  2152. </div>`).join('')}</div>` : neEmpty('No bottom links yet.');
  2153. }
  2154. ​
  2155. function updateBottomLink(index, field, value) {
  2156. if (!footerData.bottom_links[index]) return;
  2157. footerData.bottom_links[index][field] = value;
  2158. updateFooterData();
  2159. }
  2160. ​
  2161. function addBottomLink() {
  2162. if (!footerData.bottom_links) footerData.bottom_links = [];
  2163. footerData.bottom_links.push({ label: '', url: '#' });
  2164. renderBottomLinks();
  2165. updateFooterData();
  2166. neFocusLast(document.getElementById('bottomLinksContainer'), '.ne-bottom-label');
  2167. }
  2168. ​
  2169. function removeBottomLink(index) {
  2170. footerData.bottom_links.splice(index, 1);
  2171. renderBottomLinks();
  2172. updateFooterData();
  2173. }
  2174. ​
  2175. function moveBottomLink(index, dir) {
  2176. if (neMove(footerData.bottom_links, index, dir)) {
  2177. renderBottomLinks();
  2178. updateFooterData();
  2179. }
  2180. }
  2181. ​
  2182. function updateFooterData() {
  2183. footerData.brand_description = document.getElementById('footerBrandDesc')?.value || '';
  2184. footerData.copyright = document.getElementById('footerCopyright')?.value || '';
  2185. ​
  2186. if (!footerData.columns) footerData.columns = [];
  2187. if (!footerData.social_links) footerData.social_links = [];
  2188. if (!footerData.bottom_links) footerData.bottom_links = [];
  2189. }
  2190. ​
  2191. function updateAnchorsList() {
  2192. const anchorsList = document.getElementById('anchorsList');
  2193. if (!anchorsList) return;
  2194. ​
  2195. const anchors = new Set(['#']); // Always include top
  2196. ​
  2197. blocks.forEach(block => {
  2198. if (block.id && ['features', 'testimonials', 'pricing', 'faq', 'team', 'stats', 'cta', 'video', 'gallery', 'audio', 'countdown', 'newsletter'].includes(block.type)) {
  2199. anchors.add(`#${block.id}`);
  2200. }
  2201. });
  2202. ​
  2203. anchorsList.innerHTML = '';
  2204. anchors.forEach(anchor => {
  2205. const btn = document.createElement('button');
  2206. btn.type = 'button';
  2207. btn.className = 'btn btn-secondary btn-sm';
  2208. btn.onclick = () => selectLink(anchor);
  2209. btn.textContent = anchor === '#' ? '#top' : anchor;
  2210. btn.style.margin = '0 8px 8px 0';
  2211. anchorsList.appendChild(btn);
  2212. });
  2213. }
  2214. ​
  2215. function renderInternalPages() {
  2216. const container = document.getElementById('internalPages');
  2217. if (!container) return;
  2218. ​
  2219. container.innerHTML = AVAILABLE_PAGES.map(page => `
  2220. <button type="button" class="btn btn-secondary btn-sm" style="margin: 0 8px 8px 0;" onclick="selectLink('${escapeHtml(page.url)}')">${escapeHtml(page.label)}</button>
  2221. `).join('');
  2222. }
  2223. ​
  2224. function showLinkSelector(inputId, callback) {
  2225. currentLinkCallback = { inputId, callback };
  2226. updateAnchorsList();
  2227. document.getElementById('linkSelectorModal').classList.add('active');
  2228. }
  2229. ​
  2230. function hideLinkSelector() {
  2231. document.getElementById('linkSelectorModal').classList.remove('active');
  2232. currentLinkCallback = null;
  2233. }
  2234. ​
  2235. function selectLink(url) {
  2236. if (currentLinkCallback) {
  2237. const input = document.getElementById(currentLinkCallback.inputId);
  2238. if (input) {
  2239. input.value = url;
  2240. input.dispatchEvent(new Event('input'));
  2241. }
  2242. }
  2243. hideLinkSelector();
  2244. }
  2245. ​
  2246. ​
  2247. function showAddBlockModal() {
  2248. document.getElementById('addBlockModal').classList.add('active');
  2249. }
  2250. ​
  2251. function hideAddBlockModal() {
  2252. document.getElementById('addBlockModal').classList.remove('active');
  2253. }
  2254. ​
  2255. function confirmDelete(productId, productName) {
  2256. document.getElementById('deleteProductId').value = productId;
  2257. document.getElementById('deleteProductName').textContent = productName;
  2258. document.getElementById('deleteModal').classList.add('active');
  2259. }
  2260. ​
  2261. function hideDeleteModal() {
  2262. document.getElementById('deleteModal').classList.remove('active');
  2263. }
  2264. ​
  2265. ​
  2266. function escapeHtml(text) {
  2267. if (!text) return '';
  2268. const div = document.createElement('div');
  2269. div.textContent = text;
  2270. return div.innerHTML;
  2271. }
  2272. ​
  2273. document.addEventListener('keydown', function (e) {
  2274. if (e.key === 'Escape') {
  2275. hideAddBlockModal();
  2276. hideDeleteModal();
  2277. hideLinkSelector();
  2278. }
  2279. });
  2280. ​
  2281. document.querySelectorAll('.modal-overlay').forEach(overlay => {
  2282. overlay.addEventListener('click', function (e) {
  2283. if (e.target === this) {
  2284. this.classList.remove('active');
  2285. }
  2286. });
  2287. });
  2288. ​
  2289. ​
  2290. ​
  2291. ​
  2292. ​
  2293. ​
  2294. ​
  2295. ​
  2296. ​
  2297. ​
  2298. ​
  2299. ​
  2300. ​
  2301. ​
  2302. ​
  2303. ​
  2304. ​
  2305. ​
  2306. ​
  2307. ​
  2308. ​
  2309. function renderLanguages() {
  2310. const container = document.getElementById('languagesContainer');
  2311. if (!container || typeof languagesData === 'undefined') return;
  2312. ​
  2313. container.innerHTML = languagesData.length ? `<div class="ne-list">${languagesData.map((lang, i) => `
  2314. <div class="ne-item">
  2315. <div class="ne-row">
  2316. <input type="text" class="ne-input ne-lang-name" value="${escapeHtml(lang.name || '')}" placeholder="Language name, like English or Polski" maxlength="50" oninput="languagesData[${i}].name = this.value">
  2317. <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()">
  2318. ${neActions('moveLanguage', 'removeLanguage', i, languagesData.length)}
  2319. </div>
  2320. </div>`).join('')}</div>` : neEmpty('No languages yet. Add your main language first, then the others.');
  2321. }
  2322. ​
  2323. function addLanguage() {
  2324. languagesData.push({ name: '', code: '' });
  2325. renderLanguages();
  2326. neFocusLast(document.getElementById('languagesContainer'), '.ne-lang-name');
  2327. }
  2328. ​
  2329. function removeLanguage(index) {
  2330. languagesData.splice(index, 1);
  2331. renderLanguages();
  2332. }
  2333. ​
  2334. function moveLanguage(index, dir) {
  2335. if (neMove(languagesData, index, dir)) {
  2336. renderLanguages();
  2337. }
  2338. }(function () {
  2339. const form = document.getElementById('pageForm') || document.getElementById('settingsForm');
  2340. if (!form) return;
  2341. ​
  2342. const isSettings = form.id === 'settingsForm';
  2343. const params = new URLSearchParams(location.search);
  2344. const draftKey = 'flatly-draft:' + location.pathname + ':' + (params.get('tab') || 'index') + ':' + (params.get('id') || '') + ':' + (params.get('lang') || '');
  2345. const pendingKey = 'flatly-draft-pending';
  2346. 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[]']);
  2347. let baseline = '';
  2348. let timer = 0;
  2349. let submitting = false;
  2350. ​
  2351. const storage = {
  2352. get(key) {
  2353. try { return localStorage.getItem(key); } catch (e) { return null; }
  2354. },
  2355. set(key, value) {
  2356. try { localStorage.setItem(key, value); } catch (e) {}
  2357. },
  2358. remove(key) {
  2359. try { localStorage.removeItem(key); } catch (e) {}
  2360. }
  2361. };
  2362. ​
  2363. function snapshot() {
  2364. const fields = {};
  2365. Array.from(form.elements).forEach(el => {
  2366. if (!el.name || skipFields.has(el.name) || el.type === 'file' || el.type === 'submit' || el.type === 'button') return;
  2367. fields[el.name] = el.type === 'checkbox' ? el.checked : el.value;
  2368. });
  2369. ​
  2370. const data = { fields };
  2371. if (isSettings) {
  2372. updateFooterData();
  2373. data.navLinks = navLinks;
  2374. data.navButtons = navButtons;
  2375. data.footerData = footerData;
  2376. if (typeof languagesData !== 'undefined') data.languages = languagesData;
  2377. } else {
  2378. data.blocks = blocks;
  2379. }
  2380. return data;
  2381. }
  2382. ​
  2383. function serialize() {
  2384. try {
  2385. return JSON.stringify(snapshot());
  2386. } catch (e) {
  2387. return '';
  2388. }
  2389. }
  2390. ​
  2391. function saveDraft() {
  2392. if (submitting) return;
  2393. const current = serialize();
  2394. if (!current) return;
  2395. if (current === baseline) {
  2396. storage.remove(draftKey);
  2397. return;
  2398. }
  2399. storage.set(draftKey, JSON.stringify({ savedAt: Date.now(), data: JSON.parse(current) }));
  2400. }
  2401. ​
  2402. function scheduleSave() {
  2403. clearTimeout(timer);
  2404. timer = setTimeout(saveDraft, 700);
  2405. }
  2406. ​
  2407. function applyDraft(data) {
  2408. Array.from(form.elements).forEach(el => {
  2409. if (!el.name || !(el.name in data.fields)) return;
  2410. if (el.type === 'checkbox') {
  2411. el.checked = !!data.fields[el.name];
  2412. } else {
  2413. el.value = data.fields[el.name];
  2414. }
  2415. });
  2416. ​
  2417. if (isSettings) {
  2418. navLinks.splice(0, navLinks.length, ...(data.navLinks || []));
  2419. navButtons.splice(0, navButtons.length, ...(data.navButtons || []));
  2420. Object.keys(footerData).forEach(k => delete footerData[k]);
  2421. Object.assign(footerData, data.footerData || {});
  2422. const desc = document.getElementById('footerBrandDesc');
  2423. const copy = document.getElementById('footerCopyright');
  2424. if (desc) desc.value = footerData.brand_description || '';
  2425. if (copy) copy.value = footerData.copyright || '';
  2426. renderNavLinks();
  2427. renderNavButtons();
  2428. renderFooterColumns();
  2429. renderSocialLinks();
  2430. renderBottomLinks();
  2431. if (typeof languagesData !== 'undefined') languagesData.splice(0, languagesData.length, ...(data.languages || languagesData));
  2432. if (typeof renderLanguages === 'function') renderLanguages();
  2433. updateFooterData();
  2434. } else {
  2435. blocks.splice(0, blocks.length, ...(data.blocks || []));
  2436. renderBlocks();
  2437. updateBlockCount();
  2438. if (typeof schedulePreviewUpdate === 'function') schedulePreviewUpdate();
  2439. }
  2440. }
  2441. ​
  2442. function showBanner(draft) {
  2443. const host = document.querySelector('.main-content');
  2444. if (!host) return;
  2445. ​
  2446. const banner = document.createElement('div');
  2447. banner.className = 'draft-banner';
  2448. banner.innerHTML = `
  2449. <div class="draft-banner-text">
  2450. <strong>You have unsaved changes</strong>
  2451. <span>Saved on this device at ${new Date(draft.savedAt).toLocaleString()}. Restore them to pick up where you left off.</span>
  2452. </div>
  2453. <div class="draft-banner-actions">
  2454. <button type="button" class="btn btn-secondary btn-sm" data-draft="discard">Discard</button>
  2455. <button type="button" class="btn btn-primary btn-sm" data-draft="restore">Restore</button>
  2456. </div>`;
  2457. ​
  2458. banner.addEventListener('click', e => {
  2459. const action = e.target.closest('[data-draft]')?.dataset.draft;
  2460. if (!action) return;
  2461. if (action === 'restore') {
  2462. applyDraft(draft.data);
  2463. saveDraft();
  2464. } else {
  2465. storage.remove(draftKey);
  2466. }
  2467. banner.remove();
  2468. });
  2469. ​
  2470. host.insertBefore(banner, host.firstChild);
  2471. }
  2472. ​
  2473. try {
  2474. const pending = sessionStorage.getItem(pendingKey);
  2475. if (pending) {
  2476. sessionStorage.removeItem(pendingKey);
  2477. if (document.querySelector('.message.success')) storage.remove(pending);
  2478. }
  2479. } catch (e) {}
  2480. ​
  2481. form.addEventListener('submit', () => {
  2482. submitting = true;
  2483. clearTimeout(timer);
  2484. try { sessionStorage.setItem(pendingKey, draftKey); } catch (e) {}
  2485. });
  2486. ​
  2487. document.addEventListener('DOMContentLoaded', () => {
  2488. setTimeout(() => {
  2489. baseline = serialize();
  2490. ​
  2491. const raw = storage.get(draftKey);
  2492. if (raw) {
  2493. try {
  2494. const draft = JSON.parse(raw);
  2495. if (draft && draft.data && JSON.stringify(draft.data) !== baseline) {
  2496. showBanner(draft);
  2497. } else {
  2498. storage.remove(draftKey);
  2499. }
  2500. } catch (e) {
  2501. storage.remove(draftKey);
  2502. }
  2503. }
  2504. ​
  2505. ['input', 'change', 'click'].forEach(evt => document.addEventListener(evt, scheduleSave, true));
  2506. window.addEventListener('pagehide', saveDraft);
  2507. document.addEventListener('visibilitychange', () => {
  2508. if (document.visibilityState === 'hidden') saveDraft();
  2509. });
  2510. }, 400);
  2511. });
  2512. })();
  2513. ​
  2514. const MEDIA_ACCEPT = {
  2515. image: 'image/jpeg,image/png,image/gif,image/webp,image/avif,image/x-icon,.jpg,.jpeg,.png,.gif,.webp,.avif,.ico',
  2516. video: 'video/mp4,video/webm,video/ogg,video/quicktime,.mp4,.m4v,.webm,.ogv,.mov',
  2517. audio: 'audio/mpeg,audio/ogg,audio/wav,audio/mp4,audio/aac,audio/flac,audio/webm,.mp3,.ogg,.oga,.opus,.wav,.m4a,.aac,.flac,.weba'
  2518. };
  2519. ​
  2520. const MEDIA_MIME = {
  2521. mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', mov: 'video/mp4',
  2522. mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav',
  2523. m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', weba: 'audio/webm'
  2524. };
  2525. ​
  2526. const MEDIA_ICONS = {
  2527. 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>',
  2528. 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>',
  2529. 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>',
  2530. 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>',
  2531. 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>'
  2532. };
  2533. ​
  2534. function mediaMime(url, fallback) {
  2535. const path = String(url || '').split('?')[0].split('#')[0];
  2536. const ext = path.includes('.') ? path.split('.').pop().toLowerCase() : '';
  2537. return MEDIA_MIME[ext] || fallback;
  2538. }
  2539. ​
  2540. function mediaFieldFor(node) {
  2541. return node.closest('.media-field');
  2542. }
  2543. ​
  2544. function mediaSetStatus(field, text, state) {
  2545. const status = field.querySelector('.media-status');
  2546. if (!status) return;
  2547. status.textContent = text || '';
  2548. status.className = 'media-status' + (state ? ' ' + state : '');
  2549. clearTimeout(field._statusTimer);
  2550. if (state === 'success' || state === 'error') {
  2551. field._statusTimer = setTimeout(() => {
  2552. status.textContent = '';
  2553. status.className = 'media-status';
  2554. }, state === 'error' ? 8000 : 3000);
  2555. }
  2556. }
  2557. ​
  2558. function mediaApply(field, url) {
  2559. const input = field.querySelector('input[data-media]');
  2560. if (!input) return;
  2561. input.value = url;
  2562. input.dispatchEvent(new Event('input', { bubbles: true }));
  2563. input.dispatchEvent(new Event('change', { bubbles: true }));
  2564. }
  2565. ​
  2566. function mediaUploadFile(field, file) {
  2567. const kind = field.dataset.kind;
  2568. if (field.classList.contains('uploading')) return;
  2569. ​
  2570. const body = new FormData();
  2571. body.append('csrf_token', CSRF_TOKEN);
  2572. body.append('kind', kind);
  2573. body.append('file', file);
  2574. ​
  2575. const bar = field.querySelector('.media-progress-bar');
  2576. field.classList.add('uploading');
  2577. if (bar) bar.style.width = '0%';
  2578. mediaSetStatus(field, 'Uploading ' + file.name + '…', 'busy');
  2579. ​
  2580. const xhr = new XMLHttpRequest();
  2581. xhr.open('POST', FLATLY_BASE_URL + '/admin/upload');
  2582. xhr.upload.onprogress = e => {
  2583. if (e.lengthComputable && bar) bar.style.width = Math.round((e.loaded / e.total) * 100) + '%';
  2584. };
  2585. xhr.onerror = () => {
  2586. field.classList.remove('uploading');
  2587. mediaSetStatus(field, "Couldn't upload, check your connection and try again.", 'error');
  2588. };
  2589. xhr.onload = () => {
  2590. field.classList.remove('uploading');
  2591. let response = null;
  2592. try { response = JSON.parse(xhr.responseText); } catch (e) {}
  2593. if (response && response.success) {
  2594. mediaApply(field, response.url);
  2595. mediaSetStatus(field, 'Uploaded, ' + response.size, 'success');
  2596. } else {
  2597. mediaSetStatus(field, response && response.error ? response.error : 'Upload failed (HTTP ' + xhr.status + ').', 'error');
  2598. }
  2599. };
  2600. xhr.send(body);
  2601. }
  2602. ​
  2603. function mediaPickFile(button) {
  2604. const field = mediaFieldFor(button);
  2605. if (!field) return;
  2606. const picker = document.createElement('input');
  2607. picker.type = 'file';
  2608. picker.accept = MEDIA_ACCEPT[field.dataset.kind] || '';
  2609. picker.onchange = () => {
  2610. if (picker.files && picker.files[0]) mediaUploadFile(field, picker.files[0]);
  2611. };
  2612. picker.click();
  2613. }
  2614. ​
  2615. function mediaLibraryKey(e) {
  2616. if (e.key === 'Escape') mediaCloseLibrary();
  2617. }
  2618. ​
  2619. function mediaCloseLibrary() {
  2620. document.querySelector('.media-modal')?.remove();
  2621. document.removeEventListener('keydown', mediaLibraryKey);
  2622. }
  2623. ​
  2624. function mediaOpenLibrary(button) {
  2625. const field = mediaFieldFor(button);
  2626. if (!field) return;
  2627. const kind = field.dataset.kind;
  2628. ​
  2629. mediaCloseLibrary();
  2630. const modal = document.createElement('div');
  2631. modal.className = 'media-modal';
  2632. modal.innerHTML = `
  2633. <div class="media-modal-box">
  2634. <div class="media-modal-head">
  2635. <h3>Media library</h3>
  2636. <input type="text" class="ne-input" placeholder="Search files" data-media-search>
  2637. <button type="button" class="ne-btn" data-media-close title="Close">${MEDIA_ICONS.close}</button>
  2638. </div>
  2639. <div class="media-modal-body"><div class="media-empty">Loading...</div></div>
  2640. </div>`;
  2641. document.body.appendChild(modal);
  2642. document.addEventListener('keydown', mediaLibraryKey);
  2643. ​
  2644. const body = modal.querySelector('.media-modal-body');
  2645. let items = [];
  2646. ​
  2647. const draw = query => {
  2648. const q = (query || '').trim().toLowerCase();
  2649. const visible = items.filter(item => !q || item.name.toLowerCase().includes(q));
  2650. if (!visible.length) {
  2651. body.innerHTML = `<div class="media-empty">${items.length ? 'No matching files.' : 'No files uploaded yet.'}</div>`;
  2652. return;
  2653. }
  2654. body.innerHTML = `<div class="media-grid">${visible.map(item => `
  2655. <button type="button" class="media-item" data-url="${escapeHtml(item.url)}" title="${escapeHtml(item.name)}">
  2656. <span class="media-thumb">${kind === 'image' ? `<img src="${escapeHtml(flatlyUrl(item.url))}" alt="" loading="lazy">` : MEDIA_ICONS[kind] || ''}</span>
  2657. <span class="media-name">${escapeHtml(item.name)}</span>
  2658. <span class="media-size">${escapeHtml(item.size)}</span>
  2659. </button>`).join('')}</div>`;
  2660. };
  2661. ​
  2662. modal.addEventListener('click', e => {
  2663. if (e.target === modal || e.target.closest('[data-media-close]')) {
  2664. mediaCloseLibrary();
  2665. return;
  2666. }
  2667. const item = e.target.closest('.media-item');
  2668. if (item) {
  2669. mediaApply(field, item.dataset.url);
  2670. mediaCloseLibrary();
  2671. }
  2672. });
  2673. modal.querySelector('[data-media-search]').addEventListener('input', e => draw(e.target.value));
  2674. ​
  2675. fetch(FLATLY_BASE_URL + '/admin/upload?list=' + encodeURIComponent(kind), { credentials: 'same-origin' })
  2676. .then(r => r.json())
  2677. .then(data => {
  2678. if (!data.success) throw new Error(data.error || "Couldn't load your files.");
  2679. items = data.items || [];
  2680. draw('');
  2681. })
  2682. .catch(err => {
  2683. body.innerHTML = `<div class="media-empty">${escapeHtml(err.message)}</div>`;
  2684. });
  2685. }
  2686. ​
  2687. function enhanceMediaInputs(root) {
  2688. (root || document).querySelectorAll('input[data-media]:not([data-media-ready])').forEach(input => {
  2689. const kind = input.dataset.media;
  2690. if (!MEDIA_ACCEPT[kind]) return;
  2691. input.dataset.mediaReady = '1';
  2692. ​
  2693. const field = document.createElement('div');
  2694. field.className = 'media-field';
  2695. field.dataset.kind = kind;
  2696. input.parentNode.insertBefore(field, input);
  2697. ​
  2698. const row = document.createElement('div');
  2699. row.className = 'media-row';
  2700. field.appendChild(row);
  2701. row.appendChild(input);
  2702. ​
  2703. const actions = document.createElement('div');
  2704. actions.className = 'media-actions';
  2705. actions.innerHTML = `
  2706. <button type="button" class="btn btn-secondary btn-sm" onclick="mediaPickFile(this)">${MEDIA_ICONS.upload} Upload</button>
  2707. <button type="button" class="btn btn-secondary btn-sm" onclick="mediaOpenLibrary(this)">${MEDIA_ICONS.library} Library</button>`;
  2708. row.appendChild(actions);
  2709. ​
  2710. field.insertAdjacentHTML('beforeend', '<div class="media-progress"><div class="media-progress-bar"></div></div><div class="media-status"></div>');
  2711. });
  2712. }
  2713. ​
  2714. (function () {
  2715. let frame = 0;
  2716. const schedule = () => {
  2717. if (frame) return;
  2718. frame = requestAnimationFrame(() => {
  2719. frame = 0;
  2720. enhanceMediaInputs(document);
  2721. });
  2722. };
  2723. ​
  2724. const start = () => {
  2725. enhanceMediaInputs(document);
  2726. const host = document.querySelector('.main-content') || document.body;
  2727. new MutationObserver(schedule).observe(host, { childList: true, subtree: true });
  2728. };
  2729. ​
  2730. if (document.readyState === 'loading') {
  2731. document.addEventListener('DOMContentLoaded', start);
  2732. } else {
  2733. start();
  2734. }
  2735. ​
  2736. document.addEventListener('dragover', e => {
  2737. const field = e.target.closest && e.target.closest('.media-field');
  2738. if (!field || !e.dataTransfer || !Array.from(e.dataTransfer.types || []).includes('Files')) return;
  2739. e.preventDefault();
  2740. field.classList.add('dragging');
  2741. });
  2742. ​
  2743. document.addEventListener('dragleave', e => {
  2744. const field = e.target.closest && e.target.closest('.media-field');
  2745. if (field && !field.contains(e.relatedTarget)) field.classList.remove('dragging');
  2746. });
  2747. ​
  2748. document.addEventListener('drop', e => {
  2749. const field = e.target.closest && e.target.closest('.media-field');
  2750. if (!field) return;
  2751. field.classList.remove('dragging');
  2752. const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
  2753. if (!file) return;
  2754. e.preventDefault();
  2755. mediaUploadFile(field, file);
  2756. });
  2757. })();
  2758. ​
  2759. function switchEditLang(code) {
  2760. const url = new URL(location.href);
  2761. if (code) {
  2762. url.searchParams.set('lang', code);
  2763. } else {
  2764. url.searchParams.delete('lang');
  2765. }
  2766. location.href = url.toString();
  2767. }