Guest User

Untitled

a guest
Jun 5th, 2026
34
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 227.83 KB | None | 0 0
  1. // ==UserScript==
  2. // @name Grid OP Editor
  3. // @namespace grid-op-editor
  4. // @version 1.0
  5. // @description Visual grid layout editor for forum OPs
  6. // @match https://8chan.st/*/res/*.html
  7. // @match https://8chan.moe/*/res/*.html
  8. // @match http://alephchvkipd2houttjirmgivro5pxullvcgm4c47ptm7mhubbja6kad.onion/*/res/*.html
  9. // @require https://cdn.jsdelivr.net/npm/gridstack@12/dist/gridstack-all.js
  10. // @resource gridstackCSS https://cdn.jsdelivr.net/npm/gridstack@12/dist/gridstack.min.css
  11. // @grant GM_addStyle
  12. // @grant GM_getResourceText
  13. // @grant GM_registerMenuCommand
  14. // ==/UserScript==
  15.  
  16.  
  17. (function() {
  18. 'use strict';
  19.  
  20. // Load Gridstack CSS
  21. GM_addStyle(GM_getResourceText('gridstackCSS'));
  22.  
  23. // Editor CSS (scoped to #grid-editor-overlay)
  24. const EDITOR_CSS = `
  25. #grid-editor-overlay {
  26. position: fixed;
  27. inset: 0;
  28. z-index: 99999;
  29. display: none;
  30. overflow: hidden;
  31. }
  32. #grid-editor-overlay.is-open {
  33. display: block;
  34. }
  35. :root {
  36. /* Editor's own theme โ€” not using forum vars here because the editor is a standalone tool */
  37. --e-bg: var(--background-color, #f4f5f8);
  38. --e-panel: var(--menu-color, #ffffff);
  39. --e-border: var(--border-color, #cfd3dc);
  40. --e-text: var(--text-color, #1a1a1a);
  41. --e-muted: var(--text-color-muted, #666);
  42. --e-accent: var(--subject-color, #34345C);
  43. --e-accent-hover: var(--link-color, #1f1f3d);
  44. --e-danger: var(--alert-color, #c0392b);
  45. --e-ok: #2d7a2d;
  46. --e-warn: #b38600;
  47. --e-radius: 3px;
  48. --e-gap: 8px;
  49. }
  50. #grid-editor-overlay, #grid-editor-overlay *, #grid-editor-overlay *::before, #grid-editor-overlay *::after { box-sizing: border-box; }
  51. #grid-editor-overlay { margin: 0; padding: 0; width: 100%; height: 100%; background: var(--e-bg); color: var(--e-text); font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; }
  52.  
  53. .app { display: grid; grid-template-rows: auto 1fr auto; height: 100%; }
  54. .topbar {
  55. display: flex; align-items: center; gap: var(--e-gap);
  56. padding: 8px 12px; background: var(--e-panel); border-bottom: 1px solid var(--e-border);
  57. flex-wrap: wrap;
  58. }
  59. .topbar h1 { margin: 0; font-size: 15px; font-weight: 600; margin-right: 12px; }
  60. .topbar .sep { flex: 1; }
  61. .topbar input[type="text"] { flex: 0 1 280px; min-width: 180px; }
  62. .topbar .count { font-size: 12px; padding: 4px 8px; border-radius: var(--e-radius); background: color-mix(in srgb, var(--e-ok) 15%, var(--e-panel)); color: var(--e-ok); font-variant-numeric: tabular-nums; }
  63. .topbar .count.warn { background: color-mix(in srgb, var(--e-warn) 15%, var(--e-panel)); color: var(--e-warn); }
  64. .topbar .count.over { background: color-mix(in srgb, var(--e-danger) 15%, var(--e-panel)); color: var(--e-danger); }
  65.  
  66. .main { display: grid; grid-template-columns: 200px 1fr 320px; overflow: hidden; }
  67. .sidebar {
  68. background: var(--e-panel); border-right: 1px solid var(--e-border);
  69. padding: 12px; overflow-y: auto;
  70. }
  71. .sidebar-right { border-right: none; border-left: 1px solid var(--e-border); }
  72. .sidebar h3 { margin: 0 0 8px; font-size: 12px; text-transform: uppercase; color: var(--e-muted); letter-spacing: 0.5px; }
  73. .sidebar .add-btn {
  74. display: block; width: 100%; text-align: left; padding: 8px 10px; margin-bottom: 4px;
  75. background: var(--e-panel); border: 1px solid var(--e-border); border-radius: var(--e-radius);
  76. cursor: pointer; font-size: 13px;
  77. }
  78. .sidebar .add-btn:hover { border-color: var(--e-accent); background: color-mix(in srgb, var(--e-accent) 8%, transparent); }
  79. .sidebar .add-btn .ic { display: inline-block; width: 14px; text-align: center; margin-right: 6px; font-weight: bold; color: var(--e-accent); }
  80.  
  81. .grid-container {
  82. overflow: auto; padding: 0px; background: var(--e-bg);
  83. background-image:
  84. linear-gradient(to right, rgba(0,0,0,0.04) 1px, transparent 1px),
  85. linear-gradient(to bottom, rgba(0,0,0,0.04) 1px, transparent 1px);
  86. background-size: calc(100% / 18) 50px;
  87. }
  88. .grid-stack { background: transparent; }
  89. .grid-stack-item-content {
  90. background: var(--e-panel); border: 1px solid var(--e-border); border-radius: var(--e-radius);
  91. padding: 6px; overflow: hidden; display: flex; flex-direction: column;
  92. font-size: 12px;
  93. }
  94. .grid-stack-item-content:hover { border-color: var(--e-accent); }
  95. .grid-stack-item.selected > .grid-stack-item-content { border-color: var(--e-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--e-accent) 25%, transparent); }
  96. .w-type-tag {
  97. font-size: 9px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--e-muted);
  98. margin-bottom: 3px; flex-shrink: 0;
  99. }
  100. .w-title { font-weight: 600; font-size: 12px; margin-bottom: 4px; flex-shrink: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  101. .w-preview { font-size: 11px; color: var(--e-muted); flex: 1; overflow: hidden; line-height: 1.3; }
  102. .w-preview .chip {
  103. display: inline-block; background: color-mix(in srgb, var(--e-accent) 10%, var(--e-panel)); border-radius: 2px; padding: 1px 5px; margin: 1px;
  104. font-size: 10px;
  105. }
  106. .w-delete {
  107. position: absolute; top: 2px; right: 2px; z-index: 2;
  108. background: var(--e-panel); border: 1px solid var(--e-border); border-radius: 50%;
  109. width: 18px; height: 18px; line-height: 14px; text-align: center; font-size: 14px;
  110. cursor: pointer; padding: 0; color: var(--e-danger); display: none;
  111. }
  112. .grid-stack-item:hover .w-delete, .grid-stack-item.selected .w-delete { display: block; }
  113. .w-delete:hover { background: var(--e-danger); color: white; }
  114.  
  115. /* Properties panel */
  116. .props-panel { padding: 12px; }
  117. .props-empty { color: var(--e-muted); font-style: italic; text-align: center; padding: 24px 8px; }
  118. .field { margin-bottom: 10px; }
  119. .field label { display: block; font-size: 11px; font-weight: 600; margin-bottom: 3px; color: var(--e-muted); text-transform: uppercase; letter-spacing: 0.5px; }
  120. .field input[type="text"], .field input[type="url"], .field input[type="datetime-local"], .field textarea, .field select {
  121. width: 100%; padding: 5px 7px; border: 1px solid var(--e-border); border-radius: var(--e-radius);
  122. font: inherit; font-size: 12px; background: var(--e-bg); color: var(--e-text);
  123. }
  124. .field textarea { resize: vertical; min-height: 60px; font-family: inherit; }
  125. .field input[type="color"] { width: 40px; height: 26px; padding: 0; border: 1px solid var(--e-border); border-radius: var(--e-radius); cursor: pointer; }
  126. .field-row { display: flex; gap: 6px; align-items: center; }
  127. .field-row > * { flex: 1; }
  128. .field-row .fixed { flex: 0 0 auto; }
  129.  
  130. .list-header {
  131. display: flex; justify-content: space-between; align-items: center;
  132. margin: 12px 0 6px; padding-bottom: 4px; border-bottom: 1px solid var(--e-border);
  133. }
  134. .list-header h4 { margin: 0; font-size: 12px; text-transform: uppercase; color: var(--e-muted); letter-spacing: 0.5px; }
  135. .list-items { display: flex; flex-direction: column; gap: 4px; }
  136. .list-item {
  137. padding: 0px 8px; background: var(--e-bg); border: 1px solid var(--e-border); border-radius: var(--e-radius);
  138. font-size: 11px; display: flex; justify-content: space-between; align-items: center;
  139. cursor: pointer;
  140. }
  141. .list-item:hover { background: color-mix(in srgb, var(--e-accent) 8%, transparent); border-color: var(--e-accent); }
  142. .list-item .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 4px; }
  143. .list-item .actions { display: flex; gap: 2px; }
  144. .mini-btn {
  145. background: none; border: 1px solid var(--e-border); border-radius: 2px;
  146. width: 20px; height: 20px; cursor: pointer; font-size: 11px; padding: 0;
  147. color: var(--e-muted);
  148. }
  149. .mini-btn:hover { background: var(--e-accent); color: white; border-color: var(--e-accent); }
  150. .mini-btn.danger:hover { background: var(--e-danger); border-color: var(--e-danger); }
  151.  
  152. .lang-url-row {
  153. display: flex; gap: 4px; align-items: center; margin-top: 4px;
  154. }
  155. .lang-url-row input[type="text"] {
  156. width: 40px; flex: 0 0 40px; text-transform: uppercase; text-align: center;
  157. padding: 5px 4px; font-size: 11px; font-weight: 600;
  158. border: 1px solid var(--e-border); border-radius: var(--e-radius);
  159. }
  160. .lang-url-row input[type="url"] {
  161. flex: 1; min-width: 0;
  162. padding: 5px 7px; font: inherit; font-size: 12px;
  163. border: 1px solid var(--e-border); border-radius: var(--e-radius);
  164. }
  165. .grid-stack > .grid-stack-item > .grid-stack-item-content { overflow: hidden !important;}
  166. button.btn {
  167. padding: 5px 10px; font: inherit; font-size: 12px; cursor: pointer;
  168. border: 1px solid var(--e-border); border-radius: var(--e-radius); background: var(--e-panel);
  169. }
  170. button.btn:hover { background: color-mix(in srgb, var(--e-accent) 8%, transparent); border-color: var(--e-accent); }
  171. button.btn.primary { background: var(--e-accent); color: white; border-color: var(--e-accent); }
  172. button.btn.primary:hover { background: var(--e-accent-hover); }
  173. button.btn.danger { color: var(--e-danger); }
  174. button.btn.danger:hover { background: var(--e-danger); color: white; border-color: var(--e-danger); }
  175.  
  176. /* Bottom panel - output preview */
  177. .bottom {
  178. background: var(--e-panel); border-top: 1px solid var(--e-border);
  179. max-height: 40vh; display: flex; flex-direction: column;
  180. }
  181. .bottom-header {
  182. padding: 6px 12px; display: flex; align-items: center; gap: 8px;
  183. cursor: pointer; user-select: none; background: var(--e-bg); border-bottom: 1px solid var(--e-border);
  184. }
  185. .bottom-header h3 { margin: 0; font-size: 12px; text-transform: uppercase; color: var(--e-muted); letter-spacing: 0.5px; }
  186. .bottom-header .arrow { transition: transform 0.15s; }
  187. .bottom.collapsed .bottom-header .arrow { transform: rotate(-90deg); }
  188. .bottom.collapsed .bottom-body { display: none; }
  189. .bottom-body { padding: 10px 12px; overflow: auto; display: flex; gap: 10px; flex: 1; min-height: 0; }
  190. .bottom-body textarea {
  191. flex: 1; min-height: 140px; font: 11px/1.4 Consolas, Monaco, monospace;
  192. border: 1px solid var(--e-border); border-radius: var(--e-radius); padding: 6px; background: var(--e-bg);
  193. resize: none; white-space: pre; overflow: auto;
  194. }
  195.  
  196. /* Dialogs */
  197. #grid-editor-overlay dialog {
  198. border: 1px solid var(--e-border); border-radius: var(--e-radius); padding: 0;
  199. max-width: 90vw; width: 500px; background: var(--e-panel); color: var(--e-text);
  200. }
  201. #grid-editor-overlay dialog::backdrop { background: rgba(0,0,0,0.35); }
  202. .dlg-header { padding: 10px 14px; border-bottom: 1px solid var(--e-border); display: flex; justify-content: space-between; align-items: center; }
  203. .dlg-header h3 { margin: 0; font-size: 14px; }
  204. .dlg-body { padding: 14px; max-height: 70vh; overflow-y: auto; }
  205. .dlg-footer { padding: 10px 14px; border-top: 1px solid var(--e-border); display: flex; justify-content: flex-end; gap: 6px; }
  206.  
  207. #grid-editor-overlay .toast {
  208. position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
  209. background: var(--e-text); color: white; padding: 8px 16px; border-radius: var(--e-radius);
  210. font-size: 12px; z-index: 10000; opacity: 0; transition: opacity 0.2s;
  211. pointer-events: none;
  212. }
  213. #grid-editor-overlay .toast.show { opacity: 0.95; }
  214.  
  215. /* Server rows in the Servers dialog */
  216. .sv-row {
  217. display: grid; grid-template-columns: auto 1fr 1fr auto;
  218. gap: 6px; align-items: center;
  219. padding: 6px; border: 1px solid var(--e-border); border-radius: var(--e-radius);
  220. background: var(--e-bg);
  221. }
  222. .sv-row input, .sv-row select {
  223. padding: 4px 6px; font: inherit; font-size: 12px;
  224. border: 1px solid var(--e-border); border-radius: var(--e-radius); background: white;
  225. width: 100%;
  226. }
  227. .sv-row .sv-default {
  228. display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--e-muted);
  229. white-space: nowrap; cursor: pointer;
  230. }
  231. #btn-servers.has-config { border-color: var(--e-accent); }
  232.  
  233. /* Timezone picker โ€” searchable combobox with live offset badge and dropdown. */
  234. .tz-picker { position: relative; display: flex; align-items: center; gap: 4px; min-width: 0; }
  235. .tz-picker-input {
  236. flex: 1; min-width: 0;
  237. padding: 4px 6px; font: inherit; font-size: 12px;
  238. border: 1px solid var(--e-border); border-radius: var(--e-radius); background: white;
  239. }
  240. .tz-picker-badge {
  241. flex: 0 0 auto; font-size: 10px; color: var(--e-muted);
  242. padding: 2px 5px; background: color-mix(in srgb, var(--e-accent) 12%, var(--e-panel)); border-radius: 3px;
  243. min-width: 42px; text-align: center; white-space: nowrap;
  244. }
  245. .tz-picker-badge-invalid { background: color-mix(in srgb, var(--e-danger) 15%, var(--e-panel)); color: var(--e-danger); }
  246. .tz-picker-list {
  247. display: none;
  248. position: absolute; left: 0; top: 100%; margin-top: 2px; z-index: 50;
  249. background: white; border: 1px solid var(--e-border); border-radius: var(--e-radius);
  250. max-height: 260px; width: max(100%, 320px); overflow-y: auto;
  251. box-shadow: 0 4px 16px rgba(0,0,0,0.12);
  252. font-size: 12px;
  253. }
  254. .tz-picker-list-open { display: block; }
  255. .tz-picker-row {
  256. display: flex; align-items: center; justify-content: space-between;
  257. gap: 10px; padding: 4px 8px; cursor: pointer;
  258. }
  259. .tz-picker-row:hover { background: var(--e-accent-tint, #eef2ff); }
  260. .tz-picker-row-selected { background: var(--e-accent-tint, #eef2ff); font-weight: bold; }
  261. .tz-picker-row-name { color: var(--e-fg, #000); }
  262. .tz-picker-row-off { color: var(--e-muted); font-size: 11px; white-space: nowrap; }
  263. .tz-picker-more {
  264. padding: 4px 8px; font-size: 11px; font-style: italic; color: var(--e-muted);
  265. text-align: center;
  266. }
  267.  
  268. /* Preview overlay: full-screen, forum-themed container for the live renderer */
  269. #grid-editor-overlay #preview-overlay {
  270. position: fixed; inset: 0; z-index: 9999;
  271. background: rgba(20, 22, 35, 0.75);
  272. display: flex; flex-direction: column;
  273. }
  274. #grid-editor-overlay #preview-overlay[hidden] { display: none; }
  275. .preview-topbar {
  276. display: flex; align-items: center; gap: 12px;
  277. padding: 8px 14px; background: var(--e-panel); border-bottom: 1px solid var(--e-border);
  278. }
  279. .preview-title { flex: 1; font-size: 13px; color: var(--e-muted); }
  280. .preview-toggles { display: flex; gap: 12px; font-size: 12px; }
  281. .preview-toggles label { cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
  282. .preview-stage {
  283. flex: 1; overflow: auto;
  284. display: flex; justify-content: center; align-items: flex-start;
  285. padding: 20px;
  286. }
  287. .preview-post {
  288. /* Inherit the forum's theme variables so the preview matches the actual page */
  289. width: 90%;
  290. background: var(--background-gradient, var(--background-color, #eef2ff));
  291. border: 1px solid var(--horizon-sep-color, #b7c5d9);
  292. padding: 10px;
  293. color: var(--text-color, black);
  294. font: 10pt Arial, Helvetica, sans-serif;
  295. }
  296. .preview-post.mobile { max-width: 420px; }
  297. /* When mobile preview is active, force the renderer's single-column layout
  298. regardless of viewport size (the renderer's own media query uses innerWidth). */
  299. .preview-post.mobile .gr-root {
  300. display: flex !important;
  301. flex-direction: column !important;
  302. grid-auto-rows: auto !important;
  303. }
  304. .preview-post.mobile .gr-widget {
  305. grid-column: unset !important;
  306. grid-row: unset !important;
  307. min-height: 120px;
  308. height: auto;
  309. }
  310. .preview-post.mobile .gr-widget-timeline .gr-widget-body { min-height: 200px; }
  311.  
  312. @media (max-width: 900px) {
  313. .main { grid-template-columns: 150px 1fr 260px; }
  314. }
  315. @media (max-width: 700px) {
  316. .main { grid-template-columns: 1fr; grid-template-rows: auto auto 1fr; }
  317. .sidebar, .sidebar-right { max-height: 200px; }
  318. .sidebar-right { order: 3; }
  319. }
  320. `;
  321. GM_addStyle(EDITOR_CSS);
  322.  
  323. let editorOpen = false;
  324. let editorInitialized = false;
  325.  
  326. function toggleEditor() {
  327. if (!editorInitialized) {
  328. initEditor();
  329. editorInitialized = true;
  330. }
  331. editorOpen = !editorOpen;
  332. document.getElementById('grid-editor-overlay').classList.toggle('is-open', editorOpen);
  333. document.body.style.overflow = editorOpen ? 'hidden' : '';
  334. }
  335.  
  336. GM_registerMenuCommand('Toggle Grid Editor', toggleEditor);
  337.  
  338. // Keyboard shortcut: Escape closes the editor
  339. document.addEventListener('keydown', (e) => {
  340. if (e.key === 'Escape' && editorOpen) {
  341. // Don't close if a dialog is open
  342. const openDialog = document.querySelector('#grid-editor-overlay dialog[open]');
  343. if (openDialog) return;
  344. toggleEditor();
  345. }
  346. });
  347.  
  348. function initEditor() {
  349. // Create overlay
  350. const overlay = document.createElement('div');
  351. overlay.id = 'grid-editor-overlay';
  352. overlay.innerHTML = `<div class="app">
  353. <div class="topbar">
  354. <h1>Grid OP Editor</h1>
  355. <span class="count" id="char-count" title="Total OP characters">OP: 0 / 12,000</span>
  356. <span class="count" id="code-count" title="Code block characters">[code]: 0</span>
  357. <span class="sep"></span>
  358. <button class="btn" id="btn-servers">Servers</button>
  359. <button class="btn" id="btn-fetch-thread">Fetch</button>
  360. <button class="btn" id="btn-import">Import</button>
  361. <button class="btn" id="btn-export">Export JSON</button>
  362. <button class="btn" id="btn-preview">Preview</button>
  363. <button class="btn primary" id="btn-copy">Copy OP</button>
  364. <button class="btn" id="btn-copy-code">Copy [code]</button>
  365. <button class="btn danger" id="btn-clear">Clear</button>
  366. <button class="btn" id="btn-close-editor" style="margin-left:auto;">โœ• Close</button>
  367. </div>
  368.  
  369. <div class="main">
  370. <div class="sidebar">
  371. <h3>Add widget</h3>
  372. <button class="add-btn" data-add="generic"><span class="ic">๐Ÿ“</span>Generic</button>
  373. <button class="add-btn" data-add="timeline"><span class="ic">๐Ÿ“…</span>Timeline</button>
  374. <button class="add-btn" data-add="countdown"><span class="ic">โฑ๏ธ</span>Countdown</button>
  375. <button class="add-btn" data-add="list"><span class="ic">๐Ÿ“‹</span>List</button>
  376. <button class="add-btn" data-add="link"><span class="ic">๐Ÿ”—</span>Link</button>
  377. <button class="add-btn" data-add="attachments"><span class="ic">๐Ÿ“Ž</span>Attachments</button>
  378. <button class="add-btn" data-add="content"><span class="ic">๐Ÿ“„</span>Thread text</button>
  379. <h3 style="margin-top: 20px;">Tips</h3>
  380. <div style="font-size: 11px; color: var(--e-muted); line-height: 1.5;">
  381. Drag widgets to move. Drag the bottom-right corner to resize. Click to edit properties. Mobile view collapses everything to a single column.
  382. </div>
  383. </div>
  384.  
  385. <div class="grid-container">
  386. <div class="grid-stack"></div>
  387. </div>
  388.  
  389. <div class="sidebar sidebar-right">
  390. <div class="props-panel" id="props-panel">
  391. <div class="props-empty">Select a widget to edit its properties, or click "Add widget" to create one.</div>
  392. </div>
  393. </div>
  394. </div>
  395.  
  396. <div class="bottom collapsed" id="bottom">
  397. <div class="bottom-header" id="bottom-header">
  398. <span class="arrow">โ–ผ</span>
  399. <h3>Output preview</h3>
  400. <span style="color: var(--e-muted); font-size: 11px;">(click to toggle)</span>
  401. </div>
  402. <div class="bottom-body">
  403. <textarea id="output-preview" readonly spellcheck="false"></textarea>
  404. </div>
  405. </div>
  406. </div>
  407.  
  408. <div class="toast" id="toast"></div>
  409.  
  410. <!-- Preview overlay: shows a live render of the current grid using renderer.js (non-interactive) -->
  411. <div id="preview-overlay" hidden>
  412. <div class="preview-topbar">
  413. <div class="preview-title">Preview โ€” press Esc or click outside to close</div>
  414. <div class="preview-toggles">
  415. <label><input type="radio" name="preview-width" value="desktop" checked> Desktop</label>
  416. <label><input type="radio" name="preview-width" value="mobile"> Mobile</label>
  417. </div>
  418. <button class="btn" id="preview-close">Close</button>
  419. </div>
  420. <div class="preview-stage" id="preview-stage">
  421. <div class="preview-post" id="preview-post"></div>
  422. </div>
  423. </div>
  424.  
  425. <!-- Dialog: import -->
  426. <dialog id="dlg-import">
  427. <div class="dlg-header"><h3>Import</h3></div>
  428. <div class="dlg-body">
  429. <p style="margin-top:0;font-size:12px;color:var(--e-muted);">Paste a generated OP (with the [code]@@grid-v1@@...[/code] block) or just the JSON itself.</p>
  430. <textarea id="import-text" style="width:100%;height:220px;font:11px/1.4 Consolas,Monaco,monospace;padding:6px;"></textarea>
  431. <div id="import-error" style="color:var(--e-danger);font-size:12px;margin-top:6px;"></div>
  432. </div>
  433. <div class="dlg-footer">
  434. <button class="btn" data-close>Cancel</button>
  435. <button class="btn primary" id="import-confirm">Import</button>
  436. </div>
  437. </dialog>
  438.  
  439. <!-- Dialog: timeline event editor -->
  440. <dialog id="dlg-event">
  441. <div class="dlg-header"><h3 id="dlg-event-title">Edit event</h3></div>
  442. <div class="dlg-body">
  443. <div class="field">
  444. <label>Name</label>
  445. <input type="text" id="evt-name">
  446. </div>
  447. <div class="field">
  448. <label>Start</label>
  449. <input type="datetime-local" id="evt-start">
  450. </div>
  451. <div class="field">
  452. <label><input type="checkbox" id="evt-has-end" style="vertical-align:middle;"> Has end date</label>
  453. </div>
  454. <div class="field" id="evt-end-wrap">
  455. <label>End</label>
  456. <input type="datetime-local" id="evt-end">
  457. </div>
  458. <div class="field-row">
  459. <div class="field" style="flex:0 0 auto;">
  460. <label>Color</label>
  461. <input type="color" id="evt-color">
  462. </div>
  463. <div class="field" style="flex:1;">
  464. <label>Lane (optional)</label>
  465. <input type="text" id="evt-lane" inputmode="numeric" pattern="[0-9]*" placeholder="auto">
  466. </div>
  467. </div>
  468. <div class="field">
  469. <label>Background image URL (optional)</label>
  470. <input type="url" id="evt-bg" placeholder="https://...">
  471. </div>
  472. <div class="field">
  473. <label>Popup description (optional)</label>
  474. <textarea id="evt-text"></textarea>
  475. </div>
  476. </div>
  477. <div class="dlg-footer">
  478. <button class="btn" data-close>Cancel</button>
  479. <button class="btn primary" id="evt-save">Save</button>
  480. </div>
  481. </dialog>
  482.  
  483. <!-- Dialog: list item editor -->
  484. <dialog id="dlg-list-item">
  485. <div class="dlg-header"><h3 id="dlg-list-item-title">Edit list item</h3></div>
  486. <div class="dlg-body">
  487. <div class="field"><label>Title</label><input type="text" id="li-title"></div>
  488. <div id="li-url-section">
  489. <div class="field" id="li-url-single">
  490. <label>Link URL (optional)</label>
  491. <div class="field-row">
  492. <input type="url" id="li-url" placeholder="https://..." style="flex:1;">
  493. <button class="btn fixed" id="li-add-lang" type="button" style="font-size:11px;" title="Convert to multilingual URLs">+ Lang</button>
  494. </div>
  495. </div>
  496. <div id="li-url-multi" style="display:none;">
  497. <div class="field">
  498. <label>Link URLs by language</label>
  499. <div id="li-lang-urls"></div>
  500. <div style="margin-top:4px;">
  501. <button class="btn" id="li-add-lang-row" type="button" style="font-size:11px;">+ Language</button>
  502. </div>
  503. </div>
  504. </div>
  505. </div>
  506. <div class="field">
  507. <label>Background image URL (optional)</label>
  508. <input type="url" id="li-bg" placeholder="https://...">
  509. </div>
  510. <div class="field">
  511. <label>Text (optional, shown in some display modes)</label>
  512. <textarea id="li-text" rows="4"></textarea>
  513. </div>
  514. </div>
  515. <div class="dlg-footer">
  516. <button class="btn" data-close>Cancel</button>
  517. <button class="btn primary" id="li-save">Save</button>
  518. </div>
  519. </dialog>
  520.  
  521. <!-- Dialog: countdown phase editor -->
  522. <dialog id="dlg-phase">
  523. <div class="dlg-header"><h3 id="dlg-phase-title">Edit phase</h3></div>
  524. <div class="dlg-body">
  525. <div class="field"><label>Phase title</label><input type="text" id="ph-title"></div>
  526. <div class="field">
  527. <label>Target date/time (server wall time)</label>
  528. <input type="datetime-local" id="ph-target">
  529. </div>
  530. <div class="field">
  531. <label>Ended message (optional)</label>
  532. <input type="text" id="ph-endedtext" placeholder="Ended">
  533. </div>
  534. <div class="field"><label>Background image URL (optional)</label><input type="url" id="ph-bg" placeholder="https://..."></div>
  535. <div class="field" id="ph-bg-options" style="display:none;">
  536. <label>Position</label>
  537. <select id="ph-bgmode">
  538. <option value="cover">Cover (fill widget, may crop)</option>
  539. <option value="contain">Contain (fit inside, no crop)</option>
  540. <option value="corner-tl">Corner: top-left</option>
  541. <option value="corner-tr">Corner: top-right</option>
  542. <option value="corner-bl">Corner: bottom-left</option>
  543. <option value="corner-br">Corner: bottom-right</option>
  544. <option value="tile">Tile (repeat)</option>
  545. </select>
  546. </div>
  547. </div>
  548. <div class="dlg-footer">
  549. <button class="btn" data-close>Cancel</button>
  550. <button class="btn primary" id="ph-save">Save</button>
  551. </div>
  552. </dialog>
  553.  
  554. <!-- Dialog: servers (game regions/timezones) -->
  555. <dialog id="dlg-servers">
  556. <div class="dlg-header"><h3>Game servers / timezones</h3></div>
  557. <div class="dlg-body">
  558. <p style="margin-top:0;font-size:12px;color:var(--e-muted);">
  559. Configure one or more server regions. All times in the post will be displayed in the selected server's timezone; readers can switch between servers. Leave empty to display all times in UTC.
  560. </p>
  561. <div class="list-items" id="sv-list" style="gap:6px;"></div>
  562. <button class="btn" id="sv-add" style="margin-top:8px;">+ Add server</button>
  563. </div>
  564. <div class="dlg-footer">
  565. <button class="btn" data-close>Cancel</button>
  566. <button class="btn primary" id="sv-save">Save</button>
  567. </div>
  568. </dialog>`;
  569. document.body.appendChild(overlay);
  570.  
  571. // Close button
  572. document.getElementById('btn-close-editor').addEventListener('click', toggleEditor);
  573.  
  574. // ===== EMBEDDED RENDERER ===================================================
  575. // If InteractiveOP is available globally (from custom.js), use it.
  576. // Otherwise the preview will show placeholders.
  577. if (typeof InteractiveOP === 'function' && !window.InteractiveOP) {
  578. window.InteractiveOP = InteractiveOP();
  579. const style = document.createElement('style');
  580. style.textContent = window.InteractiveOP.getGridCSS();
  581. document.head.appendChild(style);
  582. }
  583.  
  584. // Run the editor logic
  585. (function () {
  586. 'use strict';
  587.  
  588. // ===== CONFIG =============================================================
  589. const MAX_CHARS = 12000;
  590. const STORAGE_KEY = 'grid-op-editor-state-v1';
  591. const MAGIC = '@@grid-v1@@';
  592. const COLUMNS = 18;
  593. const CELL_HEIGHT = 50;
  594.  
  595. // ===== STATE ==============================================================
  596. // Canonical state. Grid positions are synced from/to Gridstack on changes.
  597. const state = {
  598. meta: { title: '', servers: [], defaultServerId: null },
  599. widgets: [], // array of widget objects (each has id, type, x, y, w, h, ...)
  600. selectedId: null,
  601. };
  602. let grid = null; // Gridstack instance
  603. let suspendPersist = false;
  604.  
  605. // ===== UTILS ==============================================================
  606. const uid = (() => {
  607. const used = new Set();
  608. return (prefix) => {
  609. let id;
  610. do {
  611. id = (prefix || 'w') + Math.random().toString(36).slice(2, 6);
  612. } while (used.has(id));
  613. used.add(id);
  614. return id;
  615. };
  616. })();
  617.  
  618. function randomPastel() {
  619. const h = Math.floor(Math.random() * 360);
  620. const s = 60 + Math.floor(Math.random() * 20); // 60โ€“80
  621. const l = 65 + Math.floor(Math.random() * 10); // 65โ€“75
  622. return hslToHex(h, s, l);
  623. }
  624. function hslToHex(h, s, l) {
  625. s /= 100; l /= 100;
  626. const k = n => (n + h / 30) % 12;
  627. const a = s * Math.min(l, 1 - l);
  628. const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  629. const toHex = x => Math.round(255 * x).toString(16).padStart(2, '0');
  630. return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
  631. }
  632. const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  633.  
  634. function toast(msg, ms = 1800) {
  635. const t = document.getElementById('toast');
  636. t.textContent = msg;
  637. t.classList.add('show');
  638. clearTimeout(toast._h);
  639. toast._h = setTimeout(() => t.classList.remove('show'), ms);
  640. }
  641.  
  642. // datetime-local <-> ISO helpers, TZ-aware.
  643. // tz is an IANA timezone string like "UTC" or "Asia/Tokyo".
  644. // "datetime-local" inputs carry no TZ info โ€” we always treat the typed value
  645. // as wall-clock time in `tz` and convert to/from UTC ISO for storage.
  646. function isoToInputInTz(iso, tz) {
  647. if (!iso) return '';
  648. const d = new Date(iso);
  649. if (isNaN(d)) return '';
  650. // Use Intl to extract Y/M/D/h/m as they appear in `tz`.
  651. const parts = new Intl.DateTimeFormat('en-CA', {
  652. timeZone: tz || 'UTC',
  653. year: 'numeric', month: '2-digit', day: '2-digit',
  654. hour: '2-digit', minute: '2-digit', hour12: false,
  655. }).formatToParts(d).reduce((o, p) => { o[p.type] = p.value; return o; }, {});
  656. // hour may come back as "24" in some locales at midnight; normalize.
  657. const hh = parts.hour === '24' ? '00' : parts.hour;
  658. return `${parts.year}-${parts.month}-${parts.day}T${hh}:${parts.minute}`;
  659. }
  660. // Treat the typed "YYYY-MM-DDTHH:mm" string as wall-clock in `tz` and return UTC ISO.
  661. // Strategy: guess UTC by offsetting once, then correct using the actual offset at that instant.
  662. function inputInTzToIso(v, tz) {
  663. if (!v) return '';
  664. const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(v);
  665. if (!m) return '';
  666. const [_, Y, Mo, D, h, mi] = m.map(x => typeof x === 'string' ? x : x);
  667. // Build a UTC instant with those numbers (pretend the typed time IS UTC).
  668. const pretendUtc = Date.UTC(+Y, +Mo - 1, +D, +h, +mi, 0);
  669. // Find what that instant actually looks like in `tz`, compute offset, correct.
  670. const offsetMs = tzOffsetMs(new Date(pretendUtc), tz || 'UTC');
  671. const correctedUtc = pretendUtc - offsetMs;
  672. return new Date(correctedUtc).toISOString();
  673. }
  674. // How many ms is `tz` ahead of UTC at the given instant? (Tokyo โ†’ +9*3600*1000)
  675. function tzOffsetMs(date, tz) {
  676. if (!tz || tz === 'UTC') return 0;
  677. // Format the date in the target TZ and parse it back as if it were UTC.
  678. const parts = new Intl.DateTimeFormat('en-CA', {
  679. timeZone: tz,
  680. year: 'numeric', month: '2-digit', day: '2-digit',
  681. hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
  682. }).formatToParts(date).reduce((o, p) => { o[p.type] = p.value; return o; }, {});
  683. const hh = parts.hour === '24' ? '00' : parts.hour;
  684. const asIfUtc = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +hh, +parts.minute, +parts.second);
  685. return asIfUtc - date.getTime();
  686. }
  687.  
  688. // Returns the TZ to use for date inputs and display: default server's TZ, or UTC.
  689. function currentTz() {
  690. const servers = (state.meta && state.meta.servers) || [];
  691. if (servers.length === 0) return 'UTC';
  692. const def = servers.find(s => s.id === state.meta.defaultServerId) || servers[0];
  693. return def.tz || 'UTC';
  694. }
  695. function currentServerLabel() {
  696. const servers = (state.meta && state.meta.servers) || [];
  697. if (servers.length === 0) return 'UTC';
  698. const def = servers.find(s => s.id === state.meta.defaultServerId) || servers[0];
  699. return `${def.name} / ${def.tz}`;
  700. }
  701.  
  702. // ===== WIDGET DEFAULTS ====================================================
  703. function newWidget(type) {
  704. const base = { id: uid('w'), type, x: 0, y: 0, w: 4, h: 2 };
  705. // For countdown: default target = 7 days from today at 00:00 server wall time.
  706. const seed = new Date();
  707. seed.setDate(seed.getDate() + 7);
  708. const defaultTarget = `${seed.getFullYear()}-${String(seed.getMonth()+1).padStart(2,'0')}-${String(seed.getDate()).padStart(2,'0')}T00:00`;
  709. switch (type) {
  710. case 'generic': return { ...base, title: 'New widget', text: '' };
  711. case 'timeline': return { ...base, w: 12, h: 5, title: 'Timeline', events: [] };
  712. case 'countdown': return { ...base, w: 3, h: 2, isCyclic: false, interval: [0, 0, 0], phases: [{ id: uid('ph'), title: 'Countdown', target: defaultTarget, endedText: '', bg: '' }] };
  713. case 'list': return { ...base, w: 12, h: 3, title: 'List', items: [], displayMode: 'horizontal' };
  714. case 'link': return { ...base, w: 3, h: 1, title: 'Link', url: '' };
  715. case 'attachments': return { ...base, w: 12, h: 3, title: 'Attachments' };
  716. case 'content': return { ...base, w: 12, h: 6, title: '' };
  717. }
  718. }
  719.  
  720. // ===== PERSISTENCE ========================================================
  721. function save() {
  722. if (suspendPersist) return;
  723. try {
  724. localStorage.setItem(STORAGE_KEY, JSON.stringify({ meta: state.meta, widgets: state.widgets }));
  725. } catch (e) { console.warn('save failed', e); }
  726. }
  727. function load() {
  728. try {
  729. const s = localStorage.getItem(STORAGE_KEY);
  730. if (!s) return null;
  731. return JSON.parse(s);
  732. } catch (e) { return null; }
  733. }
  734.  
  735. // ===== GRID SYNC ==========================================================
  736. function buildItemContent(w) {
  737. // Summary preview inside the grid cell
  738. let body = '';
  739. if (w.type === 'generic') {
  740. body = `<div class="w-preview">${escapeHtml((w.text || '(no text)').slice(0, 120))}${(w.text||'').length>120?'โ€ฆ':''}</div>`;
  741. } else if (w.type === 'timeline') {
  742. const count = (w.events || []).length;
  743. body = `<div class="w-preview"><span class="chip">${count} event${count===1?'':'s'}</span></div>`;
  744. } else if (w.type === 'list') {
  745. const count = (w.items || []).length;
  746. body = `<div class="w-preview"><span class="chip">${count} item${count===1?'':'s'}</span></div>`;
  747. } else if (w.type === 'link') {
  748. body = `<div class="w-preview">${escapeHtml(w.url || '(no url)')}</div>`;
  749. } else if (w.type === 'attachments') {
  750. body = `<div class="w-preview"><span class="chip">thread attachments</span></div>`;
  751. } else if (w.type === 'content') {
  752. body = `<div class="w-preview"><span class="chip">thread text</span></div>`;
  753. }
  754. return `
  755. <button class="w-delete" data-del="${w.id}" title="Delete">ร—</button>
  756. <div class="w-type-tag">${w.type}</div>
  757. <div class="w-title">${escapeHtml(w.title || (w.phases && w.phases[0] ? w.phases[0].title : '') || '(no title)')}</div>
  758. ${body}
  759. `;
  760. }
  761.  
  762. function initGrid() {
  763. // v11+ removed the default innerHTML-from-content behavior for XSS safety.
  764. // We trust our own generated content, so reinstate the classic behavior.
  765. GridStack.renderCB = function (el, w) {
  766. if (w && typeof w.content === 'string') el.innerHTML = w.content;
  767. };
  768.  
  769. grid = GridStack.init({
  770. column: COLUMNS,
  771. cellHeight: CELL_HEIGHT,
  772. margin: 4,
  773. float: true, // allow free placement, no auto-compact
  774. animate: true,
  775. disableOneColumnMode: true, // we preview desktop layout; mobile collapses in renderer
  776. handle: '.grid-stack-item-content',
  777. resizable: { handles: 'se' },
  778. });
  779.  
  780. grid.on('change', (e, items) => {
  781. // items is array of GridStack nodes that changed
  782. items.forEach(node => {
  783. const w = state.widgets.find(x => x.id === node.id);
  784. if (w) {
  785. w.x = node.x; w.y = node.y; w.w = node.w; w.h = node.h;
  786. }
  787. });
  788. onStateChanged();
  789. });
  790.  
  791. // Click to select widget (but not when clicking delete button)
  792. document.querySelector('.grid-stack').addEventListener('click', (e) => {
  793. if (e.target.closest('.w-delete')) return;
  794. const gsItem = e.target.closest('.grid-stack-item');
  795. if (gsItem && gsItem.gridstackNode) {
  796. selectWidget(gsItem.gridstackNode.id);
  797. }
  798. });
  799.  
  800. // Delete button
  801. document.querySelector('.grid-stack').addEventListener('click', (e) => {
  802. const del = e.target.closest('.w-delete');
  803. if (del) {
  804. e.stopPropagation();
  805. removeWidget(del.dataset.del);
  806. }
  807. });
  808. }
  809.  
  810. function addWidgetToGrid(w, opts) {
  811. // Append via Gridstack so it handles DOM + engine state
  812. const spec = {
  813. id: w.id,
  814. x: w.x, y: w.y, w: w.w, h: w.h,
  815. content: buildItemContent(w),
  816. };
  817. if (opts && opts.autoPosition) {
  818. spec.autoPosition = true;
  819. delete spec.x;
  820. delete spec.y;
  821. }
  822. const el = grid.addWidget(spec);
  823. return el;
  824. }
  825.  
  826. function refreshWidgetContent(w) {
  827. // Update just the content of an existing grid item
  828. const el = document.querySelector(`.grid-stack-item[gs-id="${w.id}"]`);
  829. if (!el) return;
  830. const content = el.querySelector('.grid-stack-item-content');
  831. if (content) content.innerHTML = buildItemContent(w);
  832. // Re-apply selected state visual
  833. el.classList.toggle('selected', w.id === state.selectedId);
  834. }
  835.  
  836. function reloadGrid() {
  837. // Wipe and rebuild grid from state.widgets
  838. suspendPersist = true;
  839. grid.removeAll();
  840. state.widgets.forEach(w => addWidgetToGrid(w));
  841. suspendPersist = false;
  842. // Re-apply selection highlight
  843. if (state.selectedId) {
  844. const el = document.querySelector(`.grid-stack-item[gs-id="${state.selectedId}"]`);
  845. if (el) el.classList.add('selected');
  846. }
  847. }
  848.  
  849. function removeWidget(id) {
  850. const idx = state.widgets.findIndex(w => w.id === id);
  851. if (idx === -1) return;
  852. state.widgets.splice(idx, 1);
  853. const el = document.querySelector(`.grid-stack-item[gs-id="${id}"]`);
  854. if (el) grid.removeWidget(el);
  855. if (state.selectedId === id) selectWidget(null);
  856. onStateChanged();
  857. }
  858.  
  859. // ===== SELECTION / PROPS PANEL ============================================
  860. function selectWidget(id) {
  861. state.selectedId = id;
  862. document.querySelectorAll('.grid-stack-item').forEach(el => el.classList.remove('selected'));
  863. if (id) {
  864. const el = document.querySelector(`.grid-stack-item[gs-id="${id}"]`);
  865. if (el) el.classList.add('selected');
  866. }
  867. renderPropsPanel();
  868. }
  869.  
  870. function renderPropsPanel() {
  871. const panel = document.getElementById('props-panel');
  872. if (!state.selectedId) {
  873. panel.innerHTML = '<div class="props-empty">Select a widget to edit its properties, or click "Add widget" to create one.</div>';
  874. return;
  875. }
  876. const w = state.widgets.find(x => x.id === state.selectedId);
  877. if (!w) { panel.innerHTML = ''; return; }
  878.  
  879. // Dispatch by type
  880. switch (w.type) {
  881. case 'generic': renderGenericProps(panel, w); break;
  882. case 'timeline': renderTimelineProps(panel, w); break;
  883. case 'countdown': renderCountdownProps(panel, w); break;
  884. case 'list': renderListProps(panel, w); break;
  885. case 'link': renderLinkProps(panel, w); break;
  886. case 'attachments': renderAttachmentsProps(panel, w); break;
  887. case 'content': renderContentProps(panel, w); break;
  888. }
  889. // Shared order field appended to every widget's props panel.
  890. appendOrderField(panel, w);
  891.  
  892. // Copy widget JSON button
  893. const copyWrap = document.createElement('div');
  894. copyWrap.style.cssText = 'margin-top:8px;';
  895. const copyBtn = document.createElement('button');
  896. copyBtn.className = 'btn';
  897. copyBtn.textContent = 'Copy widget JSON';
  898. copyBtn.style.cssText = 'width:100%;font-size:11px;';
  899. copyBtn.addEventListener('click', async () => {
  900. const json = JSON.stringify(w, null, 2);
  901. try {
  902. await navigator.clipboard.writeText(json);
  903. toast('Widget JSON copied');
  904. } catch (e) {
  905. prompt('Copy widget JSON:', json);
  906. }
  907. });
  908. copyWrap.appendChild(copyBtn);
  909. panel.appendChild(copyWrap);
  910. }
  911.  
  912. // Manual widget order controls both text-output position (within its type group)
  913. // and mobile view position. Unset = auto-order (falls to end of group).
  914. function appendOrderField(panel, w) {
  915. const wrap = document.createElement('div');
  916. wrap.innerHTML = `
  917. <hr style="margin:14px 0 8px;border:none;border-top:1px solid var(--e-border);">
  918. <div class="field" style="display:flex;gap:12px;">
  919. <label><input type="checkbox" id="p-hideTitle" ${w.hideTitle ? 'checked' : ''}> Hide title</label>
  920. <label><input type="checkbox" id="p-hideBg" ${w.hideBg ? 'checked' : ''}> Hide background</label>
  921. </div>
  922. <div class="field">
  923. <label>Order (optional)</label>
  924. <input type="text" id="p-order" inputmode="numeric" pattern="-?[0-9]*" value="${w.order == null ? '' : escapeHtml(String(w.order))}" placeholder="auto">
  925. <p style="color:var(--e-muted);font-size:11px;margin:3px 0 0;">
  926. Lower numbers first. Affects text output (within each widget type) and mobile column order. Leave blank for automatic ordering.
  927. </p>
  928. </div>
  929. `;
  930. panel.appendChild(wrap);
  931.  
  932. document.getElementById('p-hideTitle').addEventListener('change', (e) => {
  933. w.hideTitle = e.target.checked || undefined;
  934. refreshWidgetContent(w);
  935. onStateChanged();
  936. });
  937.  
  938. document.getElementById('p-hideBg').addEventListener('change', (e) => {
  939. w.hideBg = e.target.checked || undefined;
  940. refreshWidgetContent(w);
  941. onStateChanged();
  942. });
  943.  
  944. const input = document.getElementById('p-order');
  945. input.addEventListener('input', () => {
  946. const raw = input.value.trim();
  947. if (raw === '') {
  948. delete w.order;
  949. } else {
  950. const n = parseInt(raw, 10);
  951. if (!Number.isNaN(n)) w.order = n;
  952. }
  953. onStateChanged();
  954. });
  955. }
  956.  
  957. // --- Generic props ---
  958. function renderGenericProps(panel, w) {
  959. const bgMode = w.bgMode || 'cover';
  960. panel.innerHTML = `
  961. <h3>Generic widget</h3>
  962. <div class="field"><label>Title *</label><input type="text" id="p-title" value="${escapeHtml(w.title || '')}"></div>
  963. <div class="field"><label>Background image URL</label><input type="url" id="p-bg" value="${escapeHtml(w.bg || '')}"></div>
  964. <div id="p-bg-options" style="${w.bg ? '' : 'display:none;'}">
  965. <div class="field">
  966. <label>Position</label>
  967. <select id="p-bgmode">
  968. <option value="cover">Cover (fill widget, may crop)</option>
  969. <option value="contain">Contain (fit inside, no crop)</option>
  970. <option value="corner-tl">Corner: top-left</option>
  971. <option value="corner-tr">Corner: top-right</option>
  972. <option value="corner-bl">Corner: bottom-left</option>
  973. <option value="corner-br">Corner: bottom-right</option>
  974. <option value="tile">Tile (repeat)</option>
  975. </select>
  976. </div>
  977. <p style="color:var(--e-muted);font-size:11px;margin:4px 0 0;">
  978. For best readability, use images with transparent backgrounds or dark/subtle areas where text will appear.
  979. </p>
  980. </div>
  981. <div class="field"><label>Hover background URL</label><input type="url" id="p-hoverBg" value="${escapeHtml(w.hoverBg || '')}"></div>
  982. <div class="field"><label>Text</label><textarea id="p-text" rows="8">${escapeHtml(w.text || '')}</textarea></div>
  983. `;
  984. document.getElementById('p-bgmode').value = bgMode;
  985.  
  986. bindInput('p-title', v => { w.title = v; refreshWidgetContent(w); onStateChanged(); });
  987. bindBgInput('p-bg', v => {
  988. w.bg = v;
  989. document.getElementById('p-bg-options').style.display = v ? '' : 'none';
  990. onStateChanged();
  991. });
  992. bindInput('p-bgmode', v => { w.bgMode = v; onStateChanged(); });
  993. bindBgInput('p-hoverBg', v => { w.hoverBg = v; refreshWidgetContent(w); onStateChanged(); });
  994. bindInput('p-text', v => { w.text = v; refreshWidgetContent(w); onStateChanged(); });
  995. }
  996.  
  997. // --- Link props ---
  998. function renderLinkProps(panel, w) {
  999. const bgMode = w.bgMode || 'cover';
  1000. panel.innerHTML = `
  1001. <h3>Link widget</h3>
  1002. <div class="field"><label>Title</label><input type="text" id="p-title" value="${escapeHtml(w.title || '')}"></div>
  1003. <div class="field"><label>URL *</label><input type="url" id="p-url" value="${escapeHtml(w.url || '')}"></div>
  1004. <div class="field"><label>Background image URL</label><input type="url" id="p-bg" value="${escapeHtml(w.bg || '')}"></div>
  1005. <div id="p-bg-options" style="${w.bg ? '' : 'display:none;'}">
  1006. <div class="field">
  1007. <label>Position</label>
  1008. <select id="p-bgmode">
  1009. <option value="cover">Cover (fill widget, may crop)</option>
  1010. <option value="contain">Contain (fit inside, no crop)</option>
  1011. <option value="corner-tl">Corner: top-left</option>
  1012. <option value="corner-tr">Corner: top-right</option>
  1013. <option value="corner-bl">Corner: bottom-left</option>
  1014. <option value="corner-br">Corner: bottom-right</option>
  1015. <option value="tile">Tile (repeat)</option>
  1016. </select>
  1017. </div>
  1018. </div>
  1019. <div class="field"><label>Hover background URL</label><input type="url" id="p-hoverBg" value="${escapeHtml(w.hoverBg || '')}"></div>
  1020. <p style="color:var(--e-muted);font-size:11px;margin-top:12px;">If background is set, it fills the button. Otherwise title text is shown. Resize via grid handle.</p>
  1021. `;
  1022. document.getElementById('p-bgmode').value = bgMode;
  1023. bindInput('p-title', v => { w.title = v; refreshWidgetContent(w); onStateChanged(); });
  1024. bindInput('p-url', v => { w.url = v; refreshWidgetContent(w); onStateChanged(); });
  1025. bindBgInput('p-bg', v => {
  1026. w.bg = v;
  1027. document.getElementById('p-bg-options').style.display = v ? '' : 'none';
  1028. refreshWidgetContent(w);
  1029. onStateChanged();
  1030. });
  1031. bindInput('p-bgmode', v => { w.bgMode = v; refreshWidgetContent(w); onStateChanged(); });
  1032. bindBgInput('p-hoverBg', v => { w.hoverBg = v; refreshWidgetContent(w); onStateChanged(); });
  1033. }
  1034.  
  1035. // --- Attachments props ---
  1036. function renderAttachmentsProps(panel, w) {
  1037. panel.innerHTML = `
  1038. <h3>Attachments widget</h3>
  1039. <div class="field"><label>Title</label><input type="text" id="p-title" value="${escapeHtml(w.title || '')}"></div>
  1040. <p style="color:var(--e-muted);font-size:11px;margin-top:12px;line-height:1.5;">
  1041. This widget has no content to configure. At render time, it pulls attachments directly from the thread's native upload panel and displays them as tiles. The editor preview shows 5 placeholder tiles to represent the layout.
  1042. </p>
  1043. `;
  1044. bindInput('p-title', v => { w.title = v; refreshWidgetContent(w); onStateChanged(); });
  1045. }
  1046.  
  1047. function renderContentProps(panel, w) {
  1048. panel.innerHTML = `
  1049. <h3>Thread text widget</h3>
  1050. <div class="field"><label>Title (optional)</label><input type="text" id="p-title" value="${escapeHtml(w.title || '')}" placeholder="Leave empty for no title bar"></div>
  1051. <p style="color:var(--e-muted);font-size:11px;margin-top:12px;line-height:1.5;">
  1052. Displays the thread's text content inside a widget. The text shown is everything in the OP message except the grid code block. Use this when you want a minimal layout with a few timers alongside the regular post text.
  1053. </p>
  1054. `;
  1055. bindInput('p-title', v => { w.title = v; refreshWidgetContent(w); onStateChanged(); });
  1056. }
  1057.  
  1058. // --- Countdown props ---
  1059. function renderCountdownProps(panel, w) {
  1060. // Migrate legacy flat fields into phases array
  1061. if (!w.phases || w.phases.length === 0) {
  1062. w.phases = [{
  1063. id: uid('ph'),
  1064. title: w.title || 'Countdown',
  1065. target: w.target || '',
  1066. endedText: w.endedText || '',
  1067. bg: w.bg || '',
  1068. bgMode: w.bgMode || '',
  1069. }];
  1070. delete w.target;
  1071. delete w.endedText;
  1072. delete w.bg;
  1073. delete w.bgMode;
  1074. delete w.title;
  1075. }
  1076.  
  1077. // Migrate: if w.title exists but phase0 has a generic title, move it
  1078. if (w.title && w.phases[0]) {
  1079. if (!w.phases[0].title || w.phases[0].title === 'Countdown') {
  1080. w.phases[0].title = w.title;
  1081. }
  1082. delete w.title;
  1083. }
  1084.  
  1085. const isSingle = w.phases.length === 1;
  1086. const phase0 = w.phases[0];
  1087. const bgMode = phase0.bgMode || 'cover';
  1088.  
  1089. panel.innerHTML = `
  1090. <h3>Countdown widget</h3>
  1091. <div class="field" id="p-title-field" style="${isSingle ? '' : 'display:none'}"><label>Title</label><input type="text" id="p-title" value="${escapeHtml(phase0.title || '')}"></div>
  1092.  
  1093. <div id="p-cd-single" style="${isSingle ? '' : 'display:none'}">
  1094. <div class="field" style="display:flex;gap:8px;align-items:center;">
  1095. <label style="white-space:nowrap;">
  1096. <input type="checkbox" id="p-rawTz" ${w.rawTz != null ? 'checked' : ''}>
  1097. Absolute time
  1098. </label>
  1099. <label id="p-rawTz-offset-label" style="white-space:nowrap;${w.rawTz != null ? '' : 'display:none'}">
  1100. UTC offset:
  1101. <input type="number" id="p-rawTz-offset" style="width:60px;" min="-12" max="14" step="1" value="${w.rawTz ?? 0}">
  1102. </label>
  1103. </div>
  1104. <div class="field">
  1105. <label id="p-target-label">${w.rawTz != null ? 'Target date/time (UTC' + (w.rawTz >= 0 ? '+' : '') + (w.rawTz ?? 0) + ')' : 'Target date/time (server wall time)'}</label>
  1106. <input type="datetime-local" id="p-target" value="${escapeHtml(toInputValue(phase0.target || ''))}">
  1107. </div>
  1108. <div class="field">
  1109. <label><input type="checkbox" id="p-isCyclic" ${(w.isCyclic ? "checked" : "")}> Cyclic</label>
  1110. </div>
  1111. <div id="p-cyclic-options" style="${w.isCyclic ? '' : 'display:none'}">
  1112. <div class="field" style="display:flex;gap:12px;">
  1113. <label><input type="radio" name="p-cyclicMode" value="timespan" ${(w.cyclicMode || 'timespan') === 'timespan' ? 'checked' : ''}> Time span</label>
  1114. <label><input type="radio" name="p-cyclicMode" value="datetime" ${w.cyclicMode === 'datetime' ? 'checked' : ''}> Recurring dates</label>
  1115. </div>
  1116. <div id="p-cyclic-timespan" style="${(w.cyclicMode || 'timespan') === 'timespan' ? '' : 'display:none'}">
  1117. <div class="field" style="display:flex;gap:5px;">
  1118. <label>Days: <input style="width:50px;" type="number" id="p-intervalDays" min="0" value="${(w.interval || [])[0] || 0}"></label>
  1119. <label>Hours: <input type="number" id="p-intervalHours" min="0" max="23" value="${(w.interval || [])[1] || 0}"></label>
  1120. <label>Minutes: <input type="number" id="p-intervalMinutes" min="0" max="59" value="${(w.interval || [])[2] || 0}"></label>
  1121. </div>
  1122. </div>
  1123. <div id="p-cyclic-datetime" style="${w.cyclicMode === 'datetime' ? '' : 'display:none'}">
  1124. <div id="p-cyclic-dates"></div>
  1125. <button class="btn" id="p-add-cyclic-date" type="button" style="font-size:11px;margin-top:4px;">+ Add date</button>
  1126. </div>
  1127. </div>
  1128. <div class="field">
  1129. <label>Ended message (optional)</label>
  1130. <input type="text" id="p-endedtext" value="${escapeHtml(phase0.endedText || '')}" placeholder="Ended">
  1131. </div>
  1132. <div class="field"><label>Background image URL</label><input type="url" id="p-bg" value="${escapeHtml(phase0.bg || '')}"></div>
  1133. <div id="p-bg-options" style="${phase0.bg ? '' : 'display:none;'}">
  1134. <div class="field">
  1135. <label>Position</label>
  1136. <select id="p-bgmode">
  1137. <option value="cover">Cover (fill widget, may crop)</option>
  1138. <option value="contain">Contain (fit inside, no crop)</option>
  1139. <option value="corner-tl">Corner: top-left</option>
  1140. <option value="corner-tr">Corner: top-right</option>
  1141. <option value="corner-bl">Corner: bottom-left</option>
  1142. <option value="corner-br">Corner: bottom-right</option>
  1143. <option value="tile">Tile (repeat)</option>
  1144. </select>
  1145. </div>
  1146. </div>
  1147. <div class="field">
  1148. <label>Style</label>
  1149. <select id="p-cdstyle">
  1150. <option value="">Default</option>
  1151. <option value="label">Label</option>
  1152. </select>
  1153. </div>
  1154. <div class="field"><label>Hover background URL</label><input type="url" id="p-hoverBg" value="${escapeHtml(w.hoverBg || '')}"></div>
  1155. <div class="field">
  1156. <button class="btn" id="p-add-phases" type="button">+ Convert to series</button>
  1157. </div>
  1158. </div>
  1159.  
  1160. <div id="p-cd-phases" style="${isSingle ? 'display:none' : ''}">
  1161. <div class="list-header">
  1162. <h4>Phases (${w.phases.length})</h4>
  1163. <span style="display:flex;gap:4px;">
  1164. <button class="btn" id="p-add-phase">+ Phase</button>
  1165. <button class="btn" id="p-remove-phases" title="Convert back to single countdown">ร— Series</button>
  1166. </span>
  1167. </div>
  1168. <div class="list-items" id="p-phase-list"></div>
  1169. </div>
  1170. `;
  1171. document.getElementById('p-bgmode').value = bgMode;
  1172. document.getElementById('p-cdstyle').value = w.cdStyle || '';
  1173.  
  1174. // Widget-level bindings
  1175. bindInput('p-title', v => { phase0.title = v; refreshWidgetContent(w); onStateChanged(); });
  1176. bindInput('p-cdstyle', v => { w.cdStyle = v || undefined; refreshWidgetContent(w); onStateChanged(); });
  1177. bindBgInput('p-hoverBg', v => { w.hoverBg = v; refreshWidgetContent(w); onStateChanged(); });
  1178. // Cyclic toggle
  1179. document.getElementById('p-isCyclic').addEventListener('change', (e) => {
  1180. w.isCyclic = e.target.checked;
  1181. document.getElementById('p-cyclic-options').style.display = e.target.checked ? '' : 'none';
  1182. refreshWidgetContent(w);
  1183. onStateChanged();
  1184. });
  1185.  
  1186. // Cyclic mode radio
  1187. document.querySelectorAll('input[name="p-cyclicMode"]').forEach(r => {
  1188. r.addEventListener('change', (e) => {
  1189. w.cyclicMode = e.target.value;
  1190. document.getElementById('p-cyclic-timespan').style.display = e.target.value === 'timespan' ? '' : 'none';
  1191. document.getElementById('p-cyclic-datetime').style.display = e.target.value === 'datetime' ? '' : 'none';
  1192. refreshWidgetContent(w);
  1193. onStateChanged();
  1194. });
  1195. });
  1196.  
  1197. // Timespan inputs
  1198. bindInput('p-intervalDays', v => { if (!w.interval) w.interval = [0,0,0]; w.interval[0] = v; refreshWidgetContent(w); onStateChanged(); });
  1199. bindInput('p-intervalHours', v => { if (!w.interval) w.interval = [0,0,0]; w.interval[1] = v; refreshWidgetContent(w); onStateChanged(); });
  1200. bindInput('p-intervalMinutes', v => { if (!w.interval) w.interval = [0,0,0]; w.interval[2] = v; refreshWidgetContent(w); onStateChanged(); });
  1201.  
  1202. // Recurring dates
  1203. function renderCyclicDates() {
  1204. const container = document.getElementById('p-cyclic-dates');
  1205. container.innerHTML = '';
  1206. (w.cyclicDates || []).forEach((entry, idx) => {
  1207. const row = document.createElement('div');
  1208. row.style.cssText = 'display:flex;gap:4px;align-items:center;margin-top:4px;';
  1209. row.innerHTML = `
  1210. <label style="white-space:nowrap;">Day:
  1211. <input type="number" min="1" max="31" style="width:50px;" value="${entry.day || 1}">
  1212. </label>
  1213. <label style="white-space:nowrap;">Time:
  1214. <input type="time" value="${entry.time || '00:00'}">
  1215. </label>
  1216. <button class="mini-btn danger" type="button" title="Remove">ร—</button>
  1217. `;
  1218. row.querySelector('input[type="number"]').addEventListener('input', (e) => {
  1219. entry.day = Number(e.target.value) || 1;
  1220. refreshWidgetContent(w);
  1221. onStateChanged();
  1222. });
  1223. row.querySelector('input[type="time"]').addEventListener('input', (e) => {
  1224. entry.time = e.target.value;
  1225. refreshWidgetContent(w);
  1226. onStateChanged();
  1227. });
  1228. row.querySelector('button').addEventListener('click', () => {
  1229. w.cyclicDates.splice(idx, 1);
  1230. renderCyclicDates();
  1231. refreshWidgetContent(w);
  1232. onStateChanged();
  1233. });
  1234. container.appendChild(row);
  1235. });
  1236. }
  1237. renderCyclicDates();
  1238.  
  1239. document.getElementById('p-add-cyclic-date').addEventListener('click', () => {
  1240. if (!w.cyclicDates) w.cyclicDates = [];
  1241. w.cyclicDates.push({ day: 1, time: '00:00' });
  1242. renderCyclicDates();
  1243. refreshWidgetContent(w);
  1244. onStateChanged();
  1245. });
  1246.  
  1247. // Absolute time toggle
  1248. document.getElementById('p-rawTz').addEventListener('change', (e) => {
  1249. if (e.target.checked) {
  1250. w.rawTz = Number(document.getElementById('p-rawTz-offset').value) || 0;
  1251. } else {
  1252. delete w.rawTz;
  1253. }
  1254. document.getElementById('p-rawTz-offset-label').style.display = e.target.checked ? '' : 'none';
  1255. const label = document.getElementById('p-target-label');
  1256. label.textContent = e.target.checked
  1257. ? `Target date/time (UTC${w.rawTz >= 0 ? '+' : ''}${w.rawTz ?? 0})`
  1258. : 'Target date/time (server wall time)';
  1259. refreshWidgetContent(w);
  1260. onStateChanged();
  1261. });
  1262. document.getElementById('p-rawTz-offset').addEventListener('input', (e) => {
  1263. w.rawTz = Number(e.target.value) || 0;
  1264. const label = document.getElementById('p-target-label');
  1265. label.textContent = `Target date/time (UTC${w.rawTz >= 0 ? '+' : ''}${w.rawTz})`;
  1266. refreshWidgetContent(w);
  1267. onStateChanged();
  1268. });
  1269.  
  1270. // Single-phase bindings โ†’ write to phases[0]
  1271. bindInput('p-target', v => { phase0.target = v; refreshWidgetContent(w); onStateChanged(); });
  1272. bindInput('p-endedtext', v => { phase0.endedText = v; refreshWidgetContent(w); onStateChanged(); });
  1273. bindBgInput('p-bg', v => {
  1274. phase0.bg = v;
  1275. document.getElementById('p-bg-options').style.display = v ? '' : 'none';
  1276. refreshWidgetContent(w);
  1277. onStateChanged();
  1278. });
  1279. bindInput('p-bgmode', v => { phase0.bgMode = v; refreshWidgetContent(w); onStateChanged(); });
  1280.  
  1281. // Convert single โ†’ multi: add a second phase
  1282. document.getElementById('p-add-phases').addEventListener('click', () => {
  1283. const newPhase = { id: uid('ph'), title: '', target: '', endedText: '', bg: '' };
  1284. w.phases.push(newPhase);
  1285. // Clear cyclic since it doesn't apply to multi-phase
  1286. delete w.isCyclic;
  1287. delete w.interval;
  1288. delete w.cyclicMode;
  1289. delete w.cyclicDates;
  1290. openPhaseDialog(w, newPhase, true);
  1291. renderPropsPanel();
  1292. refreshWidgetContent(w);
  1293. onStateChanged();
  1294. });
  1295.  
  1296. // Convert multi โ†’ single: keep only the first phase
  1297. document.getElementById('p-remove-phases').addEventListener('click', () => {
  1298. w.phases = [w.phases[0]];
  1299. renderPropsPanel();
  1300. refreshWidgetContent(w);
  1301. onStateChanged();
  1302. });
  1303.  
  1304. // Add new phase
  1305. document.getElementById('p-add-phase').addEventListener('click', () => {
  1306. const phase = { id: uid('ph'), title: '', target: '', endedText: '', bg: '' };
  1307. w.phases.push(phase);
  1308. openPhaseDialog(w, phase, true);
  1309. });
  1310.  
  1311. // Render phase list (multi-phase mode)
  1312. if (!isSingle) {
  1313. const list = document.getElementById('p-phase-list');
  1314. w.phases.forEach((phase, idx) => {
  1315. const row = document.createElement('div');
  1316. row.className = 'list-item';
  1317. const dateStr = phase.target ? toInputValue(phase.target).replace('T', ' ') : '?';
  1318. row.innerHTML = `
  1319. <span class="name">${escapeHtml(phase.title || '(unnamed)')} ยท ${dateStr}</span>
  1320. <span class="actions">
  1321. <button class="mini-btn" data-up title="Up">โ–ฒ</button>
  1322. <button class="mini-btn" data-down title="Down">โ–ผ</button>
  1323. <button class="mini-btn danger" data-del title="Delete">ร—</button>
  1324. </span>
  1325. `;
  1326. row.addEventListener('click', (e) => {
  1327. if (e.target.closest('[data-up]')) { e.stopPropagation(); moveItem(w.phases, idx, -1); renderPropsPanel(); refreshWidgetContent(w); onStateChanged(); return; }
  1328. if (e.target.closest('[data-down]')) { e.stopPropagation(); moveItem(w.phases, idx, +1); renderPropsPanel(); refreshWidgetContent(w); onStateChanged(); return; }
  1329. if (e.target.closest('[data-del]')) {
  1330. e.stopPropagation();
  1331. w.phases.splice(idx, 1);
  1332. // Never let phases be empty โ€” if one left, switch to single view
  1333. if (w.phases.length === 0) w.phases = [{ id: uid('ph'), title: '', target: '', endedText: '', bg: '' }];
  1334. renderPropsPanel();
  1335. refreshWidgetContent(w);
  1336. onStateChanged();
  1337. return;
  1338. }
  1339. openPhaseDialog(w, phase);
  1340. });
  1341. list.appendChild(row);
  1342. });
  1343. }
  1344. }
  1345.  
  1346. // --- List props ---
  1347. function renderListProps(panel, w) {
  1348. const displayMode = w.displayMode || 'horizontal';
  1349. panel.innerHTML = `
  1350. <h3>List widget</h3>
  1351. <div class="field"><label>Title</label><input type="text" id="p-title" value="${escapeHtml(w.title || '')}"></div>
  1352. <div class="field">
  1353. <label>Default display mode</label>
  1354. <select id="p-displaymode">
  1355. <option value="horizontal">Horizontal strip</option>
  1356. <option value="vertical">Vertical strip</option>
  1357. <option value="accordion">Accordion (collapsible)</option>
  1358. <option value="carousel">Carousel (one at a time)</option>
  1359. </select>
  1360. <p style="color:var(--e-muted);font-size:11px;margin:3px 0 0;">
  1361. Readers can cycle through modes via a button on the widget; their choice is remembered per widget.
  1362. </p>
  1363. </div>
  1364. <div class="list-header">
  1365. <h4>Items (${(w.items||[]).length})</h4>
  1366. <span style="display:flex;gap:4px;">
  1367. <button class="btn" id="p-add-item-top" title="Add to top">+ Top</button>
  1368. <button class="btn" id="p-add-item-end" title="Add to end">+ End</button>
  1369. </span>
  1370. </div>
  1371. <div class="list-items" id="p-items"></div>
  1372. `;
  1373. document.getElementById('p-displaymode').value = displayMode;
  1374. bindInput('p-title', v => { w.title = v; refreshWidgetContent(w); onStateChanged(); });
  1375. bindInput('p-displaymode', v => { w.displayMode = v; refreshWidgetContent(w); onStateChanged(); });
  1376.  
  1377. const list = document.getElementById('p-items');
  1378. (w.items || []).forEach((item, idx) => {
  1379. const row = document.createElement('div');
  1380. row.className = 'list-item';
  1381. row.innerHTML = `
  1382. <span class="name">${escapeHtml(item.title || '(untitled)')}</span>
  1383. <span class="actions">
  1384. <button class="mini-btn" data-up title="Up">โ–ฒ</button>
  1385. <button class="mini-btn" data-down title="Down">โ–ผ</button>
  1386. <button class="mini-btn danger" data-del title="Delete">ร—</button>
  1387. </span>
  1388. `;
  1389. row.addEventListener('click', (e) => {
  1390. if (e.target.closest('[data-up]')) { e.stopPropagation(); moveItem(w.items, idx, -1); renderPropsPanel(); refreshWidgetContent(w); onStateChanged(); return; }
  1391. if (e.target.closest('[data-down]')) { e.stopPropagation(); moveItem(w.items, idx, +1); renderPropsPanel(); refreshWidgetContent(w); onStateChanged(); return; }
  1392. if (e.target.closest('[data-del]')) { e.stopPropagation(); w.items.splice(idx, 1); renderPropsPanel(); refreshWidgetContent(w); onStateChanged(); return; }
  1393. openListItemDialog(w, item);
  1394. });
  1395. list.appendChild(row);
  1396. });
  1397.  
  1398. document.getElementById('p-add-item-end').addEventListener('click', () => {
  1399. const item = { id: uid('li'), title: 'New item', url: '', bg: '', text: '' };
  1400. (w.items = w.items || []).push(item);
  1401. openListItemDialog(w, item, true);
  1402. });
  1403.  
  1404. document.getElementById('p-add-item-top').addEventListener('click', () => {
  1405. const item = { id: uid('li'), title: 'New item', url: '', bg: '', text: '' };
  1406. (w.items = w.items || []).unshift(item);
  1407. openListItemDialog(w, item, true);
  1408. });
  1409. }
  1410.  
  1411. // --- Timeline props ---
  1412. function renderTimelineProps(panel, w) {
  1413. panel.innerHTML = `
  1414. <h3>Timeline widget</h3>
  1415. <div class="field"><label>Title</label><input type="text" id="p-title" value="${escapeHtml(w.title || '')}"></div>
  1416. <div class="list-header">
  1417. <h4>Events (${(w.events||[]).length})</h4>
  1418. <button class="btn" id="p-add-event">+ Add</button>
  1419. </div>
  1420. <div class="list-items" id="p-events"></div>
  1421. <p style="color:var(--e-muted);font-size:11px;margin-top:8px;">Each event belongs to a lane (row). Events in the same lane that overlap in time will overlap visually โ€” spread them across lanes to keep them readable.</p>
  1422. `;
  1423. bindInput('p-title', v => { w.title = v; refreshWidgetContent(w); onStateChanged(); });
  1424.  
  1425. const list = document.getElementById('p-events');
  1426. // Resolve lanes so auto-lane events show their actual computed lane
  1427. const resolvedLanes = resolveLanes(w.events || []);
  1428. // Sort view by resolved lane, then start date
  1429. const viewEvents = (w.events || []).map((ev, idx) => ({ ev, idx, lane: resolvedLanes.get(ev.id) ?? 0 }));
  1430. viewEvents.sort((a, b) => {
  1431. if (a.lane !== b.lane) return a.lane - b.lane;
  1432. return new Date(a.ev.start) - new Date(b.ev.start);
  1433. });
  1434. viewEvents.forEach(({ ev, idx, lane }) => {
  1435. const row = document.createElement('div');
  1436. row.className = 'list-item';
  1437. const dateStr = (() => {
  1438. const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(ev.start || '');
  1439. if (!m) return '?';
  1440. const d = new Date(+m[1], +m[2] - 1, +m[3]);
  1441. const month = d.toLocaleString(undefined, { month: 'short' });
  1442. return `${month} ${+m[3]}`;
  1443. })();
  1444. const laneLabel = ev.lane == null ? `L${lane}*` : `L${lane}`;
  1445. row.innerHTML = `
  1446. <span class="name"><span style="display:inline-block;width:10px;height:10px;background:${ev.color||'#aaa'};border:1px solid #666;margin-right:4px;vertical-align:middle;"></span>${laneLabel} ยท ${dateStr} ยท ${escapeHtml(ev.name || '(unnamed)')}</span>
  1447. <span class="actions">
  1448. <button class="mini-btn danger" data-del title="Delete">ร—</button>
  1449. </span>
  1450. `;
  1451. row.addEventListener('click', (e) => {
  1452. if (e.target.closest('[data-del]')) { e.stopPropagation(); w.events.splice(idx, 1); renderPropsPanel(); refreshWidgetContent(w); onStateChanged(); return; }
  1453. openEventDialog(w, ev);
  1454. });
  1455. list.appendChild(row);
  1456. });
  1457.  
  1458. document.getElementById('p-add-event').addEventListener('click', () => {
  1459. // Default to today 00:00 "server wall time" in the current server TZ.
  1460. // Use Intl to extract today's Y-M-D as the selected server would see it,
  1461. // then build "YYYY-MM-DDT00:00" strings directly (no UTC conversion โ€” these
  1462. // are stored as TZ-less wall-clock times per the server-relative rule model).
  1463. const tz = currentTz();
  1464. const parts = new Intl.DateTimeFormat('en-CA', {
  1465. timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
  1466. }).formatToParts(new Date()).reduce((o, p) => { o[p.type] = p.value; return o; }, {});
  1467. const startWall = `${parts.year}-${parts.month}-${parts.day}T00:00`;
  1468. // End = same time + 7 days. Add via simple Date math on a throwaway UTC Date.
  1469. const seed = new Date(Date.UTC(+parts.year, +parts.month - 1, +parts.day));
  1470. seed.setUTCDate(seed.getUTCDate() + 7);
  1471. const endWall = `${seed.getUTCFullYear()}-${String(seed.getUTCMonth()+1).padStart(2,'0')}-${String(seed.getUTCDate()).padStart(2,'0')}T00:00`;
  1472.  
  1473. const ev = {
  1474. id: uid('e'),
  1475. name: 'New event',
  1476. start: startWall,
  1477. end: endWall,
  1478. color: randomPastel(),
  1479. // lane omitted โ€” auto-assigned at render/export time.
  1480. bg: '',
  1481. text: '',
  1482. };
  1483. (w.events = w.events || []).push(ev);
  1484. openEventDialog(w, ev, true);
  1485. });
  1486. }
  1487.  
  1488. function moveItem(arr, idx, dir) {
  1489. const j = idx + dir;
  1490. if (j < 0 || j >= arr.length) return;
  1491. [arr[idx], arr[j]] = [arr[j], arr[idx]];
  1492. }
  1493.  
  1494. // Forum media references. The forum stores uploads at `/.media/<sha256>.<ext>`
  1495. // and the same hash works on any of the forum's mirror domains. To save space
  1496. // in the OP's 12000-char limit, we collapse pasted URLs that look like
  1497. // forum media down to just `<hash>.<ext>` (or `<hash>` for extension-less
  1498. // formats like apng). At render time the renderer expands them back using
  1499. // the current origin. Match is by path-only โ€” if a URL on ANY host has the
  1500. // shape `/.media/<hash>[.<ext>]`, it's treated as forum media and collapsed.
  1501. const HASH_PATTERN = /^[0-9a-f]{64}(?:\.[a-z0-9]+)?$/i;
  1502. const MEDIA_PATH_PATTERN = /^\/\.media\/([0-9a-f]{64}(?:\.[a-z0-9]+)?)$/i;
  1503. function compactMediaUrl(url) {
  1504. if (!url) return url;
  1505. const trimmed = String(url).trim();
  1506. if (!trimmed) return trimmed;
  1507. // Already in compact form? leave as-is.
  1508. if (HASH_PATTERN.test(trimmed)) return trimmed;
  1509. // Try to parse as full URL and inspect its path.
  1510. try {
  1511. const u = new URL(trimmed);
  1512. const m = MEDIA_PATH_PATTERN.exec(u.pathname);
  1513. if (m) return m[1];
  1514. } catch (e) { /* not a parseable URL โ€” leave as-is */ }
  1515. return trimmed;
  1516. }
  1517.  
  1518. function bindInput(id, cb) {
  1519. const el = document.getElementById(id);
  1520. if (!el) return;
  1521. el.addEventListener('input', () => el.type == "checkbox" ? cb(el.checked) : cb(el.value));
  1522. }
  1523.  
  1524. // Variant of bindInput for image-URL fields. Two behaviors:
  1525. // 1. Stored value is always the compacted form (hash if it's a forum
  1526. // media URL, otherwise the URL as-is). The state always has minimal data.
  1527. // 2. On blur, the input's displayed text also collapses โ€” so the author
  1528. // can paste a long forum URL and see it shrink to the hash form when
  1529. // they tab out, confirming the compaction visually.
  1530. function bindBgInput(id, cb) {
  1531. const el = document.getElementById(id);
  1532. if (!el) return;
  1533. el.addEventListener('input', () => cb(compactMediaUrl(el.value)));
  1534. el.addEventListener('blur', () => {
  1535. const compact = compactMediaUrl(el.value);
  1536. if (compact !== el.value) el.value = compact;
  1537. });
  1538. }
  1539.  
  1540. // ===== DIALOGS ============================================================
  1541. // Close-on-cancel for all dialogs
  1542. document.addEventListener('click', (e) => {
  1543. if (e.target.matches('[data-close]')) {
  1544. const d = e.target.closest('dialog');
  1545. if (d) d.close();
  1546. }
  1547. });
  1548.  
  1549. let currentEventCtx = null;
  1550. function openEventDialog(widget, ev, isNew) {
  1551. currentEventCtx = { widget, ev, isNew };
  1552. document.getElementById('dlg-event-title').textContent = isNew ? 'Add event' : 'Edit event';
  1553. document.getElementById('evt-name').value = ev.name || '';
  1554. // Wall-time strings are already in the format datetime-local expects.
  1555. // Strip seconds/ms if present (e.g. "2026-04-20T04:00:00" -> "2026-04-20T04:00").
  1556. document.getElementById('evt-start').value = toInputValue(ev.start);
  1557.  
  1558. // "Has end" is checked by default for new events; mirror the stored value for existing.
  1559. const hasEnd = isNew ? true : !!ev.end;
  1560. document.getElementById('evt-has-end').checked = hasEnd;
  1561. document.getElementById('evt-end-wrap').style.display = hasEnd ? '' : 'none';
  1562. document.getElementById('evt-end').value = toInputValue(ev.end);
  1563.  
  1564. document.getElementById('evt-color').value = ev.color || '#aaaaaa';
  1565. // Empty string when lane is unset (auto-assigned at render/export time).
  1566. document.getElementById('evt-lane').value = ev.lane == null ? '' : String(ev.lane);
  1567. document.getElementById('evt-bg').value = ev.bg || '';
  1568. document.getElementById('evt-text').value = ev.text || '';
  1569. document.getElementById('dlg-event').showModal();
  1570. }
  1571. // Normalize a stored wall-time (or legacy UTC ISO) string to the HTML
  1572. // datetime-local format "YYYY-MM-DDTHH:mm". Strips ":ss", ".sss", and "Z" if present.
  1573. function toInputValue(s) {
  1574. if (!s) return '';
  1575. const m = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/.exec(s);
  1576. return m ? m[1] : '';
  1577. }
  1578.  
  1579. document.getElementById('evt-has-end').addEventListener('change', (e) => {
  1580. const wrap = document.getElementById('evt-end-wrap');
  1581. wrap.style.display = e.target.checked ? '' : 'none';
  1582. // If user just enabled end and end input is empty, seed with start+1 day at 00:00.
  1583. if (e.target.checked && !document.getElementById('evt-end').value) {
  1584. const startVal = document.getElementById('evt-start').value;
  1585. if (startVal) {
  1586. // Parse start's YMD, bump day by 1, keep 00:00 time. No TZ conversion โ€”
  1587. // this is all wall-time arithmetic.
  1588. const parts = /^(\d{4})-(\d{2})-(\d{2})/.exec(startVal);
  1589. if (parts) {
  1590. const d = new Date(Date.UTC(+parts[1], +parts[2] - 1, +parts[3]));
  1591. d.setUTCDate(d.getUTCDate() + 1);
  1592. const endInput = `${d.getUTCFullYear()}-${String(d.getUTCMonth()+1).padStart(2,'0')}-${String(d.getUTCDate()).padStart(2,'0')}T00:00`;
  1593. document.getElementById('evt-end').value = endInput;
  1594. }
  1595. }
  1596. }
  1597. });
  1598. document.getElementById('evt-save').addEventListener('click', () => {
  1599. if (!currentEventCtx) return;
  1600. const { widget, ev } = currentEventCtx;
  1601. ev.name = document.getElementById('evt-name').value.trim() || 'Unnamed';
  1602. // Store the wall-time string as typed. No UTC conversion โ€” this is the
  1603. // server's clock face. The renderer converts to real UTC at display time
  1604. // using the currently-selected server's TZ.
  1605. ev.start = document.getElementById('evt-start').value;
  1606. const hasEnd = document.getElementById('evt-has-end').checked;
  1607. ev.end = hasEnd ? document.getElementById('evt-end').value : '';
  1608. ev.color = document.getElementById('evt-color').value;
  1609. // Empty lane input = auto-assign at export/render time (stored as undefined).
  1610. const laneRaw = document.getElementById('evt-lane').value.trim();
  1611. if (laneRaw === '') {
  1612. delete ev.lane;
  1613. } else {
  1614. ev.lane = Math.max(0, parseInt(laneRaw, 10) || 0);
  1615. }
  1616. ev.bg = compactMediaUrl(document.getElementById('evt-bg').value.trim());
  1617. ev.text = document.getElementById('evt-text').value;
  1618. if (!ev.start) {
  1619. alert('Start date is required');
  1620. return;
  1621. }
  1622. document.getElementById('dlg-event').close();
  1623. currentEventCtx = null;
  1624. renderPropsPanel();
  1625. refreshWidgetContent(widget);
  1626. onStateChanged();
  1627. });
  1628.  
  1629. // ===== URL IMAGE PARSERS ==================================================
  1630. // Each parser: { name, match(url)->bool, extract(url)->imageUrl|null }.
  1631. // Only CORS-unrestricted sources can be used from the browser without a proxy.
  1632. // Add more entries below as needed.
  1633. let currentListItemCtx = null;
  1634.  
  1635. function setUrlMode(multi) {
  1636. document.getElementById('li-url-single').style.display = multi ? 'none' : '';
  1637. document.getElementById('li-url-multi').style.display = multi ? '' : 'none';
  1638. }
  1639.  
  1640. // --- Phase dialog (countdown series) ---
  1641. let currentPhaseCtx = null;
  1642. function openPhaseDialog(widget, phase, isNew) {
  1643. currentPhaseCtx = { widget, phase, isNew };
  1644. document.getElementById('dlg-phase-title').textContent = isNew ? 'Add phase' : 'Edit phase';
  1645. document.getElementById('ph-title').value = phase.title || '';
  1646. document.getElementById('ph-target').value = toInputValue(phase.target || '');
  1647. document.getElementById('ph-endedtext').value = phase.endedText || '';
  1648. document.getElementById('ph-bg').value = phase.bg || '';
  1649. document.getElementById('ph-bgmode').value = phase.bgMode || 'cover';
  1650. document.getElementById('ph-bg-options').style.display = phase.bg ? '' : 'none';
  1651. document.getElementById('ph-bg').addEventListener('input', function handler() {
  1652. document.getElementById('ph-bg-options').style.display = this.value.trim() ? '' : 'none';
  1653. });
  1654. document.getElementById('dlg-phase').showModal();
  1655. }
  1656.  
  1657. document.getElementById('ph-save').addEventListener('click', () => {
  1658. if (!currentPhaseCtx) return;
  1659. const { widget, phase } = currentPhaseCtx;
  1660. phase.title = document.getElementById('ph-title').value.trim() || 'Untitled';
  1661. phase.target = document.getElementById('ph-target').value;
  1662. phase.endedText = document.getElementById('ph-endedtext').value.trim();
  1663. phase.bg = document.getElementById('ph-bg').value.trim();
  1664. phase.bgMode = phase.bg ? document.getElementById('ph-bgmode').value : '';
  1665. document.getElementById('dlg-phase').close();
  1666. currentPhaseCtx = null;
  1667. renderPropsPanel();
  1668. refreshWidgetContent(widget);
  1669. onStateChanged();
  1670. });
  1671.  
  1672. function openListItemDialog(widget, item, isNew) {
  1673. currentListItemCtx = { widget, item, isNew };
  1674. document.getElementById('dlg-list-item-title').textContent = isNew ? 'Add list item' : 'Edit list item';
  1675. document.getElementById('li-title').value = item.title || '';
  1676. document.getElementById('li-bg').value = item.bg || '';
  1677. document.getElementById('li-text').value = item.text || '';
  1678.  
  1679. const container = document.getElementById('li-lang-urls');
  1680. container.innerHTML = '';
  1681.  
  1682. if (item.urls && Object.keys(item.urls).length > 0) {
  1683. // Multilingual mode
  1684. document.getElementById('li-url').value = '';
  1685. Object.entries(item.urls).forEach(([lang, url]) => {
  1686. container.appendChild(createLangUrlRow(lang, url));
  1687. });
  1688. setUrlMode(true);
  1689. } else {
  1690. // Single URL mode
  1691. document.getElementById('li-url').value = item.url || '';
  1692. setUrlMode(false);
  1693. }
  1694.  
  1695. document.getElementById('dlg-list-item').showModal();
  1696. }
  1697.  
  1698. function createLangUrlRow(lang, url) {
  1699. const row = document.createElement('div');
  1700. row.className = 'lang-url-row';
  1701.  
  1702. const langInput = document.createElement('input');
  1703. langInput.type = 'text';
  1704. langInput.placeholder = 'EN';
  1705. langInput.value = lang || '';
  1706. langInput.maxLength = 5;
  1707.  
  1708. const urlInput = document.createElement('input');
  1709. urlInput.type = 'url';
  1710. urlInput.placeholder = 'https://...';
  1711. urlInput.value = url || '';
  1712.  
  1713. const del = document.createElement('button');
  1714. del.className = 'mini-btn danger';
  1715. del.textContent = 'ร—';
  1716. del.title = 'Remove';
  1717. del.type = 'button';
  1718. del.addEventListener('click', () => {
  1719. row.remove();
  1720. // If no language rows left, switch back to single URL mode
  1721. const remaining = document.getElementById('li-lang-urls').querySelectorAll('.lang-url-row');
  1722. if (remaining.length === 0) {
  1723. setUrlMode(false);
  1724. }
  1725. });
  1726.  
  1727. row.appendChild(langInput);
  1728. row.appendChild(urlInput);
  1729. row.appendChild(del);
  1730. return row;
  1731. }
  1732.  
  1733. // Convert from single URL to multilingual
  1734. document.getElementById('li-add-lang').addEventListener('click', () => {
  1735. const container = document.getElementById('li-lang-urls');
  1736. const existingUrl = document.getElementById('li-url').value.trim();
  1737. // Move existing URL into the first language row
  1738. if (existingUrl) {
  1739. container.appendChild(createLangUrlRow('', existingUrl));
  1740. }
  1741. // Add a blank row for the new language
  1742. container.appendChild(createLangUrlRow('', ''));
  1743. document.getElementById('li-url').value = '';
  1744. setUrlMode(true);
  1745. // Focus the lang input โ€” first row if URL was moved, otherwise the only row
  1746. const rows = container.querySelectorAll('.lang-url-row');
  1747. const target = rows[0];
  1748. if (target) target.querySelector('input[type="text"]').focus();
  1749. });
  1750.  
  1751. // Add another language row in multi mode
  1752. document.getElementById('li-add-lang-row').addEventListener('click', () => {
  1753. const container = document.getElementById('li-lang-urls');
  1754. container.appendChild(createLangUrlRow('', ''));
  1755. const rows = container.querySelectorAll('.lang-url-row');
  1756. const last = rows[rows.length - 1];
  1757. if (last) last.querySelector('input[type="text"]').focus();
  1758. });
  1759.  
  1760. document.getElementById('li-save').addEventListener('click', () => {
  1761. if (!currentListItemCtx) return;
  1762. const { widget, item } = currentListItemCtx;
  1763. item.title = document.getElementById('li-title').value.trim() || 'Untitled';
  1764. item.bg = compactMediaUrl(document.getElementById('li-bg').value.trim());
  1765. item.text = document.getElementById('li-text').value;
  1766.  
  1767. const isMulti = document.getElementById('li-url-multi').style.display !== 'none';
  1768.  
  1769. if (isMulti) {
  1770. // Multilingual mode: only use urls, clear url
  1771. const langRows = document.getElementById('li-lang-urls').querySelectorAll('.lang-url-row');
  1772. const urls = {};
  1773. langRows.forEach(row => {
  1774. const lang = row.querySelector('input[type="text"]').value.trim().toLowerCase();
  1775. const url = row.querySelector('input[type="url"]').value.trim();
  1776. if (lang && url) urls[lang] = url;
  1777. });
  1778. if (Object.keys(urls).length > 0) {
  1779. item.urls = urls;
  1780. delete item.url;
  1781. } else {
  1782. // All rows were empty, fall back to no URL
  1783. delete item.urls;
  1784. delete item.url;
  1785. }
  1786. } else {
  1787. // Single URL mode
  1788. item.url = document.getElementById('li-url').value.trim();
  1789. delete item.urls;
  1790. }
  1791.  
  1792. document.getElementById('dlg-list-item').close();
  1793. currentListItemCtx = null;
  1794. renderPropsPanel();
  1795. refreshWidgetContent(widget);
  1796. onStateChanged();
  1797. });
  1798.  
  1799. // ===== OUTPUT GENERATION ==================================================
  1800. // Generate the full OP paste-able text:
  1801. // [human-readable sections grouped by type]
  1802. // [code]
  1803. // @@grid-v1@@
  1804. // {minified json}
  1805. // [/code]
  1806. function generateOutput() {
  1807. const sections = [];
  1808.  
  1809. // First line of every exported OP is a manual placeholder for the link to
  1810. // the previous thread. Author pastes the template into the new thread,
  1811. // then types or quote-links the previous thread number after "Previous: ".
  1812. // The renderer parses this back as a (Cross-thread) link and surfaces it
  1813. // in the status bar.
  1814. sections.push('Previous: ', '');
  1815.  
  1816. // Group widgets by type.
  1817. const byType = { generic: [], timeline: [], list: [], link: [] };
  1818. state.widgets.forEach(w => { (byType[w.type] || (byType[w.type] = [])).push(w); });
  1819.  
  1820. // Within each group, sort by manual `order` (unset = Infinity โ†’ end of group).
  1821. // Stable sort: widgets with equal orders keep their insertion order.
  1822. const orderOf = w => (w.order == null ? Infinity : w.order);
  1823. Object.keys(byType).forEach(k => {
  1824. byType[k].sort((a, b) => orderOf(a) - orderOf(b));
  1825. });
  1826.  
  1827. // Emit each type's section as a function that returns its text lines.
  1828. // Each emitter's "group order" is the min order of any widget in it (unset
  1829. // members don't contribute). A group with no ordered members gets Infinity,
  1830. // which sinks it below ordered groups โ€” and ties (including the all-unset
  1831. // default case) break on the hardcoded natural sequence below.
  1832. const emitters = [
  1833. { type: 'generic', emit: emitGeneric },
  1834. { type: 'timeline', emit: emitTimeline },
  1835. { type: 'list', emit: emitList },
  1836. { type: 'link', emit: emitLink },
  1837. ];
  1838. const groupMinOrder = widgets => {
  1839. let min = Infinity;
  1840. widgets.forEach(w => { if (w.order != null && w.order < min) min = w.order; });
  1841. return min;
  1842. };
  1843. emitters.forEach((e, idx) => {
  1844. e.min = groupMinOrder(byType[e.type] || []);
  1845. e.tiebreak = idx; // hardcoded sequence fallback
  1846. });
  1847. emitters.sort((a, b) => (a.min - b.min) || (a.tiebreak - b.tiebreak));
  1848.  
  1849. emitters.forEach(e => {
  1850. const lines = e.emit(byType[e.type] || []);
  1851. lines.forEach(l => sections.push(l));
  1852. });
  1853.  
  1854. // Code block with JSON โ€” auto-lanes are resolved here so exported payload
  1855. // has explicit lane numbers for every event (renderer stays simple).
  1856. const payload = buildPayloadWithResolvedLanes();
  1857. const json = JSON.stringify(payload);
  1858. sections.push('[code]');
  1859. sections.push(MAGIC);
  1860. sections.push(json);
  1861. sections.push('[/code]');
  1862.  
  1863. return sections.join('\n');
  1864. }
  1865.  
  1866. function emitGeneric(widgets) {
  1867. const lines = [];
  1868. widgets.forEach(w => {
  1869. lines.push(`>[${w.title || 'Untitled'}]`);
  1870. if (w.text) lines.push(w.text);
  1871. lines.push('');
  1872. });
  1873. return lines;
  1874. }
  1875. function emitTimeline(widgets) {
  1876. const lines = [];
  1877. widgets.forEach(w => {
  1878. let header = `>[${w.title || 'Timeline'}]`;
  1879. if ((state.meta.servers || []).length > 0) {
  1880. header += ` (${currentServerLabel()})`;
  1881. }
  1882. lines.push(header);
  1883. (w.events || []).slice().sort((a, b) => new Date(a.start) - new Date(b.start)).forEach(ev => {
  1884. lines.push(formatEventText(ev));
  1885. });
  1886. lines.push('');
  1887. });
  1888. return lines;
  1889. }
  1890. function emitList(widgets) {
  1891. const lines = [];
  1892. widgets.forEach(w => {
  1893. lines.push(`>[${w.title || 'List'}]`);
  1894. (w.items || []).forEach(item => {
  1895. if (item.urls && Object.keys(item.urls).length > 0) {
  1896. lines.push(item.title || 'Untitled');
  1897. Object.entries(item.urls).forEach(([lang, url]) => {
  1898. lines.push(`${lang.toUpperCase()} ${url}`);
  1899. });
  1900. } else {
  1901. const bits = [item.title || 'Untitled'];
  1902. if (item.url) bits.push(item.url);
  1903. lines.push(bits.join(' | '));
  1904. }
  1905. });
  1906. lines.push('');
  1907. });
  1908. return lines;
  1909. }
  1910. function emitLink(widgets) {
  1911. if (widgets.length === 0) return [];
  1912. const lines = ['>[Links]'];
  1913. widgets.forEach(w => {
  1914. const bits = [w.title || 'Link'];
  1915. if (w.url) bits.push(w.url);
  1916. lines.push(bits.join(' | '));
  1917. });
  1918. lines.push('');
  1919. return lines;
  1920. }
  1921.  
  1922. // Assign auto-lanes (greedy interval scheduling). Events with explicit `lane`
  1923. // numbers are pinned and block their lane for their time range. Events with
  1924. // `lane == null` get the lowest-numbered lane that has no conflict at their
  1925. // start moment. Returns a Map(eventId โ†’ lane number) covering ALL events.
  1926. function resolveLanes(events) {
  1927. const out = new Map();
  1928. if (!events || events.length === 0) return out;
  1929.  
  1930. const DAY_MS = 86400000;
  1931. // Use wall-time string directly for interval comparison โ€” since all events
  1932. // in the same widget are interpreted under one "server relative" rule, two
  1933. // wall-time strings can be compared as plain strings (ISO-like format makes
  1934. // lexicographic == chronological).
  1935. const rangeOf = ev => {
  1936. const s = ev.start || '';
  1937. const e = ev.end || '';
  1938. // If no end, treat as half-day width โ€” but we don't know wall-time math here;
  1939. // a coarse "1 day after start" estimation is fine for conflict-detection.
  1940. if (!e) {
  1941. // Bump last two chars of the day portion โ€” or just append 'Z' and parse.
  1942. // Simpler: use Date parsing (wall time interpreted as local); we only
  1943. // compare intervals for overlap, so TZ consistency within one call is
  1944. // all that matters.
  1945. const sMs = Date.parse(s + 'Z');
  1946. return { s: sMs, e: sMs + DAY_MS * 0.5 };
  1947. }
  1948. return { s: Date.parse(s + 'Z'), e: Date.parse(e + 'Z') };
  1949. };
  1950.  
  1951. // Track per-lane the list of occupied [s, e] intervals, so we can check
  1952. // whether a new event collides with anything in that lane.
  1953. const laneIntervals = new Map(); // laneNum -> [{s, e}, ...]
  1954.  
  1955. // First pass: place events with explicit lanes. They always win their slot.
  1956. events.forEach(ev => {
  1957. if (ev.lane == null) return;
  1958. const r = rangeOf(ev);
  1959. if (!laneIntervals.has(ev.lane)) laneIntervals.set(ev.lane, []);
  1960. laneIntervals.get(ev.lane).push(r);
  1961. out.set(ev.id, ev.lane);
  1962. });
  1963.  
  1964. // Second pass: assign auto-lane events. Sort by start so earlier events
  1965. // take lower lanes by default (visually nicer stacking).
  1966. const autos = events.filter(ev => ev.lane == null).map(ev => ({ ev, r: rangeOf(ev) }));
  1967. autos.sort((a, b) => a.r.s - b.r.s);
  1968.  
  1969. const overlaps = (a, b) => a.s < b.e && b.s < a.e;
  1970. autos.forEach(({ ev, r }) => {
  1971. // Try lanes 0, 1, 2, ... until we find one where no existing interval overlaps.
  1972. for (let lane = 0; lane < 1000; lane++) {
  1973. const ivs = laneIntervals.get(lane) || [];
  1974. if (ivs.every(iv => !overlaps(iv, r))) {
  1975. if (!laneIntervals.has(lane)) laneIntervals.set(lane, []);
  1976. laneIntervals.get(lane).push(r);
  1977. out.set(ev.id, lane);
  1978. return;
  1979. }
  1980. }
  1981. // Shouldn't reach here โ€” 1000 lanes is plenty.
  1982. out.set(ev.id, 0);
  1983. });
  1984.  
  1985. return out;
  1986. }
  1987.  
  1988. // Apply resolved lanes by mutating a cloned event list โ€” used by serializers
  1989. // so the exported JSON has explicit lane numbers for every event.
  1990. function eventsWithResolvedLanes(events) {
  1991. const lanes = resolveLanes(events || []);
  1992. return (events || []).map(ev => ({ ...ev, lane: lanes.get(ev.id) ?? 0 }));
  1993. }
  1994.  
  1995. // Build a payload with auto-lanes filled in for every timeline widget.
  1996. function buildPayloadWithResolvedLanes() {
  1997. const widgets = state.widgets.map(w => {
  1998. let out = { ...w };
  1999. if (out.type === 'timeline') {
  2000. out = { ...out, events: eventsWithResolvedLanes(out.events).map(cleanEvent) };
  2001. }
  2002. if (out.type === 'list') {
  2003. out = { ...out, items: (out.items || []).map(cleanListItem) };
  2004. }
  2005. if (out.type === 'countdown') {
  2006. out = cleanCountdown(out);
  2007. }
  2008. // Widget-level optional fields
  2009. if (!out.bg) delete out.bg;
  2010. if (!out.bgMode) delete out.bgMode;
  2011. if (!out.endedText) delete out.endedText;
  2012. if (!out.hideTitle) delete out.hideTitle;
  2013. if (!out.hideBg) delete out.hideBg;
  2014. if (!out.hoverBg) delete out.hoverBg;
  2015. return out;
  2016. });
  2017. return { meta: state.meta, widgets };
  2018. }
  2019.  
  2020. function cleanEvent(ev) {
  2021. const out = { ...ev };
  2022. if (!out.bg) delete out.bg;
  2023. if (!out.text) delete out.text;
  2024. return out;
  2025. }
  2026.  
  2027. function cleanListItem(item) {
  2028. const out = { ...item };
  2029. if (!out.bg) delete out.bg;
  2030. if (!out.text) delete out.text;
  2031. if (out.urls && Object.keys(out.urls).length === 0) delete out.urls;
  2032. return out;
  2033. }
  2034.  
  2035. function cleanCountdown(w) {
  2036. const out = { ...w };
  2037. delete out.title;
  2038. if (!out.isCyclic) {
  2039. delete out.isCyclic;
  2040. delete out.interval;
  2041. delete out.cyclicMode;
  2042. delete out.cyclicDates;
  2043. } else if (out.cyclicMode === 'datetime') {
  2044. delete out.interval;
  2045. if (!out.cyclicDates || out.cyclicDates.length === 0) delete out.cyclicDates;
  2046. } else {
  2047. // timespan mode (default)
  2048. delete out.cyclicMode;
  2049. delete out.cyclicDates;
  2050. if (out.interval && out.interval.every(v => !v)) delete out.interval;
  2051. }
  2052. if (out.rawTz == null) delete out.rawTz;
  2053. if (!out.cdStyle) delete out.cdStyle;
  2054. if (out.phases) {
  2055. out.phases = out.phases.map(p => {
  2056. const cp = { ...p };
  2057. if (!cp.bg) delete cp.bg;
  2058. if (!cp.bgMode) delete cp.bgMode;
  2059. if (!cp.endedText) delete cp.endedText;
  2060. return cp;
  2061. });
  2062. }
  2063. return out;
  2064. }
  2065.  
  2066. function formatEventText(ev /*, tz */) {
  2067. // Wall-time strings are stored directly as the server sees them. Parse the
  2068. // date portion straight from the string โ€” no TZ conversion needed.
  2069. const parseParts = s => {
  2070. const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s || '');
  2071. return m ? { y: m[1], m: m[2], d: m[3] } : null;
  2072. };
  2073. const ps = parseParts(ev.start);
  2074. const pe = parseParts(ev.end);
  2075. if (ps && pe) {
  2076. const full = p => `${p.y}-${p.m}-${p.d}`;
  2077. const short = p => `${p.m}-${p.d}`;
  2078. if (ps.y === pe.y) return `${full(ps)} - ${short(pe)} | ${ev.name}`;
  2079. return `${full(ps)} - ${full(pe)} | ${ev.name}`;
  2080. }
  2081. return ps ? `${ps.y}-${ps.m}-${ps.d} | ${ev.name}` : `? | ${ev.name}`;
  2082. }
  2083.  
  2084. // ===== CHANGE HANDLER =====================================================
  2085. function onStateChanged() {
  2086. save();
  2087. updateOutput();
  2088. }
  2089.  
  2090. function updateOutput() {
  2091. const out = generateOutput();
  2092. document.getElementById('output-preview').value = out;
  2093.  
  2094. const count = out.length;
  2095. const el = document.getElementById('char-count');
  2096. el.textContent = `OP: ${count.toLocaleString()} / ${MAX_CHARS.toLocaleString()}`;
  2097. el.classList.remove('warn', 'over');
  2098. if (count > MAX_CHARS) el.classList.add('over');
  2099. else if (count > MAX_CHARS * 0.9) el.classList.add('warn');
  2100.  
  2101. const payload = buildPayloadWithResolvedLanes();
  2102. const codeLen = JSON.stringify(payload).length + MAGIC.length + 14; // [code]\n@@magic@@\n{json}\n[/code]
  2103. document.getElementById('code-count').textContent = `[code]: ${codeLen.toLocaleString()}`;
  2104. }
  2105.  
  2106. // ===== TOP BAR ============================================================
  2107.  
  2108. // Add widget
  2109. document.querySelectorAll('[data-add]').forEach(btn => {
  2110. btn.addEventListener('click', () => {
  2111. const type = btn.dataset.add;
  2112. const w = newWidget(type);
  2113. state.widgets.push(w);
  2114. addWidgetToGrid(w, { autoPosition: true });
  2115. // Pull back the actual placed position from Gridstack
  2116. const node = grid.engine.nodes.find(n => n.id === w.id);
  2117. if (node) { w.x = node.x; w.y = node.y; w.w = node.w; w.h = node.h; }
  2118. selectWidget(w.id);
  2119. // Scroll the new widget into view
  2120. const el = document.querySelector(`.grid-stack-item[gs-id="${w.id}"]`);
  2121. if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  2122. onStateChanged();
  2123. });
  2124. });
  2125.  
  2126. // Clear
  2127. document.getElementById('btn-clear').addEventListener('click', () => {
  2128. if (!confirm('Clear all widgets and server config?')) return;
  2129. state.widgets = [];
  2130. state.meta = { title: '', servers: [], defaultServerId: null };
  2131. state.selectedId = null;
  2132. grid.removeAll();
  2133. updateServerButton();
  2134. renderPropsPanel();
  2135. onStateChanged();
  2136. });
  2137.  
  2138. // Copy OP
  2139. document.getElementById('btn-copy').addEventListener('click', async () => {
  2140. const text = generateOutput();
  2141. try {
  2142. await navigator.clipboard.writeText(text);
  2143. toast('Copied to clipboard');
  2144. } catch (e) {
  2145. // Fallback
  2146. const ta = document.getElementById('output-preview');
  2147. ta.focus(); ta.select();
  2148. document.execCommand('copy');
  2149. toast('Copied (fallback)');
  2150. }
  2151. });
  2152.  
  2153. // Copy [code] block only
  2154. document.getElementById('btn-copy-code').addEventListener('click', async () => {
  2155. const payload = buildPayloadWithResolvedLanes();
  2156. const json = JSON.stringify(payload);
  2157. const text = `[code]\n${MAGIC}\n${json}\n[/code]`;
  2158. try {
  2159. await navigator.clipboard.writeText(text);
  2160. toast('Code block copied');
  2161. } catch (e) {
  2162. const ta = document.getElementById('output-preview');
  2163. ta.value = text;
  2164. ta.focus(); ta.select();
  2165. document.execCommand('copy');
  2166. toast('Code block copied (fallback)');
  2167. }
  2168. });
  2169.  
  2170. // ===== PREVIEW ============================================================
  2171. function openPreview() {
  2172. if (!window.InteractiveOP) {
  2173. toast('Renderer not loaded (renderer.js missing?)');
  2174. return;
  2175. }
  2176. const overlay = document.getElementById('preview-overlay');
  2177. const post = document.getElementById('preview-post');
  2178. post.innerHTML = '';
  2179. const grid = window.InteractiveOP.renderGrid(
  2180. buildPayloadWithResolvedLanes(),
  2181. { interactive: true, placeholder: true, placeholderCount: 5 }
  2182. );
  2183. post.appendChild(grid);
  2184. overlay.hidden = false;
  2185. document.addEventListener('keydown', onPreviewKey);
  2186. }
  2187. function closePreview() {
  2188. document.getElementById('preview-overlay').hidden = true;
  2189. document.getElementById('preview-post').innerHTML = '';
  2190. document.removeEventListener('keydown', onPreviewKey);
  2191. }
  2192. function onPreviewKey(e) {
  2193. if (e.key === 'Escape') closePreview();
  2194. }
  2195. document.getElementById('btn-preview').addEventListener('click', openPreview);
  2196. document.getElementById('preview-close').addEventListener('click', closePreview);
  2197. // Click outside the post content (on the dimmed stage) closes
  2198. document.getElementById('preview-overlay').addEventListener('click', (e) => {
  2199. if (e.target.id === 'preview-stage' || e.target.id === 'preview-overlay') closePreview();
  2200. });
  2201. // Mobile/desktop width toggle
  2202. document.querySelectorAll('input[name="preview-width"]').forEach(r => {
  2203. r.addEventListener('change', (e) => {
  2204. const post = document.getElementById('preview-post');
  2205. post.classList.toggle('mobile', e.target.value === 'mobile');
  2206. });
  2207. });
  2208.  
  2209. // ===== EMBEDDED RENDERER ===================================================
  2210.  
  2211. const InteractiveOP = function () {
  2212. const OP_SELECTOR = '.opCell';
  2213. const MSG_SELECTOR = '.divMessage';
  2214. const MAGIC = '@@grid-v1@@';
  2215. const GRID_COLS = 18;
  2216. const MOBILE_BREAKPOINT = 812;
  2217. const ROW_TO_COL_RATIO = 0.6;
  2218. const ROW_HEIGHT_PX = 50;
  2219.  
  2220. const state = {
  2221. enabled: true,
  2222. originals: new WeakMap(), // post element -> original innerHTML
  2223. rendered: new WeakSet(), // post elements already processed
  2224. movedAttachments: new WeakMap(), // post element -> [{link, originalParent}, ...]
  2225. lightboxEl: null, // currently-open lightbox backdrop element
  2226. lightboxKeyHandler: null, // keydown handler attached while lightbox is open
  2227. };
  2228.  
  2229. function decodeHtml(s) {
  2230. const ta = document.createElement('textarea');
  2231. ta.innerHTML = s;
  2232. return ta.value;
  2233. }
  2234.  
  2235. function extractGridData(postEl, msgEl) {
  2236. const codeBlock = msgEl.querySelector('span.blockCode code');
  2237. if (!codeBlock) return null;
  2238.  
  2239. const txt = codeBlock.textContent;
  2240. const parsed = tryParseMarkerBlock(txt);
  2241. if (parsed) return { data: parsed, source: 'dom' };
  2242.  
  2243. return null;
  2244. }
  2245.  
  2246. function tryParseMarkerBlock(text) {
  2247. if (!text) return null;
  2248. const trimmed = text.trim();
  2249. if (!trimmed.startsWith(MAGIC)) {
  2250. const idx = trimmed.indexOf(MAGIC);
  2251. if (idx === -1) return null;
  2252. const jsonStr = trimmed.slice(idx + MAGIC.length).trim();
  2253. return safeParseJson(jsonStr);
  2254. }
  2255. const jsonStr = trimmed.slice(MAGIC.length).trim();
  2256. return safeParseJson(jsonStr);
  2257. }
  2258.  
  2259. function safeParseJson(s) {
  2260. try {
  2261. const data = JSON.parse(s);
  2262. if (!data || !Array.isArray(data.widgets)) return null;
  2263. return data;
  2264. } catch (e) {
  2265. console.warn('[grid-renderer] JSON parse failed:', e);
  2266. return null;
  2267. }
  2268. }
  2269.  
  2270. function isMobile() { return window.innerWidth < MOBILE_BREAKPOINT; }
  2271.  
  2272. function observeVisibility(el, onVisible, onHidden) {
  2273. if (typeof IntersectionObserver === 'undefined') {
  2274. try { onVisible(); } catch (e) { console.error(e); }
  2275. return () => { };
  2276. }
  2277. let isVisible = false;
  2278. const io = new IntersectionObserver(entries => {
  2279. for (const entry of entries) {
  2280. const visibleNow = entry.isIntersecting;
  2281. if (visibleNow === isVisible) continue;
  2282. isVisible = visibleNow;
  2283. try { (visibleNow ? onVisible : onHidden)(); }
  2284. catch (e) { console.error('[grid-renderer] visibility callback failed:', e); }
  2285. }
  2286. }, { threshold: 0 });
  2287. io.observe(el);
  2288. return () => io.disconnect();
  2289. }
  2290.  
  2291. function lazyBgImage(el, url) {
  2292. const cssUrl = `url("${url}")`;
  2293. if (typeof IntersectionObserver === 'undefined') {
  2294. el.style.backgroundImage = cssUrl;
  2295. return;
  2296. }
  2297. const io = new IntersectionObserver(entries => {
  2298. for (const entry of entries) {
  2299. if (entry.isIntersecting) {
  2300. el.style.backgroundImage = cssUrl;
  2301. io.disconnect();
  2302. break;
  2303. }
  2304. }
  2305. }, { threshold: 0 });
  2306. io.observe(el);
  2307. }
  2308.  
  2309. function escapeHtml(s) {
  2310. return String(s).replace(/[&<>"']/g, c => (
  2311. { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
  2312. ));
  2313. }
  2314.  
  2315. function linkifyText(text) {
  2316. const escaped = escapeHtml(text);
  2317. return escaped.replace(/(https?:\/\/[^\s<>"]+)/g, (url) => {
  2318. return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
  2319. }).replace(/\n/g, '<br>');
  2320. }
  2321.  
  2322. let activePopup = null;
  2323. function closePopup() {
  2324. if (activePopup) { activePopup.remove(); activePopup = null; }
  2325. document.removeEventListener('click', onDocClickForPopup, true);
  2326. document.removeEventListener('keydown', onEscForPopup);
  2327. }
  2328. function onDocClickForPopup(e) {
  2329. if (activePopup && !activePopup.contains(e.target)) closePopup();
  2330. }
  2331. function onEscForPopup(e) { if (e.key === 'Escape') closePopup(); }
  2332.  
  2333. function computeCountdown(startDate, endDate) {
  2334. const now = Date.now();
  2335. const s = startDate ? startDate.getTime() : null;
  2336. const e = endDate ? endDate.getTime() : null;
  2337. if (s !== null && now < s) {
  2338. return { text: 'starts in ' + humanDuration(s - now), state: 'future' };
  2339. }
  2340. if (e !== null) {
  2341. if (now < e) {
  2342. const remaining = e - now;
  2343. return {
  2344. text: humanDuration(remaining) + ' left',
  2345. state: remaining < 86400000 ? 'urgent' : 'normal',
  2346. };
  2347. }
  2348. return { text: 'ended ' + humanDuration(now - e) + ' ago', state: 'ended' };
  2349. }
  2350. return null;
  2351. }
  2352. // "2d 4h" / "5h 30m" / "45m" / "30s"
  2353. function humanDuration(ms) {
  2354. if (ms < 0) ms = 0;
  2355. const sec = Math.floor(ms / 1000);
  2356. const d = Math.floor(sec / 86400);
  2357. const h = Math.floor((sec % 86400) / 3600);
  2358. const m = Math.floor((sec % 3600) / 60);
  2359. const s = sec % 60;
  2360. if (d > 0) return `${d}d ${h}h`;
  2361. if (h > 0) return `${h}h ${m}m`;
  2362. if (m > 0) return `${m}m`;
  2363. return `${s}s`;
  2364. }
  2365.  
  2366. // Shared "Mon DD, YYYY HH:MM" format options (24h).
  2367. const FULL_DATETIME_OPTS = {
  2368. year: 'numeric', month: 'short', day: '2-digit',
  2369. hour: '2-digit', minute: '2-digit', hour12: false,
  2370. };
  2371. // Append a "<start> โ€” <end> (label)" (or single-time) line to a popup.
  2372. function appendPopupTimeLine(popup, fmt, s, e, label, extraClass) {
  2373. const line = document.createElement('div');
  2374. line.className = 'gr-popup-time' + (extraClass ? ' ' + extraClass : '');
  2375. line.textContent = e
  2376. ? `${fmt.format(s)} โ€” ${fmt.format(e)} (${label})`
  2377. : `${fmt.format(s)} (${label})`;
  2378. popup.appendChild(line);
  2379. }
  2380.  
  2381. function showEventPopup(anchorEl, event, tz, serverName) {
  2382. closePopup();
  2383. tz = tz || 'UTC';
  2384. const popup = document.createElement('div');
  2385. popup.className = 'gr-popup';
  2386.  
  2387. const close = document.createElement('span');
  2388. close.className = 'gr-popup-close';
  2389. close.textContent = 'โœ•';
  2390. close.onclick = (e) => { e.stopPropagation(); closePopup(); };
  2391. popup.appendChild(close);
  2392.  
  2393. const title = document.createElement('div');
  2394. title.className = 'gr-popup-title';
  2395. title.textContent = event.name;
  2396. popup.appendChild(title);
  2397.  
  2398. const sMs = wallTimeToUtcMs(event.start, tz);
  2399. const eMs = event.end ? wallTimeToUtcMs(event.end, tz) : null;
  2400. const s = new Date(sMs);
  2401. const e = eMs !== null ? new Date(eMs) : null;
  2402.  
  2403. const serverLabel = serverName || ((tz && tz !== 'UTC') ? tz : 'UTC');
  2404. appendPopupTimeLine(popup, tzFormatter(tz || 'UTC', FULL_DATETIME_OPTS), s, e, serverLabel);
  2405. appendPopupTimeLine(popup, new Intl.DateTimeFormat(undefined, FULL_DATETIME_OPTS), s, e, 'local time', 'gr-popup-time-local');
  2406.  
  2407. const countdown = computeCountdown(s, e);
  2408. if (countdown) {
  2409. const cd = document.createElement('div');
  2410. cd.className = 'gr-popup-countdown ' + countdown.state;
  2411. cd.textContent = countdown.text;
  2412. popup.appendChild(cd);
  2413. }
  2414.  
  2415. if (event.bg) {
  2416. const img = document.createElement('img');
  2417. img.className = 'gr-popup-img';
  2418. img.src = expandMediaUrl(event.bg);
  2419. img.alt = '';
  2420. img.onerror = () => img.remove();
  2421. popup.appendChild(img);
  2422. }
  2423.  
  2424. if (event.text) {
  2425. const text = document.createElement('div');
  2426. text.className = 'gr-popup-text';
  2427. text.innerHTML = linkifyText(event.text);
  2428. popup.appendChild(text);
  2429. }
  2430.  
  2431. document.body.appendChild(popup);
  2432. const rect = anchorEl.getBoundingClientRect();
  2433. const popupRect = popup.getBoundingClientRect();
  2434. let left = rect.left + window.scrollX;
  2435. let top = rect.bottom + window.scrollY + 4;
  2436. const maxLeft = window.scrollX + window.innerWidth - popupRect.width - 8;
  2437. if (left > maxLeft) left = maxLeft;
  2438. if (left < window.scrollX + 4) left = window.scrollX + 4;
  2439. popup.style.left = left + 'px';
  2440. popup.style.top = top + 'px';
  2441.  
  2442. activePopup = popup;
  2443. setTimeout(() => {
  2444. document.addEventListener('click', onDocClickForPopup, true);
  2445. document.addEventListener('keydown', onEscForPopup);
  2446. }, 0);
  2447. }
  2448.  
  2449. function createWidgetShell(w, bodyClass, opts) {
  2450. opts = opts || {};
  2451. const el = document.createElement('div');
  2452. el.className = 'gr-widget' + (opts.rootClass ? ' ' + opts.rootClass : '');
  2453. if (w.hideBg) el.classList.add('gr-no-bg');
  2454.  
  2455. let titleEl = null;
  2456. if (w.hideTitle) {
  2457. if (opts.keepTitleSlot) {
  2458. titleEl = document.createElement('div');
  2459. titleEl.style.display = 'none';
  2460. el.appendChild(titleEl);
  2461. }
  2462. } else if (opts.keepTitleSlot) {
  2463. titleEl = document.createElement('div');
  2464. titleEl.className = 'gr-widget-title';
  2465. titleEl.style.display = 'none'; // caller manages visibility
  2466. el.appendChild(titleEl);
  2467. } else if (w.title) {
  2468. titleEl = document.createElement('div');
  2469. titleEl.className = 'gr-widget-title';
  2470. titleEl.textContent = w.title;
  2471. el.appendChild(titleEl);
  2472. }
  2473.  
  2474. const body = document.createElement('div');
  2475. body.className = 'gr-widget-body' + (bodyClass ? ' ' + bodyClass : '');
  2476. el.appendChild(body);
  2477.  
  2478. return { el, body, titleEl };
  2479. }
  2480.  
  2481. function applyBg(el, url, mode) {
  2482. el.className = 'gr-generic-bg gr-bg-' + (mode || 'cover');
  2483. lazyBgImage(el, expandMediaUrl(url));
  2484. }
  2485. function makeBgLayer(url, mode) {
  2486. const bg = document.createElement('div');
  2487. applyBg(bg, url, mode);
  2488. return bg;
  2489. }
  2490.  
  2491. function setupHoverBg(el, hoverBgUrl, bgTarget) {
  2492. if (!hoverBgUrl) return;
  2493. const target = bgTarget || el;
  2494. const hoverUrl = expandMediaUrl(hoverBgUrl);
  2495. // Preload
  2496. const img = new Image();
  2497. img.src = hoverUrl;
  2498.  
  2499. let originalBg = '';
  2500. el.addEventListener('mouseenter', () => {
  2501. originalBg = target.style.backgroundImage;
  2502. target.style.backgroundImage = `url("${hoverUrl}")`;
  2503. });
  2504. el.addEventListener('mouseleave', () => {
  2505. target.style.backgroundImage = originalBg;
  2506. });
  2507. }
  2508.  
  2509. function setupExternalLink(a, url, opts) {
  2510. opts = opts || {};
  2511. a.href = url;
  2512. a.target = '_blank';
  2513. a.rel = 'noopener noreferrer';
  2514. a.dataset.enhanced = '1'; // force 8chanSS to ignore it
  2515. if (opts.youtube && isYouTubeUrl(url)) {
  2516. a.addEventListener('click', (e) => {
  2517. if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.button !== 0) return;
  2518. if (isYouTubeUrl(a.href)) {
  2519. e.preventDefault();
  2520. openYouTubeLightbox(a.href);
  2521. }
  2522. });
  2523. }
  2524. }
  2525.  
  2526. function makeLangBadge(code) {
  2527. const badge = document.createElement('span');
  2528. badge.className = 'gr-list-lang-badge';
  2529. badge.textContent = code.toUpperCase();
  2530. return badge;
  2531. }
  2532. function fillLangLink(link, url, hasLangs, itemLang) {
  2533. link.textContent = '';
  2534. if (hasLangs && itemLang) {
  2535. link.appendChild(makeLangBadge(itemLang));
  2536. link.appendChild(document.createTextNode(' '));
  2537. }
  2538. link.appendChild(document.createTextNode(url));
  2539. }
  2540.  
  2541. function renderGeneric(w) {
  2542. const { el, body } = createWidgetShell(w, 'gr-generic-text', { rootClass: 'gr-generic' });
  2543.  
  2544. if (w.bg) {
  2545. const bgLayer = makeBgLayer(w.bg, w.bgMode);
  2546. body.appendChild(bgLayer);
  2547. setupHoverBg(el, w.hoverBg, bgLayer);
  2548. } else if (w.hoverBg) {
  2549. setupHoverBg(el, w.hoverBg, el);
  2550. }
  2551. if (w.text) {
  2552. const textEl = document.createElement('div');
  2553. textEl.className = 'gr-generic-text-inner';
  2554. textEl.innerHTML = linkifyText(w.text);
  2555. body.appendChild(textEl);
  2556. }
  2557. return el;
  2558. }
  2559.  
  2560. function renderCountdown(w, opts) {
  2561. opts = opts || {};
  2562. const tz = opts.tz || 'UTC';
  2563. const serverName = opts.serverName || '';
  2564. const { el, body, titleEl } = createWidgetShell(w, 'gr-countdown-body', { rootClass: 'gr-countdown', keepTitleSlot: true });
  2565.  
  2566. const isLabel = w.cdStyle === 'label';
  2567. if (isLabel) el.classList.add('gr-countdown-label');
  2568.  
  2569. const bgLayer = document.createElement('div');
  2570. bgLayer.className = 'gr-generic-bg gr-bg-cover';
  2571. bgLayer.style.display = 'none';
  2572. body.appendChild(bgLayer);
  2573.  
  2574. setupHoverBg(el, w.hoverBg, bgLayer);
  2575.  
  2576. let labelTitle = null;
  2577. if (isLabel) {
  2578. labelTitle = document.createElement('div');
  2579. labelTitle.className = 'gr-countdown-label-title';
  2580. body.appendChild(labelTitle);
  2581. }
  2582.  
  2583. const timerEl = document.createElement('div');
  2584. timerEl.className = 'gr-countdown-timer';
  2585. body.appendChild(timerEl);
  2586.  
  2587. const phases = (w.phases && w.phases.length > 0)
  2588. ? w.phases
  2589. : [{ title: w.title, target: w.target, endedText: w.endedText, bg: w.bg, bgMode: w.bgMode }];
  2590. const isMultiPhase = phases.length > 1;
  2591.  
  2592. function phaseToUtcMs(target) {
  2593. if (w.rawTz != null) {
  2594. if (!target) return NaN;
  2595. const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(target);
  2596. if (!m) return NaN;
  2597. const pretend = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] || 0));
  2598. return pretend - (Number(w.rawTz) || 0) * 3600000;
  2599. }
  2600. return wallTimeToUtcMs(target, tz);
  2601. }
  2602.  
  2603. const intervalMs = (!isMultiPhase && w.isCyclic && w.cyclicMode !== 'datetime')
  2604. ? ((Number((w.interval || [])[0]) || 0) * 86400000
  2605. + (Number((w.interval || [])[1]) || 0) * 3600000
  2606. + (Number((w.interval || [])[2]) || 0) * 60000)
  2607. : 0;
  2608.  
  2609. function nextCyclicDateMs() {
  2610. const dates = w.cyclicDates;
  2611. if (!dates || !dates.length) return NaN;
  2612.  
  2613. const now = new Date();
  2614. const candidates = [];
  2615.  
  2616. for (const entry of dates) {
  2617. const day = Number(entry.day) || 1;
  2618. const [hh, mm] = (entry.time || '00:00').split(':').map(Number);
  2619.  
  2620. for (let mOff = 0; mOff <= 1; mOff++) {
  2621. const d = new Date(now.getFullYear(), now.getMonth() + mOff, day, hh, mm || 0, 0, 0);
  2622. if (d.getDate() !== day) continue;
  2623. if (d.getTime() > Date.now()) {
  2624. candidates.push(d.getTime());
  2625. break;
  2626. }
  2627. }
  2628. }
  2629.  
  2630. return candidates.length ? Math.min(...candidates) : NaN;
  2631. }
  2632.  
  2633. function resolvePhase() {
  2634. const now = Date.now();
  2635. for (const phase of phases) {
  2636. const ms = phaseToUtcMs(phase.target);
  2637. if (ms > now) return phase;
  2638. }
  2639. return phases[phases.length - 1];
  2640. }
  2641.  
  2642. let currentPhase = resolvePhase();
  2643. let targetMs = phaseToUtcMs(currentPhase.target);
  2644. let endedText = currentPhase.endedText || 'Ended';
  2645.  
  2646. function applyPhaseAppearance() {
  2647. if (w.hideTitle || isLabel) {
  2648. if (titleEl) titleEl.style.display = 'none';
  2649. if (isLabel && labelTitle) {
  2650. labelTitle.textContent = currentPhase.title || '';
  2651. labelTitle.style.display = currentPhase.title ? '' : 'none';
  2652. }
  2653. } else {
  2654. if (currentPhase.title) {
  2655. titleEl.textContent = currentPhase.title;
  2656. titleEl.style.display = '';
  2657. } else {
  2658. titleEl.style.display = 'none';
  2659. }
  2660. }
  2661. const phaseBg = currentPhase.bg || w.bg;
  2662. if (phaseBg) {
  2663. el.classList.add('has-bg');
  2664. applyBg(bgLayer, phaseBg, currentPhase.bgMode || w.bgMode);
  2665. bgLayer.style.display = '';
  2666. } else {
  2667. el.classList.remove('has-bg');
  2668. bgLayer.style.display = 'none';
  2669. }
  2670. }
  2671. applyPhaseAppearance();
  2672.  
  2673. function advanceCyclicTarget() {
  2674. if (w.cyclicMode === 'datetime' && w.isCyclic && !isMultiPhase) {
  2675. const next = nextCyclicDateMs();
  2676. if (Number.isFinite(next)) targetMs = next;
  2677. return;
  2678. }
  2679. if (intervalMs <= 0) return;
  2680. const now = Date.now();
  2681. if (targetMs > now) return;
  2682. const elapsed = now - targetMs;
  2683. const cyclesPassed = Math.floor(elapsed / intervalMs) + 1;
  2684. targetMs += cyclesPassed * intervalMs;
  2685. }
  2686. advanceCyclicTarget();
  2687.  
  2688. let intervalId = null;
  2689. let timeoutId = null;
  2690.  
  2691. let lastRenderedLen = -1;
  2692.  
  2693. const fmtTooltip = new Intl.DateTimeFormat(undefined, FULL_DATETIME_OPTS);
  2694. function updateTooltip() {
  2695. if (!Number.isFinite(targetMs)) { timerEl.removeAttribute('title'); return; }
  2696. timerEl.title = `${fmtTooltip.format(new Date(targetMs))} (local time)`;
  2697. }
  2698. let lastTooltipTarget = null;
  2699.  
  2700. function updateTimer() {
  2701. if (!Number.isFinite(targetMs)) {
  2702. timerEl.textContent = '';
  2703. timerEl.removeAttribute('title');
  2704. return;
  2705. }
  2706. let remainingMs = targetMs - Date.now();
  2707.  
  2708. if (remainingMs <= 0 && (intervalMs > 0 || (w.cyclicMode === 'datetime' && w.isCyclic))) {
  2709. advanceCyclicTarget();
  2710. remainingMs = targetMs - Date.now();
  2711. }
  2712.  
  2713. if (remainingMs <= 0 && isMultiPhase) {
  2714. const newPhase = resolvePhase();
  2715. if (newPhase !== currentPhase) {
  2716. currentPhase = newPhase;
  2717. targetMs = phaseToUtcMs(currentPhase.target);
  2718. endedText = currentPhase.endedText || 'Ended';
  2719. applyPhaseAppearance();
  2720. remainingMs = targetMs - Date.now();
  2721. }
  2722. }
  2723.  
  2724. let nextText;
  2725. if (remainingMs <= 0) {
  2726. nextText = endedText;
  2727. timerEl.classList.add('gr-countdown-ended');
  2728. } else {
  2729. timerEl.classList.remove('gr-countdown-ended');
  2730. nextText = formatCountdown(remainingMs);
  2731. }
  2732. timerEl.textContent = nextText;
  2733.  
  2734. if (targetMs !== lastTooltipTarget) {
  2735. lastTooltipTarget = targetMs;
  2736. updateTooltip();
  2737. }
  2738.  
  2739. if (nextText.length !== lastRenderedLen) {
  2740. lastRenderedLen = nextText.length;
  2741. fitTimerText();
  2742. }
  2743. }
  2744. function fitTimerText() {
  2745. timerEl.style.fontSize = '';
  2746. if (!timerEl.isConnected) return;
  2747. const avail = timerEl.clientWidth;
  2748. const needed = timerEl.scrollWidth;
  2749. if (avail <= 0 || needed <= avail) return;
  2750. const cs = window.getComputedStyle(timerEl);
  2751. const currentPx = parseFloat(cs.fontSize) || 24;
  2752. const scaled = Math.max(10, Math.floor(currentPx * (avail / needed)));
  2753. timerEl.style.fontSize = scaled + 'px';
  2754. }
  2755. updateTimer();
  2756.  
  2757. setTimeout(fitTimerText, 0);
  2758. try {
  2759. const ro = new ResizeObserver(() => fitTimerText());
  2760. ro.observe(timerEl);
  2761. } catch (e) { /* ResizeObserver missing on very old browsers โ€” skip */ }
  2762.  
  2763. function chooseCadenceMs() {
  2764. const remaining = targetMs - Date.now();
  2765. return remaining > 0 && remaining < 3600000 ? 1000 : 60000;
  2766. }
  2767.  
  2768. let currentCadenceMs = 0; // 0 = not yet scheduled
  2769. let isVisible = true;
  2770. function tick() {
  2771. if (!el.isConnected) {
  2772. if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
  2773. if (intervalId) { clearInterval(intervalId); intervalId = null; }
  2774. return;
  2775. }
  2776. if (!isVisible) return;
  2777. updateTimer();
  2778. if (chooseCadenceMs() !== currentCadenceMs) {
  2779. scheduleTicks();
  2780. }
  2781. }
  2782.  
  2783. function scheduleTicks() {
  2784. if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
  2785. if (intervalId) { clearInterval(intervalId); intervalId = null; }
  2786. currentCadenceMs = chooseCadenceMs();
  2787. const msUntilNext = currentCadenceMs - (Date.now() % currentCadenceMs);
  2788. timeoutId = setTimeout(() => {
  2789. tick();
  2790. intervalId = setInterval(tick, currentCadenceMs);
  2791. }, msUntilNext);
  2792. }
  2793. scheduleTicks();
  2794.  
  2795. observeVisibility(el,
  2796. () => {
  2797. isVisible = true;
  2798. if (el.isConnected) updateTimer();
  2799. },
  2800. () => { isVisible = false; }
  2801. );
  2802.  
  2803. return el;
  2804. }
  2805.  
  2806. // Display formats by magnitude:
  2807. // >= 24h: "12d 4h" ("12d 35m" when hour remainder is 0)
  2808. // >= 1h: "3h 45m"
  2809. // >= 1m: "15m 40s"
  2810. // < 1m: "40s"
  2811. // <= 0: "0s"
  2812. function formatCountdown(ms) {
  2813. if (ms <= 0) return '0s';
  2814. const totalSec = Math.floor(ms / 1000);
  2815. const totalMin = Math.floor(ms / 60000);
  2816. const totalHr = Math.floor(ms / 3600000);
  2817. const totalDay = Math.floor(ms / 86400000);
  2818. if (totalHr >= 24) {
  2819. const hRem = totalHr - totalDay * 24;
  2820. if (hRem === 0) {
  2821. const mRem = totalMin - totalDay * 24 * 60;
  2822. return `${totalDay}d ${String(mRem).padStart(2, '0')}m`;
  2823. }
  2824. return `${totalDay}d ${hRem}h`;
  2825. }
  2826. if (totalHr >= 1) {
  2827. const mRem = totalMin - totalHr * 60;
  2828. return `${totalHr}h ${String(mRem).padStart(2, '0')}m`;
  2829. }
  2830. if (totalMin >= 1) {
  2831. const sRem = totalSec - totalMin * 60;
  2832. return `${totalMin}m ${String(sRem).padStart(2, '0')}s`;
  2833. }
  2834. return `${totalSec}s`;
  2835. }
  2836.  
  2837. function fitTextToWidth(ctx, text, maxW) {
  2838. if (maxW <= 0 || !text) return '';
  2839. if (ctx.measureText(text).width <= maxW) return text;
  2840. const ellipsis = 'โ€ฆ';
  2841. const ellipsisW = ctx.measureText(ellipsis).width;
  2842. if (ellipsisW > maxW) return '';
  2843. let lo = 0, hi = text.length;
  2844. while (lo < hi) {
  2845. const mid = (lo + hi + 1) >> 1;
  2846. const candidate = text.slice(0, mid) + ellipsis;
  2847. if (ctx.measureText(candidate).width <= maxW) lo = mid;
  2848. else hi = mid - 1;
  2849. }
  2850. return lo === 0 ? ellipsis : text.slice(0, lo) + ellipsis;
  2851. }
  2852.  
  2853. function renderLink(w) {
  2854. const el = document.createElement('a');
  2855. el.className = 'gr-widget gr-link';
  2856. if (w.hideBg) el.classList.add('gr-no-bg');
  2857. setupExternalLink(el, w.url || '#');
  2858.  
  2859. el.addEventListener('click', (e) => {
  2860. if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.button !== 0) return;
  2861. e.preventDefault();
  2862. if (confirm(el.href)) {
  2863. window.open(el.href, '_blank', 'noopener,noreferrer');
  2864. }
  2865. });
  2866.  
  2867. const inner = document.createElement('div');
  2868. inner.className = 'gr-link-inner';
  2869. inner.textContent = w.title || w.url || '';
  2870. el.appendChild(inner);
  2871. if (w.bg) {
  2872. inner.classList.add('has-bg');
  2873. const mode = w.bgMode || 'cover';
  2874. inner.classList.add('gr-bg-' + mode);
  2875. lazyBgImage(inner, expandMediaUrl(w.bg));
  2876. setupHoverBg(el, w.hoverBg, inner);
  2877. } else if (w.hoverBg) {
  2878. setupHoverBg(el, w.hoverBg, inner);
  2879. }
  2880.  
  2881. return el;
  2882. }
  2883.  
  2884. const LIST_MODES = ['horizontal', 'vertical', 'accordion', 'carousel'];
  2885. const LIST_MODE_LABELS = {
  2886. horizontal: 'Horizontal',
  2887. vertical: 'Vertical',
  2888. accordion: 'Accordion',
  2889. carousel: 'Carousel',
  2890. };
  2891.  
  2892. function listModeStorageKey(widgetId) {
  2893. return 'grid-list-mode:' + widgetId;
  2894. }
  2895.  
  2896. function getEffectiveListMode(w) {
  2897. try {
  2898. const stored = window.localStorage.getItem(listModeStorageKey(w.id));
  2899. if (stored && LIST_MODES.includes(stored)) return stored;
  2900. } catch (e) { /* storage may be unavailable */ }
  2901. const defaultMode = w.displayMode && LIST_MODES.includes(w.displayMode) ? w.displayMode : 'horizontal';
  2902. return defaultMode;
  2903. }
  2904.  
  2905. function setListModeOverride(widgetId, mode) {
  2906. try {
  2907. window.localStorage.setItem(listModeStorageKey(widgetId), mode);
  2908. } catch (e) { /* ignore */ }
  2909. }
  2910.  
  2911. function enableDragScroll(el, axis) {
  2912. const scrollX = axis === 'x' || axis === 'both';
  2913. const scrollY = axis === 'y' || axis === 'both';
  2914. let isDown = false;
  2915. let startX, startY, scrollLeft, scrollTop;
  2916. let moved = false;
  2917. const DRAG_THRESHOLD = 5;
  2918.  
  2919. el.style.cursor = 'grab';
  2920.  
  2921. el.addEventListener('dragstart', (e) => e.preventDefault());
  2922.  
  2923. el.addEventListener('mousedown', (e) => {
  2924. if (e.button !== 0) return;
  2925. isDown = true;
  2926. moved = false;
  2927. startX = e.clientX;
  2928. startY = e.clientY;
  2929. scrollLeft = el.scrollLeft;
  2930. scrollTop = el.scrollTop;
  2931. el.style.cursor = 'grabbing';
  2932. el.style.userSelect = 'none';
  2933. });
  2934.  
  2935. document.addEventListener('mousemove', (e) => {
  2936. if (!isDown) return;
  2937. const dx = e.clientX - startX;
  2938. const dy = e.clientY - startY;
  2939. if (!moved && Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
  2940. moved = true;
  2941. if (scrollX) el.scrollLeft = scrollLeft - dx;
  2942. if (scrollY) el.scrollTop = scrollTop - dy;
  2943. });
  2944.  
  2945. document.addEventListener('mouseup', () => {
  2946. if (!isDown) return;
  2947. isDown = false;
  2948. el.style.cursor = 'grab';
  2949. el.style.userSelect = '';
  2950. });
  2951.  
  2952. el.addEventListener('click', (e) => {
  2953. if (moved) {
  2954. e.preventDefault();
  2955. e.stopPropagation();
  2956. moved = false;
  2957. }
  2958. }, true);
  2959. }
  2960.  
  2961. function langStorageKey(widgetId) {
  2962. return 'grid-list-lang:' + widgetId;
  2963. }
  2964.  
  2965. function getSelectedLang(widgetId) {
  2966. try {
  2967. return window.localStorage.getItem(langStorageKey(widgetId)) || null;
  2968. } catch { return null; }
  2969. }
  2970.  
  2971. function setSelectedLang(widgetId, lang) {
  2972. try {
  2973. if (lang) window.localStorage.setItem(langStorageKey(widgetId), lang);
  2974. else window.localStorage.removeItem(langStorageKey(widgetId));
  2975. } catch { /* ignore */ }
  2976. }
  2977.  
  2978. function collectLangs(w) {
  2979. const langSet = new Set();
  2980. (w.items || []).forEach(item => {
  2981. if (item.urls && typeof item.urls === 'object') {
  2982. Object.keys(item.urls).forEach(k => langSet.add(k));
  2983. }
  2984. });
  2985. return [...langSet];
  2986. }
  2987.  
  2988. function resolveItemUrl(item, lang) {
  2989. if (item.urls && typeof item.urls === 'object') {
  2990. const keys = Object.keys(item.urls);
  2991. if (keys.length === 0) return '';
  2992. if (lang && item.urls[lang]) return item.urls[lang];
  2993. return item.urls[keys[0]];
  2994. }
  2995. return item.url || '';
  2996. }
  2997.  
  2998. function resolveItemLang(item, lang) {
  2999. if (!item.urls || typeof item.urls !== 'object') return null;
  3000. const keys = Object.keys(item.urls);
  3001. if (keys.length === 0) return null;
  3002. if (lang && item.urls[lang]) return lang;
  3003. return keys[0];
  3004. }
  3005.  
  3006. function renderList(w) {
  3007. const { el, body } = createWidgetShell(w, 'gr-list-body');
  3008.  
  3009. // Language state
  3010. const langs = collectLangs(w);
  3011. const hasLangs = langs.length > 0;
  3012. let currentLang = hasLangs ? (getSelectedLang(w.id) || langs[0]) : null;
  3013.  
  3014. // Language switcher (only when multilingual items exist)
  3015. let langBtn = null;
  3016. if (hasLangs) {
  3017. langBtn = document.createElement('button');
  3018. langBtn.className = 'gr-list-lang-btn';
  3019. langBtn.type = 'button';
  3020. langBtn.title = 'Switch language';
  3021. langBtn.textContent = currentLang.toUpperCase();
  3022. el.appendChild(langBtn);
  3023.  
  3024. langBtn.addEventListener('click', (e) => {
  3025. e.preventDefault();
  3026. e.stopPropagation();
  3027. const idx = langs.indexOf(currentLang);
  3028. currentLang = langs[(idx + 1) % langs.length];
  3029. langBtn.textContent = currentLang.toUpperCase();
  3030. setSelectedLang(w.id, currentLang);
  3031. updateLang();
  3032. });
  3033. }
  3034.  
  3035. function updateLang() {
  3036. const items = w.items || [];
  3037.  
  3038. body.querySelectorAll('.gr-list-card[data-item-idx]').forEach(card => {
  3039. const item = items[+card.dataset.itemIdx];
  3040. if (!item) return;
  3041. const url = resolveItemUrl(item, currentLang);
  3042.  
  3043. if (card.tagName === 'A' && url) card.href = url;
  3044.  
  3045. if (!item.bg) {
  3046. const autoUrl = getAutoPreviewUrl(url);
  3047. if (autoUrl) {
  3048. card.classList.add('has-bg');
  3049. lazyBgImage(card, autoUrl);
  3050. }
  3051. }
  3052.  
  3053. const oldBadge = card.querySelector('.gr-list-lang-badge');
  3054. const itemLang = resolveItemLang(item, currentLang);
  3055. if (hasLangs && itemLang) {
  3056. if (oldBadge) {
  3057. oldBadge.textContent = itemLang.toUpperCase();
  3058. } else {
  3059. // Insert before the title
  3060. const title = card.querySelector('.gr-list-card-title');
  3061. card.insertBefore(makeLangBadge(itemLang), title);
  3062. }
  3063. } else if (oldBadge) {
  3064. oldBadge.remove();
  3065. }
  3066. });
  3067.  
  3068. body.querySelectorAll('.gr-list-acc-item[data-item-idx]').forEach(det => {
  3069. const item = items[+det.dataset.itemIdx];
  3070. if (!item) return;
  3071. const url = resolveItemUrl(item, currentLang);
  3072. const link = det.querySelector('.gr-list-acc-link');
  3073.  
  3074. if (link && url) {
  3075. link.href = url;
  3076. fillLangLink(link, url, hasLangs, resolveItemLang(item, currentLang));
  3077. }
  3078.  
  3079. const img = det.querySelector('.gr-list-acc-img');
  3080. if (!item.bg && img) {
  3081. const autoUrl = getAutoPreviewUrl(url);
  3082. if (autoUrl) img.src = autoUrl;
  3083. }
  3084. });
  3085. }
  3086.  
  3087. const cycleBtn = document.createElement('button');
  3088. cycleBtn.className = 'gr-list-cycle-btn';
  3089. cycleBtn.type = 'button';
  3090. cycleBtn.title = 'Cycle display mode';
  3091. cycleBtn.textContent = 'โŸณ';
  3092. el.appendChild(cycleBtn);
  3093.  
  3094. function applyMode(mode) {
  3095. LIST_MODES.forEach(m => el.classList.remove('gr-list-mode-' + m));
  3096. el.classList.add('gr-list-mode-' + mode);
  3097. cycleBtn.setAttribute('aria-label', 'Display mode: ' + LIST_MODE_LABELS[mode] + '. Click to cycle.');
  3098. body.innerHTML = '';
  3099. buildListBody(body, w, mode, currentLang, hasLangs);
  3100. }
  3101.  
  3102. cycleBtn.addEventListener('click', (e) => {
  3103. e.preventDefault();
  3104. e.stopPropagation();
  3105. const current = getEffectiveListMode(w);
  3106. const nextIdx = (LIST_MODES.indexOf(current) + 1) % LIST_MODES.length;
  3107. const next = LIST_MODES[nextIdx];
  3108. setListModeOverride(w.id, next);
  3109. applyMode(next);
  3110. });
  3111.  
  3112. applyMode(getEffectiveListMode(w));
  3113. return el;
  3114. }
  3115.  
  3116. function buildListBody(body, w, mode, lang, hasLangs) {
  3117. switch (mode) {
  3118. case 'horizontal':
  3119. buildListStrip(body, w, lang, hasLangs, 'gr-list-strip', 'x');
  3120. return;
  3121. case 'vertical':
  3122. buildListStrip(body, w, lang, hasLangs, 'gr-list-vstrip', 'y');
  3123. return;
  3124. case 'accordion':
  3125. buildListAccordion(body, w, lang, hasLangs);
  3126. return;
  3127. case 'carousel':
  3128. buildListCarousel(body, w, lang, hasLangs);
  3129. return;
  3130. }
  3131. }
  3132.  
  3133. function buildListStrip(body, w, lang, hasLangs, cls, axis) {
  3134. const strip = document.createElement('div');
  3135. strip.className = cls;
  3136. (w.items || []).forEach((item, idx) => {
  3137. strip.appendChild(buildListCard(item, lang, hasLangs, idx));
  3138. });
  3139. body.appendChild(strip);
  3140. enableDragScroll(strip, axis);
  3141. }
  3142.  
  3143. function buildListAccordion(body, w, lang, hasLangs) {
  3144. const list = document.createElement('div');
  3145. list.className = 'gr-list-accordion';
  3146. (w.items || []).forEach((item, idx) => {
  3147. const det = document.createElement('details');
  3148. det.className = 'gr-list-acc-item';
  3149. det.dataset.itemIdx = idx;
  3150.  
  3151. const sum = document.createElement('summary');
  3152. sum.className = 'gr-list-acc-summary';
  3153. sum.textContent = item.title || 'Untitled';
  3154. det.appendChild(sum);
  3155.  
  3156. const content = document.createElement('div');
  3157. content.className = 'gr-list-acc-content';
  3158. const accBgUrl = item.bg ? expandMediaUrl(item.bg) : getAutoPreviewUrl(resolveItemUrl(item, lang));
  3159. if (accBgUrl) {
  3160. const img = document.createElement('img');
  3161. img.className = 'gr-list-acc-img';
  3162. img.src = accBgUrl;
  3163. img.alt = '';
  3164. img.loading = 'lazy';
  3165. img.onerror = () => img.remove();
  3166. content.appendChild(img);
  3167. }
  3168. if (item.text) {
  3169. const txt = document.createElement('div');
  3170. txt.className = 'gr-list-acc-text';
  3171. txt.innerHTML = linkifyText(item.text);
  3172. content.appendChild(txt);
  3173. }
  3174. const resolvedUrl = resolveItemUrl(item, lang);
  3175. if (resolvedUrl) {
  3176. const link = document.createElement('a');
  3177. link.className = 'gr-list-acc-link';
  3178. setupExternalLink(link, resolvedUrl, { youtube: true });
  3179. fillLangLink(link, resolvedUrl, hasLangs, resolveItemLang(item, lang));
  3180. content.appendChild(link);
  3181. }
  3182. det.appendChild(content);
  3183. list.appendChild(det);
  3184. });
  3185. body.appendChild(list);
  3186. enableDragScroll(list, 'y');
  3187. }
  3188.  
  3189. function makeCarouselShell(count, renderAt) {
  3190. const root = document.createElement('div');
  3191. root.className = 'gr-list-carousel';
  3192. root.tabIndex = 0;
  3193.  
  3194. if (count <= 0) {
  3195. const empty = document.createElement('div');
  3196. empty.className = 'gr-list-car-empty';
  3197. empty.textContent = '(no items)';
  3198. root.appendChild(empty);
  3199. return root;
  3200. }
  3201.  
  3202. let idx = 0;
  3203.  
  3204. const prev = document.createElement('button');
  3205. prev.className = 'gr-list-car-btn gr-list-car-prev';
  3206. prev.type = 'button';
  3207. prev.setAttribute('aria-label', 'Previous item');
  3208. prev.textContent = 'โ—€';
  3209.  
  3210. const slot = document.createElement('div');
  3211. slot.className = 'gr-list-car-slot';
  3212.  
  3213. const next = document.createElement('button');
  3214. next.className = 'gr-list-car-btn gr-list-car-next';
  3215. next.type = 'button';
  3216. next.setAttribute('aria-label', 'Next item');
  3217. next.textContent = 'โ–ถ';
  3218.  
  3219. const counter = document.createElement('div');
  3220. counter.className = 'gr-list-car-counter';
  3221.  
  3222. function render() {
  3223. if (idx < 0) idx = count - 1;
  3224. if (idx >= count) idx = 0;
  3225. renderAt(slot, idx);
  3226. counter.textContent = `${idx + 1} / ${count}`;
  3227. }
  3228.  
  3229. prev.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); idx--; render(); root.focus(); });
  3230. next.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); idx++; render(); root.focus(); });
  3231.  
  3232. root.addEventListener('keydown', (e) => {
  3233. if (e.key === 'ArrowLeft') { e.preventDefault(); idx--; render(); }
  3234. else if (e.key === 'ArrowRight') { e.preventDefault(); idx++; render(); }
  3235. });
  3236.  
  3237. let tStartX = 0, tStartY = 0, tActive = false;
  3238. slot.addEventListener('touchstart', (e) => {
  3239. if (e.touches.length !== 1) return;
  3240. tActive = true;
  3241. tStartX = e.touches[0].clientX;
  3242. tStartY = e.touches[0].clientY;
  3243. }, { passive: true });
  3244. slot.addEventListener('touchend', (e) => {
  3245. if (!tActive || e.changedTouches.length !== 1) return;
  3246. tActive = false;
  3247. const dx = e.changedTouches[0].clientX - tStartX;
  3248. const dy = e.changedTouches[0].clientY - tStartY;
  3249. if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) {
  3250. e.preventDefault();
  3251. if (dx < 0) idx++; else idx--;
  3252. render();
  3253. }
  3254. }, { passive: false });
  3255.  
  3256. root.appendChild(slot);
  3257. if (count > 1) {
  3258. root.appendChild(prev);
  3259. root.appendChild(next);
  3260. root.appendChild(counter);
  3261. }
  3262.  
  3263. render();
  3264. return root;
  3265. }
  3266.  
  3267. function buildListCarousel(body, w, lang, hasLangs) {
  3268. const items = w.items || [];
  3269. const shell = makeCarouselShell(items.length, (slot, idx) => {
  3270. slot.innerHTML = '';
  3271. slot.appendChild(buildListCard(items[idx], lang, hasLangs, idx));
  3272. });
  3273. body.appendChild(shell);
  3274. }
  3275.  
  3276. function getAutoPreviewUrl(url) {
  3277. if (!url) return null;
  3278. const ytId = extractYouTubeId(url);
  3279. if (ytId) return `https://i.ytimg.com/vi_webp/${ytId}/maxresdefault.webp`;
  3280. return null;
  3281. }
  3282.  
  3283. function buildListCard(item, lang, hasLangs, itemIdx) {
  3284. const resolvedUrl = resolveItemUrl(item, lang);
  3285. const card = document.createElement(resolvedUrl ? 'a' : 'div');
  3286. card.className = 'gr-list-card';
  3287. card.dataset.itemIdx = itemIdx;
  3288. if (resolvedUrl) {
  3289. setupExternalLink(card, resolvedUrl, { youtube: true });
  3290. }
  3291. const bgUrl = item.bg ? expandMediaUrl(item.bg) : getAutoPreviewUrl(resolvedUrl);
  3292. if (bgUrl) {
  3293. card.classList.add('has-bg');
  3294. lazyBgImage(card, bgUrl);
  3295. }
  3296. if (isYouTubeUrl(resolvedUrl)) {
  3297. const play = document.createElement('div');
  3298. play.className = 'gr-list-yt-play';
  3299. play.innerHTML = YT_PLAY_SVG;
  3300. card.appendChild(play);
  3301. }
  3302. // Language badge
  3303. const itemLang = resolveItemLang(item, lang);
  3304. if (hasLangs && itemLang) {
  3305. card.appendChild(makeLangBadge(itemLang));
  3306. }
  3307. const t = document.createElement('div');
  3308. t.className = 'gr-list-card-title';
  3309. t.textContent = item.title || '';
  3310. card.appendChild(t);
  3311. return card;
  3312. }
  3313.  
  3314. function isYouTubeUrl(url) {
  3315. if (!url) return false;
  3316. return /(?:^|\.)youtube\.com|(?:^|\/)youtu\.be/i.test(url);
  3317. }
  3318.  
  3319. const HASH_PATTERN = /^[0-9a-f]{64}(?:\.[a-z0-9]+)?$/i;
  3320. function expandMediaUrl(stored) {
  3321. if (!stored) return stored;
  3322. if (HASH_PATTERN.test(stored)) {
  3323. return window.location.origin + '/.media/' + stored;
  3324. }
  3325. return stored;
  3326. }
  3327.  
  3328. function extractYouTubeId(url) {
  3329. if (!url) return null;
  3330. try {
  3331. const u = new URL(url);
  3332. const host = u.hostname.toLowerCase();
  3333. // youtu.be/ID (short form)
  3334. if (/(?:^|\.)youtu\.be$/.test(host)) {
  3335. const id = u.pathname.replace(/^\/+/, '').split('/')[0];
  3336. return id || null;
  3337. }
  3338. if (/(?:^|\.)youtube\.com$/.test(host)) {
  3339. // /watch?v=ID
  3340. const v = u.searchParams.get('v');
  3341. if (v) return v;
  3342. // /embed/ID, /shorts/ID, /v/ID, /live/ID
  3343. const m = u.pathname.match(/^\/(?:embed|shorts|v|live)\/([^/?#]+)/);
  3344. if (m) return m[1];
  3345. }
  3346. } catch (e) { /* bad URL */ }
  3347. return null;
  3348. }
  3349.  
  3350. function extractYouTubeStart(url) {
  3351. try {
  3352. const u = new URL(url);
  3353. const raw = u.searchParams.get('t') || u.searchParams.get('start') || '';
  3354. if (!raw) return 0;
  3355. if (/^\d+$/.test(raw)) return parseInt(raw, 10);
  3356. let total = 0;
  3357. const m = raw.match(/(\d+h)?(\d+m)?(\d+s)?/i);
  3358. if (m) {
  3359. if (m[1]) total += parseInt(m[1], 10) * 3600;
  3360. if (m[2]) total += parseInt(m[2], 10) * 60;
  3361. if (m[3]) total += parseInt(m[3], 10);
  3362. }
  3363. return total;
  3364. } catch (e) { return 0; }
  3365. }
  3366.  
  3367. function openYouTubeLightbox(url) {
  3368. const id = extractYouTubeId(url);
  3369. if (!id) {
  3370. try { window.open(url, '_blank', 'noopener'); } catch (e) { }
  3371. return;
  3372. }
  3373. const start = extractYouTubeStart(url);
  3374. const params = new URLSearchParams({
  3375. autoplay: '1',
  3376. rel: '0',
  3377. modestbranding: '1',
  3378. });
  3379. if (start > 0) params.set('start', String(start));
  3380. const embedUrl = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}?${params.toString()}`;
  3381. openLightboxIframe(embedUrl);
  3382. }
  3383.  
  3384. function renderAttachments(w, opts) {
  3385. opts = opts || {};
  3386. const { el, body } = createWidgetShell(w, 'gr-att-body');
  3387.  
  3388. if (opts.placeholder) {
  3389. const count = opts.placeholderCount || 5;
  3390. const shell = makeCarouselShell(count, (slot, idx) => {
  3391. slot.innerHTML = '';
  3392. const tile = document.createElement('div');
  3393. tile.className = 'gr-att-tile gr-att-placeholder';
  3394. tile.textContent = `Attachment ${idx + 1}`;
  3395. slot.appendChild(tile);
  3396. });
  3397. body.appendChild(shell);
  3398. return el;
  3399. }
  3400.  
  3401. const postEl = opts.postEl;
  3402. const panel = postEl ? postEl.querySelector('.opUploadPanel') : null;
  3403. const cellsNodeList = panel ? panel.querySelectorAll('.uploadCell') : [];
  3404. const cells = Array.from(cellsNodeList);
  3405.  
  3406. if (cells.length === 0) {
  3407. const empty = document.createElement('div');
  3408. empty.className = 'gr-att-empty';
  3409. empty.textContent = 'no attachments';
  3410. body.appendChild(empty);
  3411. return el;
  3412. }
  3413.  
  3414. if (postEl) {
  3415. let moved = state.movedAttachments.get(postEl);
  3416. if (!moved) { moved = []; state.movedAttachments.set(postEl, moved); }
  3417. cells.forEach(cell => moved.push({ cell, originalParent: cell.parentNode }));
  3418. }
  3419.  
  3420. cells.forEach(cell => {
  3421. cell.querySelectorAll('details').forEach(d => { d.open = true; });
  3422. cell.querySelectorAll('a.imgLink img').forEach(img => { img.style.display = ''; });
  3423. cell.querySelectorAll('a.imgLink').forEach(a => { a.style.display = ''; });
  3424. cell.querySelectorAll('.imgExpanded, .hideLink').forEach(n => n.remove());
  3425. cell.querySelectorAll('video').forEach(v => {
  3426. try { v.pause(); } catch (e) { }
  3427. v.remove();
  3428. });
  3429. });
  3430.  
  3431. const pool = document.createElement('div');
  3432. pool.className = 'gr-att-pool';
  3433. pool.style.display = 'none';
  3434. cells.forEach(cell => pool.appendChild(cell));
  3435. body.appendChild(pool);
  3436.  
  3437. let currentCell = null;
  3438. const shell = makeCarouselShell(cells.length, (slot, idx) => {
  3439. if (currentCell) pool.appendChild(currentCell);
  3440. currentCell = cells[idx];
  3441. slot.appendChild(currentCell);
  3442. });
  3443. shell.addEventListener('click', onAttachmentStripClick, true);
  3444. body.appendChild(shell);
  3445.  
  3446. if (panel) {
  3447. panel.style.display = 'none';
  3448. panel.dataset.grHidden = '1';
  3449. }
  3450.  
  3451. return el;
  3452. }
  3453.  
  3454. function onAttachmentStripClick(e) {
  3455. const a = e.target.closest && e.target.closest('a.imgLink');
  3456. if (!a) return;
  3457. const href = a.getAttribute('href');
  3458. if (!href) return;
  3459. const mime = a.getAttribute('data-filemime') || '';
  3460. e.preventDefault();
  3461. e.stopPropagation();
  3462. e.stopImmediatePropagation();
  3463. openLightbox(href, mime);
  3464. }
  3465.  
  3466. function renderThreadContent(w, opts) {
  3467. opts = opts || {};
  3468. const el = document.createElement('div');
  3469. el.className = 'gr-thread-content';
  3470.  
  3471. if (opts.placeholder) {
  3472. el.classList.add('gr-widget');
  3473. el.innerHTML = '<div class="gr-widget-body"><div class="gr-content-placeholder">Thread content will appear here</div></div>';
  3474. return el;
  3475. }
  3476.  
  3477. if (opts.contentFragment) {
  3478. el.appendChild(opts.contentFragment);
  3479. }
  3480. return el;
  3481. }
  3482.  
  3483. function openLightbox(url, mime) {
  3484. let media;
  3485. if (/^video\//i.test(mime)) {
  3486. media = document.createElement('video');
  3487. media.src = url;
  3488. media.controls = true;
  3489. media.autoplay = true;
  3490. media.loop = true;
  3491. } else {
  3492. media = document.createElement('img');
  3493. media.src = url;
  3494. media.alt = '';
  3495. }
  3496. media.className = 'gr-lightbox-media';
  3497. openLightboxWithContent(media);
  3498. }
  3499.  
  3500. function openLightboxIframe(embedUrl) {
  3501. const iframe = document.createElement('iframe');
  3502. iframe.className = 'gr-lightbox-media gr-lightbox-iframe';
  3503. iframe.src = embedUrl;
  3504. iframe.setAttribute('frameborder', '0');
  3505. iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
  3506. iframe.setAttribute('allowfullscreen', '');
  3507. openLightboxWithContent(iframe);
  3508. }
  3509.  
  3510. function openLightboxWithContent(contentEl) {
  3511. closeLightbox();
  3512.  
  3513. const backdrop = document.createElement('div');
  3514. backdrop.className = 'gr-lightbox-backdrop';
  3515.  
  3516. const frame = document.createElement('div');
  3517. frame.className = 'gr-lightbox-frame';
  3518. frame.addEventListener('click', (e) => e.stopPropagation());
  3519. frame.appendChild(contentEl);
  3520.  
  3521. const close = document.createElement('button');
  3522. close.type = 'button';
  3523. close.className = 'gr-lightbox-close';
  3524. close.setAttribute('aria-label', 'Close');
  3525. close.textContent = 'โœ•';
  3526. close.addEventListener('click', (e) => { e.stopPropagation(); closeLightbox(); });
  3527. frame.appendChild(close);
  3528.  
  3529. backdrop.appendChild(frame);
  3530. backdrop.addEventListener('click', closeLightbox);
  3531. state.lightboxKeyHandler = (ev) => { if (ev.key === 'Escape') closeLightbox(); };
  3532. document.addEventListener('keydown', state.lightboxKeyHandler);
  3533.  
  3534. document.body.appendChild(backdrop);
  3535. state.lightboxEl = backdrop;
  3536. }
  3537.  
  3538. function closeLightbox() {
  3539. if (state.lightboxEl) {
  3540. state.lightboxEl.remove();
  3541. state.lightboxEl = null;
  3542. }
  3543. if (state.lightboxKeyHandler) {
  3544. document.removeEventListener('keydown', state.lightboxKeyHandler);
  3545. state.lightboxKeyHandler = null;
  3546. }
  3547. }
  3548.  
  3549. const YT_PLAY_SVG = `<svg viewBox="0 0 68 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
  3550. <path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="#f00"/>
  3551. <path d="M45 24L27 14v20z" fill="#fff"/>
  3552. </svg>`;
  3553.  
  3554. function renderTimeline(w, opts) {
  3555. opts = opts || {};
  3556. const { el, body } = createWidgetShell(w);
  3557.  
  3558. const events = (w.events || []).slice();
  3559. if (events.length === 0) {
  3560. body.textContent = '(no events)';
  3561. return el;
  3562. }
  3563.  
  3564. let maxLane = 0;
  3565. events.forEach(ev => { if ((ev.lane || 0) > maxLane) maxLane = ev.lane; });
  3566. if (isMobile()) {
  3567. const HEADER_RESERVE = 40;
  3568. const PER_LANE = 28;
  3569. const neededHeight = HEADER_RESERVE + (maxLane + 1) * PER_LANE + 8;
  3570. body.style.minHeight = neededHeight + 'px';
  3571. }
  3572.  
  3573. setupCanvasTimeline(body, events, opts);
  3574. return el;
  3575. }
  3576.  
  3577. const tzFmtCache = new Map();
  3578. function tzFormatter(tz, opts) {
  3579. const key = (tz || 'UTC') + ':' + JSON.stringify(opts);
  3580. let f = tzFmtCache.get(key);
  3581. if (!f) {
  3582. f = new Intl.DateTimeFormat(undefined, Object.assign({ timeZone: tz || 'UTC' }, opts));
  3583. tzFmtCache.set(key, f);
  3584. }
  3585. return f;
  3586. }
  3587. function tzParts(date, tz) {
  3588. const parts = tzFormatter(tz, {
  3589. year: 'numeric', month: '2-digit', day: '2-digit',
  3590. hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
  3591. }).formatToParts(date).reduce((o, p) => { o[p.type] = p.value; return o; }, {});
  3592. const hh = parts.hour === '24' ? '00' : parts.hour;
  3593. return { y: +parts.year, m: +parts.month, d: +parts.day, h: +hh, mi: +parts.minute, s: +parts.second };
  3594. }
  3595. function tzOffsetMs(date, tz) {
  3596. if (!tz || tz === 'UTC') return 0;
  3597. const p = tzParts(date, tz);
  3598. const asIfUtc = Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s);
  3599. return asIfUtc - date.getTime();
  3600. }
  3601. function forEachLocalMidnight(startMs, endMs, cb) {
  3602. const start = new Date(startMs);
  3603. start.setHours(0, 0, 0, 0); // local midnight
  3604. let ms = start.getTime();
  3605. while (ms > startMs) ms -= 86400000; // step back if we overshot
  3606. const d = new Date(ms);
  3607. while (d.getTime() <= endMs) {
  3608. cb(d.getTime(), { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() });
  3609. d.setDate(d.getDate() + 1);
  3610. d.setHours(0, 0, 0, 0);
  3611. }
  3612. }
  3613.  
  3614. const ENGLISH_MONTH_FMT = new Intl.DateTimeFormat('en', { month: 'short' });
  3615.  
  3616. function wallTimeToUtcMs(wall, tz) {
  3617. if (!wall) return NaN;
  3618. const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(wall);
  3619. if (!m) return NaN;
  3620. const y = +m[1], mo = +m[2], d = +m[3], h = +m[4], mi = +m[5], s = +(m[6] || 0);
  3621. const pretend = Date.UTC(y, mo - 1, d, h, mi, s);
  3622. const offset = tzOffsetMs(new Date(pretend), tz || 'UTC');
  3623. return pretend - offset;
  3624. }
  3625.  
  3626. function setupCanvasTimeline(container, events, opts) {
  3627. opts = opts || {};
  3628. const interactive = opts.interactive !== false;
  3629. const tz = opts.tz || 'UTC';
  3630. const serverName = opts.serverName || '';
  3631. const wrap = document.createElement('div');
  3632. wrap.className = 'gr-timeline-canvas-wrap';
  3633. const canvas = document.createElement('canvas');
  3634. canvas.className = 'gr-timeline-canvas';
  3635. wrap.appendChild(canvas);
  3636. container.appendChild(wrap);
  3637.  
  3638. let ctx;
  3639. try { ctx = canvas.getContext('2d'); }
  3640. catch (e) { ctx = null; }
  3641. if (!ctx) {
  3642. wrap.removeChild(canvas);
  3643. const notice = document.createElement('div');
  3644. notice.className = 'gr-timeline-noctx';
  3645. notice.innerHTML =
  3646. '<strong>Timeline unavailable.</strong><br>' +
  3647. 'Your browser is blocking canvas access (anti-fingerprinting / privacy mode). ' +
  3648. 'Allow canvas for this site to see the timeline.';
  3649. wrap.appendChild(notice);
  3650. return;
  3651. }
  3652.  
  3653. const DPR = window.devicePixelRatio || 1;
  3654.  
  3655. const now = Date.now();
  3656. const DAY_MS = 86400000;
  3657. const VIEW_SPAN_DAYS_DESKTOP = 40;
  3658. const VIEW_SPAN_DAYS_MOBILE = 20;
  3659.  
  3660. const view = {
  3661. centerMs: now,
  3662. spanMs: (isMobile() ? VIEW_SPAN_DAYS_MOBILE : VIEW_SPAN_DAYS_DESKTOP) * DAY_MS,
  3663. };
  3664.  
  3665. const MONTH_PAD_MS = 30 * DAY_MS;
  3666. const boundMin = (function () {
  3667. let min = Infinity;
  3668. events.forEach(ev => {
  3669. const s = wallTimeToUtcMs(ev.start, tz);
  3670. if (Number.isFinite(s) && s < min) min = s;
  3671. });
  3672. return (min === Infinity ? now : min) - MONTH_PAD_MS;
  3673. })();
  3674. const boundMax = (function () {
  3675. let max = -Infinity;
  3676. events.forEach(ev => {
  3677. const s = wallTimeToUtcMs(ev.start, tz);
  3678. const e = ev.end ? wallTimeToUtcMs(ev.end, tz) : s;
  3679. if (Number.isFinite(e) && e > max) max = e;
  3680. });
  3681. return (max === -Infinity ? now : max) + MONTH_PAD_MS;
  3682. })();
  3683.  
  3684. function clampCenter() {
  3685. const halfSpan = view.spanMs / 2;
  3686. if (boundMax - boundMin < view.spanMs) {
  3687. view.centerMs = (boundMin + boundMax) / 2;
  3688. return;
  3689. }
  3690. const minCenter = boundMin + halfSpan;
  3691. const maxCenter = boundMax - halfSpan;
  3692. if (view.centerMs < minCenter) view.centerMs = minCenter;
  3693. else if (view.centerMs > maxCenter) view.centerMs = maxCenter;
  3694. }
  3695. clampCenter();
  3696.  
  3697. const LANE_HEIGHT = 26;
  3698. const LANE_PAD = 2;
  3699. const HEADER_H = 32;
  3700.  
  3701. const imgCache = new Map();
  3702. let imgPreloadStarted = false;
  3703. function preloadEventImages() {
  3704. if (imgPreloadStarted) return;
  3705. imgPreloadStarted = true;
  3706. events.forEach(ev => {
  3707. if (ev.bg && !imgCache.has(ev.bg)) {
  3708. const img = new Image();
  3709. img.crossOrigin = 'anonymous';
  3710. img.onload = () => { imgCache.set(ev.bg, img); scheduleDraw(); };
  3711. img.onerror = () => { imgCache.set(ev.bg, null); };
  3712. img.src = expandMediaUrl(ev.bg);
  3713. imgCache.set(ev.bg, 'loading');
  3714. }
  3715. });
  3716. }
  3717.  
  3718. function timeToX(t, w) {
  3719. const startMs = view.centerMs - view.spanMs / 2;
  3720. return ((t - startMs) / view.spanMs) * w;
  3721. }
  3722. function xToTime(x, w) {
  3723. const startMs = view.centerMs - view.spanMs / 2;
  3724. return startMs + (x / w) * view.spanMs;
  3725. }
  3726.  
  3727. let pendingDraw = false;
  3728. let tlVisible = true;
  3729. function scheduleDraw() {
  3730. if (pendingDraw) return;
  3731. if (!tlVisible) return; // skip while offscreen
  3732. pendingDraw = true;
  3733. requestAnimationFrame(() => { pendingDraw = false; draw(); });
  3734. }
  3735.  
  3736. let eventRects = [];
  3737.  
  3738. function contentHeight() {
  3739. let ml = 0;
  3740. events.forEach(ev => { if ((ev.lane || 0) > ml) ml = ev.lane; });
  3741. return HEADER_H + (ml + 1) * (LANE_HEIGHT + LANE_PAD) + 6;
  3742. }
  3743.  
  3744. function draw() {
  3745. const rect = wrap.getBoundingClientRect();
  3746. const W = Math.max(200, rect.width);
  3747. const viewH = Math.max(100, rect.height);
  3748. const H = Math.max(viewH, contentHeight());
  3749. canvas.width = W * DPR;
  3750. canvas.height = H * DPR;
  3751. canvas.style.width = W + 'px';
  3752. canvas.style.height = H + 'px';
  3753. ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  3754.  
  3755. const bgColor = getCssVar('--menu-color', '#eef2ff');
  3756. ctx.fillStyle = bgColor;
  3757. ctx.fillRect(0, 0, W, H);
  3758.  
  3759. const textColor = getCssVar('--text-color', '#000');
  3760. const borderColor = getCssVar('--border-color', '#000');
  3761. const sepColor = getCssVar('--border-color', '#b7c5d9');
  3762.  
  3763. const startMs = view.centerMs - view.spanMs / 2;
  3764. const endMs = view.centerMs + view.spanMs / 2;
  3765. ctx.font = '9pt Arial, sans-serif';
  3766. ctx.textBaseline = 'top';
  3767.  
  3768. forEachLocalMidnight(startMs, endMs, (t, p) => {
  3769. if (t < startMs - 86400000 || t > endMs + 86400000) return;
  3770. const x = timeToX(t, W);
  3771. const isMonthStart = p.d === 1;
  3772. ctx.strokeStyle = isMonthStart ? borderColor : sepColor;
  3773. ctx.lineWidth = isMonthStart ? 1.5 : 0.5;
  3774. ctx.beginPath();
  3775. ctx.moveTo(x + 0.5, 0);
  3776. ctx.lineTo(x + 0.5, H);
  3777. ctx.stroke();
  3778.  
  3779. ctx.fillStyle = textColor;
  3780. ctx.fillText(String(p.d), x + 2, 14);
  3781. if (isMonthStart) {
  3782. ctx.font = 'bold 10pt Arial, sans-serif';
  3783. const monthStr = ENGLISH_MONTH_FMT.format(new Date(t));
  3784. ctx.fillText(monthStr, x + 2, 0);
  3785. ctx.font = '9pt Arial, sans-serif';
  3786. }
  3787. });
  3788.  
  3789. let maxLane = 0;
  3790. events.forEach(ev => { if ((ev.lane || 0) > maxLane) maxLane = ev.lane; });
  3791.  
  3792. eventRects = [];
  3793. events.forEach(ev => {
  3794. const sMs = wallTimeToUtcMs(ev.start, tz);
  3795. const eMs = ev.end ? wallTimeToUtcMs(ev.end, tz) : sMs + DAY_MS * 0.5;
  3796. if (eMs < startMs || sMs > endMs) return;
  3797. const x1 = timeToX(sMs, W);
  3798. const x2 = timeToX(eMs, W);
  3799. const lane = ev.lane || 0;
  3800. const y = HEADER_H + lane * (LANE_HEIGHT + LANE_PAD);
  3801. const barH = LANE_HEIGHT;
  3802. const barW = Math.max(2, x2 - x1);
  3803.  
  3804. ctx.fillStyle = ev.color || '#aaa';
  3805. ctx.fillRect(x1, y, barW, barH);
  3806.  
  3807. const img = ev.bg ? imgCache.get(ev.bg) : null;
  3808. if (img && img !== 'loading' && img instanceof HTMLImageElement) {
  3809. ctx.save();
  3810. ctx.beginPath();
  3811. ctx.rect(x1, y, barW, barH);
  3812. ctx.clip();
  3813. const imgH = barH;
  3814. const imgW = (img.width / img.height) * imgH;
  3815. ctx.globalAlpha = 0.9;
  3816. ctx.drawImage(img, x1 + barW - imgW, y, imgW, imgH);
  3817. ctx.globalAlpha = 1.0;
  3818. ctx.restore();
  3819. }
  3820.  
  3821. ctx.strokeStyle = borderColor;
  3822. ctx.lineWidth = 1;
  3823. ctx.strokeRect(x1 + 0.5, y + 0.5, barW, barH);
  3824.  
  3825. ctx.fillStyle = '#000';
  3826. ctx.font = 'bold 9pt Arial, sans-serif';
  3827. ctx.textBaseline = 'middle';
  3828. ctx.save();
  3829. ctx.beginPath();
  3830. ctx.rect(x1 + 4, y, barW - 8, barH);
  3831. ctx.clip();
  3832. const padL = 6, padR = 4;
  3833. const naturalLeft = x1 + padL;
  3834. const viewLeft = padL;
  3835. const textLeft = Math.max(naturalLeft, viewLeft);
  3836. const textRight = x1 + barW - padR;
  3837. const maxTextW = Math.max(0, textRight - textLeft);
  3838. ctx.fillText(fitTextToWidth(ctx, ev.name, maxTextW), textLeft, y + barH / 2);
  3839. ctx.restore();
  3840. ctx.textBaseline = 'top';
  3841.  
  3842. eventRects.push({ x: x1, y, w: barW, h: barH, event: ev });
  3843. });
  3844.  
  3845. const nowX = timeToX(Date.now(), W);
  3846. if (nowX >= 0 && nowX <= W) {
  3847. ctx.strokeStyle = getCssVar('--alert-color', 'red');
  3848. ctx.lineWidth = 1;
  3849. ctx.beginPath();
  3850. ctx.moveTo(nowX + 0.5, 0);
  3851. ctx.lineTo(nowX + 0.5, H);
  3852. ctx.stroke();
  3853. ctx.fillStyle = getCssVar('--alert-color', 'red');
  3854. ctx.font = 'bold 9pt Arial, sans-serif';
  3855. const label = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date());
  3856. const lw = ctx.measureText(label).width + 8;
  3857. ctx.fillRect(nowX - lw / 2, 0, lw, 14);
  3858. ctx.fillStyle = '#fff';
  3859. ctx.fillText(label, nowX - lw / 2 + 4, 1);
  3860. }
  3861. }
  3862.  
  3863. const DRAG_THRESHOLD = 5;
  3864. let pressed = false;
  3865. let dragging = false;
  3866. let pressStartX = 0, pressStartY = 0, pressStartCenter = 0, pressStartScrollTop = 0;
  3867. let lastClientX = 0;
  3868.  
  3869. function tryHitEvent(clientX, clientY) {
  3870. const rect = canvas.getBoundingClientRect();
  3871. const x = clientX - rect.left;
  3872. const y = clientY - rect.top;
  3873. for (let i = eventRects.length - 1; i >= 0; i--) {
  3874. const r = eventRects[i];
  3875. if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
  3876. return r.event;
  3877. }
  3878. }
  3879. return null;
  3880. }
  3881.  
  3882. function openPopupAt(clientX, clientY, ev) {
  3883. const anchor = document.createElement('div');
  3884. anchor.style.cssText = `position:absolute;left:${clientX + window.scrollX}px;top:${clientY + window.scrollY}px;width:1px;height:1px;`;
  3885. document.body.appendChild(anchor);
  3886. showEventPopup(anchor, ev, tz, serverName);
  3887. setTimeout(() => anchor.remove(), 100);
  3888. }
  3889.  
  3890. canvas.addEventListener('mousedown', (e) => {
  3891. if (!interactive) return;
  3892. if (e.button !== 0) return;
  3893. pressed = true;
  3894. dragging = false;
  3895. pressStartX = e.clientX;
  3896. pressStartY = e.clientY;
  3897. pressStartCenter = view.centerMs;
  3898. pressStartScrollTop = wrap.scrollTop;
  3899. lastClientX = e.clientX;
  3900. });
  3901. window.addEventListener('mousemove', (e) => {
  3902. if (!pressed) return;
  3903. const dx = e.clientX - pressStartX;
  3904. const dy = e.clientY - pressStartY;
  3905. if (!dragging && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
  3906. dragging = true;
  3907. canvas.classList.add('dragging');
  3908. }
  3909. if (dragging) {
  3910. const W = canvas.clientWidth || 600;
  3911. view.centerMs = pressStartCenter - (dx / W) * view.spanMs;
  3912. clampCenter();
  3913. wrap.scrollTop = pressStartScrollTop - dy;
  3914. scheduleDraw();
  3915. }
  3916. lastClientX = e.clientX;
  3917. });
  3918. window.addEventListener('mouseup', (e) => {
  3919. if (!pressed) return;
  3920. const wasDragging = dragging;
  3921. pressed = false;
  3922. dragging = false;
  3923. canvas.classList.remove('dragging');
  3924. if (!wasDragging) {
  3925. const ev = tryHitEvent(e.clientX, e.clientY);
  3926. if (ev) openPopupAt(e.clientX, e.clientY, ev);
  3927. }
  3928. });
  3929.  
  3930. let touchPressed = false, touchDragging = false, touchAxisDecided = false, touchAxisX = false;
  3931. let touchStartX = 0, touchStartY = 0, touchStartCenter = 0, touchStartScrollTop = 0;
  3932. canvas.addEventListener('touchstart', (e) => {
  3933. if (!interactive) return;
  3934. if (e.touches.length !== 1) return;
  3935. touchPressed = true;
  3936. touchDragging = false;
  3937. touchAxisDecided = false;
  3938. touchAxisX = false;
  3939. touchStartX = e.touches[0].clientX;
  3940. touchStartY = e.touches[0].clientY;
  3941. touchStartCenter = view.centerMs;
  3942. touchStartScrollTop = wrap.scrollTop;
  3943. }, { passive: true });
  3944. canvas.addEventListener('touchmove', (e) => {
  3945. if (!touchPressed || e.touches.length !== 1) return;
  3946. const dx = e.touches[0].clientX - touchStartX;
  3947. const dy = e.touches[0].clientY - touchStartY;
  3948. if (!touchAxisDecided && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
  3949. touchAxisDecided = true;
  3950. touchAxisX = Math.abs(dx) > Math.abs(dy);
  3951. touchDragging = true;
  3952. }
  3953. if (touchDragging && touchAxisX) {
  3954. const W = canvas.clientWidth || 600;
  3955. view.centerMs = touchStartCenter - (dx / W) * view.spanMs;
  3956. clampCenter();
  3957. wrap.scrollTop = touchStartScrollTop - dy;
  3958. scheduleDraw();
  3959. e.preventDefault();
  3960. }
  3961. }, { passive: false });
  3962. canvas.addEventListener('touchend', (e) => {
  3963. if (!touchPressed) return;
  3964. const wasDragging = touchDragging && touchAxisX;
  3965. touchPressed = false;
  3966. touchDragging = false;
  3967. if (!wasDragging && !touchAxisDecided && e.changedTouches.length === 1) {
  3968. const t = e.changedTouches[0];
  3969. const ev = tryHitEvent(t.clientX, t.clientY);
  3970. if (ev) openPopupAt(t.clientX, t.clientY, ev);
  3971. }
  3972. });
  3973. canvas.addEventListener('touchcancel', () => {
  3974. touchPressed = false; touchDragging = false;
  3975. });
  3976.  
  3977. const ro = new ResizeObserver(() => scheduleDraw());
  3978. ro.observe(wrap);
  3979.  
  3980. if (interactive) {
  3981. function scheduleMinuteTick() {
  3982. const msUntilNextMinute = 60000 - (Date.now() % 60000);
  3983. setTimeout(() => {
  3984. scheduleDraw();
  3985. setInterval(() => scheduleDraw(), 60000);
  3986. }, msUntilNextMinute);
  3987. }
  3988. scheduleMinuteTick();
  3989.  
  3990. observeVisibility(wrap,
  3991. () => { tlVisible = true; preloadEventImages(); scheduleDraw(); },
  3992. () => { tlVisible = false; }
  3993. );
  3994. }
  3995.  
  3996. requestAnimationFrame(() => scheduleDraw());
  3997. }
  3998.  
  3999. function getCssVar(name, fallback) {
  4000. const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
  4001. if (!v) return fallback;
  4002. // Resolve light-dark(lightVal, darkVal)
  4003. const ld = v.match(/^light-dark\(\s*(.+?)\s*,\s*(.+?)\s*\)$/i);
  4004. if (ld) {
  4005. const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
  4006. || document.documentElement.classList.contains('dark')
  4007. || document.documentElement.dataset.theme === 'dark'
  4008. || document.documentElement.style.colorScheme === 'dark';
  4009. return isDark ? ld[2] : ld[1];
  4010. }
  4011. return v;
  4012. }
  4013.  
  4014. const LAYOUT_OVERRIDE_PREFIX = 'grid-layout:';
  4015. let _layoutOverrideCache = undefined;
  4016. function getLayoutOverride() {
  4017. if (_layoutOverrideCache !== undefined) return _layoutOverrideCache;
  4018. try {
  4019. if (typeof threadSubject === 'undefined') return (_layoutOverrideCache = null);
  4020. const raw = localStorage.getItem(LAYOUT_OVERRIDE_PREFIX + threadSubject);
  4021. return (_layoutOverrideCache = raw ? JSON.parse(raw) : null);
  4022. } catch { return (_layoutOverrideCache = null); }
  4023. }
  4024. function setLayoutOverride(overrides) {
  4025. _layoutOverrideCache = overrides;
  4026. try {
  4027. if (typeof threadSubject === 'undefined') return;
  4028. if (overrides && Object.keys(overrides).length) {
  4029. localStorage.setItem(LAYOUT_OVERRIDE_PREFIX + threadSubject, JSON.stringify(overrides));
  4030. } else {
  4031. localStorage.removeItem(LAYOUT_OVERRIDE_PREFIX + threadSubject);
  4032. }
  4033. } catch { /* ignore */ }
  4034. }
  4035.  
  4036. const GRID_SPAN_RE = /(\d+)\s*\/\s*span\s*(\d+)/;
  4037. function parseGridSpan(value) {
  4038. const m = GRID_SPAN_RE.exec(value || '');
  4039. return m ? { start: +m[1] - 1, span: +m[2] } : null;
  4040. }
  4041. function getGridPos(el) {
  4042. const c = parseGridSpan(el.style.gridColumn);
  4043. const r = parseGridSpan(el.style.gridRow);
  4044. if (!c || !r) return null;
  4045. return { x: c.start, y: r.start, w: c.span, h: r.span };
  4046. }
  4047. function setGridPos(el, x, y, w, h) {
  4048. el.style.gridColumn = `${x + 1} / span ${w}`;
  4049. el.style.gridRow = `${y + 1} / span ${h}`;
  4050. }
  4051.  
  4052. function reprocessAllPosts() {
  4053. document.querySelectorAll(OP_SELECTOR).forEach(op => {
  4054. restorePost(op);
  4055. processPost(op);
  4056. });
  4057. }
  4058.  
  4059. function addResizeHandle(parent) {
  4060. const handle = document.createElement('div');
  4061. handle.className = 'gr-resize-handle';
  4062. parent.appendChild(handle);
  4063. return handle;
  4064. }
  4065. function makeHideBtn(title, onClick) {
  4066. const btn = document.createElement('span');
  4067. btn.className = 'gr-hide-btn';
  4068. btn.textContent = '\u00d7';
  4069. btn.title = title;
  4070. btn.addEventListener('click', onClick);
  4071. return btn;
  4072. }
  4073.  
  4074. function renderWidget(w, opts) {
  4075. opts = opts || {};
  4076.  
  4077. const overrides = getLayoutOverride();
  4078. const ov = overrides && overrides[w.id];
  4079. const ww = ov ? { ...w, x: ov.x, y: ov.y, w: ov.w, h: ov.h } : w;
  4080. let el;
  4081. switch (w.type) {
  4082. case 'generic': el = renderGeneric(w, opts); break;
  4083. case 'link': el = renderLink(w, opts); break;
  4084. case 'list': el = renderList(w, opts); break;
  4085. case 'timeline': el = renderTimeline(w, opts); break;
  4086. case 'countdown': el = renderCountdown(w, opts); break;
  4087. case 'attachments': el = renderAttachments(w, opts); break;
  4088. case 'content': el = renderThreadContent(w, opts); break;
  4089. default:
  4090. el = document.createElement('div');
  4091. el.className = 'gr-widget gr-error';
  4092. el.textContent = `Unknown widget type: ${w.type}`;
  4093. }
  4094. el.classList.add('gr-widget-' + w.type);
  4095. el.dataset.widgetId = w.id;
  4096. const x = Math.max(0, Math.min(GRID_COLS - 1, ww.x | 0));
  4097. const width = Math.max(1, Math.min(GRID_COLS - x, ww.w | 0));
  4098. const y = Math.max(0, ww.y | 0);
  4099. const height = Math.max(1, ww.h | 0);
  4100. setGridPos(el, x, y, width, height);
  4101. return el;
  4102. }
  4103.  
  4104. function renderGrid(data, opts) {
  4105. opts = opts || {};
  4106. const servers = (data.meta && Array.isArray(data.meta.servers)) ? data.meta.servers : [];
  4107. const defaultId = data.meta && data.meta.defaultServerId;
  4108.  
  4109. const xThreadLink = opts.crossThreadLink || null;
  4110. if (servers.length === 0 && !xThreadLink) {
  4111. return buildGridContent(data, opts, 'UTC', '');
  4112. }
  4113.  
  4114. const wrap = document.createElement('div');
  4115. wrap.className = 'gr-wrap';
  4116. if (opts.interactive === false) wrap.classList.add('gr-non-interactive');
  4117.  
  4118. const bar = document.createElement('div');
  4119. bar.className = 'gr-statusbar';
  4120.  
  4121. if (xThreadLink) {
  4122. const xtWrap = document.createElement('span');
  4123. xtWrap.className = 'gr-statusbar-xthread';
  4124. xtWrap.appendChild(xThreadLink);
  4125. bar.appendChild(xtWrap);
  4126. }
  4127. if (servers.length > 0) {
  4128. const serverGroup = document.createElement('span');
  4129. serverGroup.className = 'gr-statusbar-server-group';
  4130. const label = document.createElement('span');
  4131. label.className = 'gr-statusbar-label';
  4132. label.textContent = 'Server:';
  4133. serverGroup.appendChild(label);
  4134. const select = document.createElement('select');
  4135. select.className = 'gr-statusbar-select';
  4136. servers.forEach(s => {
  4137. const opt = document.createElement('option');
  4138. opt.value = s.id;
  4139. opt.textContent = `${s.name} (${s.tz})`;
  4140. select.appendChild(opt);
  4141. });
  4142. const initial = (
  4143. getStoredServerChoice(servers)
  4144. || (servers.find(s => s.id === defaultId) && defaultId)
  4145. || servers[0].id
  4146. );
  4147. select.value = initial;
  4148. serverGroup.appendChild(select);
  4149. bar.appendChild(serverGroup);
  4150. wrap.appendChild(bar);
  4151.  
  4152. let currentGrid = buildGridContent(data, opts, getTzFor(servers, initial), getServerName(servers, initial));
  4153. wrap.appendChild(currentGrid);
  4154.  
  4155. select.addEventListener('change', () => {
  4156. setStoredServerChoice(servers, select.value);
  4157. const tz = getTzFor(servers, select.value);
  4158. const serverName = getServerName(servers, select.value);
  4159. const childOpts = Object.assign({}, opts, { tz: tz, serverName: serverName });
  4160. data.widgets.forEach(w => {
  4161. if (!TZ_AFFECTED_WIDGETS.has(w.type)) return;
  4162. const escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(w.id) : String(w.id).replace(/"/g, '\\"');
  4163. const existing = currentGrid.querySelector(`[data-widget-id="${escaped}"]`);
  4164. if (!existing) return;
  4165. try {
  4166. const replacement = renderWidget(w, childOpts);
  4167. existing.replaceWith(replacement);
  4168. } catch (e) {
  4169. console.error('[grid-renderer] TZ re-render failed:', w, e);
  4170. }
  4171. });
  4172. });
  4173. } else {
  4174. wrap.appendChild(bar);
  4175. wrap.appendChild(buildGridContent(data, opts, 'UTC', ''));
  4176. }
  4177.  
  4178. if (opts.interactive !== false) {
  4179. const editBtn = document.createElement('span');
  4180. editBtn.className = 'gr-layout-edit-btn gr-edit-toggle coloredIcon';
  4181. editBtn.title = 'Edit layout';
  4182. bar.appendChild(editBtn);
  4183.  
  4184. editBtn.addEventListener('click', () => {
  4185. const root = wrap.querySelector('.gr-root');
  4186. if (!root) return;
  4187. if (root.classList.contains('gr-edit-mode')) {
  4188. exitEditMode(root, editBtn, bar, data.widgets);
  4189. } else {
  4190. enterEditMode(root, data.widgets, editBtn, bar);
  4191. }
  4192. });
  4193. }
  4194.  
  4195. return wrap;
  4196. }
  4197.  
  4198. function enterEditMode(root, widgets, editBtn, bar) {
  4199. root.classList.add('gr-edit-mode');
  4200. editBtn.classList.add('is-active');
  4201. editBtn.title = 'Save layout';
  4202.  
  4203. const resetBtn = document.createElement('span');
  4204. resetBtn.className = 'gr-layout-edit-btn gr-layout-reset-btn coloredIcon';
  4205. resetBtn.title = 'Reset layout';
  4206. resetBtn.addEventListener('click', () => {
  4207. if (!confirm('Reset layout to default? Your customizations will be lost.')) return;
  4208. setLayoutOverride(null);
  4209. _layoutOverrideCache = undefined;
  4210. reprocessAllPosts();
  4211. });
  4212. bar.appendChild(resetBtn);
  4213.  
  4214. const jsonBtn = document.createElement('span');
  4215. jsonBtn.className = 'gr-layout-edit-btn gr-json-btn coloredIcon';
  4216. jsonBtn.title = 'Edit layout JSON';
  4217. bar.appendChild(jsonBtn);
  4218.  
  4219. const exitBtn = document.createElement('span');
  4220. exitBtn.className = 'gr-layout-edit-btn gr-exit-edit-btn coloredIcon';
  4221. exitBtn.title = 'Exit edit mode';
  4222. exitBtn.addEventListener('click', () => {
  4223. if (root._editCleanup) { root._editCleanup(); delete root._editCleanup; }
  4224. _layoutOverrideCache = undefined;
  4225. reprocessAllPosts();
  4226. });
  4227. bar.appendChild(exitBtn);
  4228.  
  4229. const jsonPanel = document.createElement('div');
  4230. jsonPanel.className = 'gr-json-panel';
  4231. jsonPanel.style.display = 'none';
  4232.  
  4233. const jsonTextarea = document.createElement('textarea');
  4234. jsonTextarea.className = 'gr-json-textarea';
  4235. jsonTextarea.spellcheck = false;
  4236. jsonPanel.appendChild(jsonTextarea);
  4237.  
  4238. const jsonActions = document.createElement('div');
  4239. jsonActions.style.cssText = 'display:flex;gap:4px;margin-top:4px;';
  4240.  
  4241. const jsonApply = document.createElement('span');
  4242. jsonApply.className = 'gr-json-apply';
  4243. jsonApply.textContent = 'Apply';
  4244. jsonApply.addEventListener('click', () => {
  4245. const trimmed = jsonTextarea.value.trim();
  4246. try {
  4247. const parsed = trimmed ? JSON.parse(trimmed) : null;
  4248. setLayoutOverride(parsed);
  4249. _layoutOverrideCache = undefined;
  4250. jsonPanel.style.display = 'none';
  4251. reprocessAllPosts();
  4252. } catch (e) {
  4253. alert('Invalid JSON: ' + e.message);
  4254. }
  4255. });
  4256. jsonActions.appendChild(jsonApply);
  4257.  
  4258. const jsonCopy = document.createElement('span');
  4259. jsonCopy.className = 'gr-json-apply';
  4260. jsonCopy.textContent = 'Copy';
  4261. jsonCopy.addEventListener('click', async () => {
  4262. try {
  4263. await navigator.clipboard.writeText(jsonTextarea.value);
  4264. } catch {
  4265. jsonTextarea.select();
  4266. document.execCommand('copy');
  4267. }
  4268. });
  4269. jsonActions.appendChild(jsonCopy);
  4270.  
  4271. jsonPanel.appendChild(jsonActions);
  4272. bar.parentNode.insertBefore(jsonPanel, bar.nextSibling);
  4273.  
  4274. jsonBtn.addEventListener('click', () => {
  4275. const isOpen = jsonPanel.style.display !== 'none';
  4276. if (isOpen) {
  4277. jsonPanel.style.display = 'none';
  4278. } else {
  4279. const ov = getLayoutOverride();
  4280. jsonTextarea.value = ov ? JSON.stringify(ov, null, 2) : '';
  4281. jsonPanel.style.display = '';
  4282. }
  4283. });
  4284.  
  4285. const GAP = 4;
  4286. const colW = () => (root.clientWidth + GAP) / GRID_COLS;
  4287. const rowH = () => ROW_HEIGHT_PX + GAP;
  4288.  
  4289. let activeEl = null;
  4290. let mode = null; // 'drag' or 'resize'
  4291. let startMX, startMY, startGX, startGY, startW, startH;
  4292.  
  4293. const overrides = getLayoutOverride() || {};
  4294.  
  4295. root.querySelectorAll('[data-widget-id]').forEach(el => {
  4296. addResizeHandle(el);
  4297. el.appendChild(makeHideBtn('Hide widget', (e) => {
  4298. e.stopPropagation();
  4299. const id = el.dataset.widgetId;
  4300. el.remove();
  4301. const w = widgets.find(w => w.id === id);
  4302. if (w) root.appendChild(createHiddenPlaceholder(w, overrides));
  4303. }));
  4304. });
  4305.  
  4306. widgets.forEach(w => {
  4307. if (overrides[w.id] && overrides[w.id].hidden) {
  4308. root.appendChild(createHiddenPlaceholder(w, overrides));
  4309. }
  4310. });
  4311.  
  4312. function createHiddenPlaceholder(w, ov) {
  4313. const ph = document.createElement('div');
  4314. ph.className = 'gr-widget gr-widget-hidden';
  4315. ph.dataset.widgetId = w.id;
  4316. ph.dataset.isHidden = '1';
  4317.  
  4318. const pos = ov[w.id] || {};
  4319. const x = pos.x ?? w.x ?? 0;
  4320. const y = pos.y ?? w.y ?? 0;
  4321. const width = pos.w ?? w.w ?? 2;
  4322. const height = pos.h ?? w.h ?? 1;
  4323. setGridPos(ph, x, y, width, height);
  4324.  
  4325. const label = document.createElement('span');
  4326. label.className = 'gr-hidden-label';
  4327. label.textContent = w.title || w.type || w.id;
  4328. ph.appendChild(label);
  4329.  
  4330. const restoreBtn = document.createElement('button');
  4331. restoreBtn.className = 'gr-restore-btn';
  4332. restoreBtn.textContent = 'โ†บ Show';
  4333. restoreBtn.addEventListener('click', (e) => {
  4334. e.stopPropagation();
  4335. delete ph.dataset.isHidden;
  4336. ph.classList.remove('gr-widget-hidden');
  4337. ph.classList.add('gr-widget-restored');
  4338. label.textContent = 'โœ“ ' + (w.title || w.type || w.id) + ' (will appear on save)';
  4339. restoreBtn.remove();
  4340. });
  4341. ph.appendChild(restoreBtn);
  4342.  
  4343. addResizeHandle(ph);
  4344.  
  4345. return ph;
  4346. }
  4347.  
  4348. function onMouseDown(e) {
  4349. if (e.button !== 0) return;
  4350. if (e.target.closest('.gr-hide-btn, .gr-restore-btn, .gr-add-widget-btn')) return;
  4351. const widget = e.target.closest('[data-widget-id]');
  4352. if (!widget || !root.contains(widget)) return;
  4353.  
  4354. e.preventDefault();
  4355. activeEl = widget;
  4356.  
  4357. const style = widget.style;
  4358. const c = parseGridSpan(style.gridColumn);
  4359. const r = parseGridSpan(style.gridRow);
  4360. startGX = c ? c.start : 0;
  4361. startGY = r ? r.start : 0;
  4362. startW = c ? c.span : 1;
  4363. startH = r ? r.span : 1;
  4364. startMX = e.clientX;
  4365. startMY = e.clientY;
  4366.  
  4367. if (e.target.closest('.gr-resize-handle')) {
  4368. mode = 'resize';
  4369. widget.classList.add('gr-resizing');
  4370. } else {
  4371. mode = 'drag';
  4372. widget.classList.add('gr-dragging');
  4373. }
  4374. }
  4375.  
  4376. function onMouseMove(e) {
  4377. if (!activeEl || !mode) return;
  4378. const cw = colW(), rh = rowH();
  4379.  
  4380. if (mode === 'drag') {
  4381. const dx = Math.round((e.clientX - startMX) / cw);
  4382. const dy = Math.round((e.clientY - startMY) / rh);
  4383. const newX = Math.max(0, Math.min(GRID_COLS - startW, startGX + dx));
  4384. const newY = Math.max(0, startGY + dy);
  4385. setGridPos(activeEl, newX, newY, startW, startH);
  4386. } else {
  4387. const dCols = Math.round((e.clientX - startMX) / cw);
  4388. const dRows = Math.round((e.clientY - startMY) / rh);
  4389. const newW = Math.max(1, Math.min(GRID_COLS - startGX, startW + dCols));
  4390. const newH = Math.max(1, startH + dRows);
  4391. setGridPos(activeEl, startGX, startGY, newW, newH);
  4392. }
  4393. }
  4394.  
  4395. function onMouseUp() {
  4396. if (!activeEl) return;
  4397. activeEl.classList.remove('gr-dragging', 'gr-resizing');
  4398. activeEl = null;
  4399. mode = null;
  4400. }
  4401.  
  4402. function onClickBlock(e) {
  4403. const widget = e.target.closest('[data-widget-id]');
  4404. if (widget && widget.tagName === 'A') {
  4405. e.preventDefault();
  4406. }
  4407. }
  4408.  
  4409. // Import widget button
  4410. const importBtn = document.createElement('span');
  4411. importBtn.className = 'gr-add-widget-btn';
  4412. importBtn.textContent = '+ Import widget';
  4413. importBtn.title = 'Import a widget from JSON';
  4414. importBtn.addEventListener('click', () => {
  4415. const json = prompt('Paste widget JSON:');
  4416. if (!json) return;
  4417. try {
  4418. const w = JSON.parse(json);
  4419. if (!w.type) throw new Error('Missing type');
  4420. const id = '__custom_' + Math.random().toString(36).slice(2, 6);
  4421. w.id = id;
  4422.  
  4423. let maxRow = 0;
  4424. root.querySelectorAll('[data-widget-id]').forEach(el => {
  4425. const r = parseGridSpan(el.style.gridRow);
  4426. if (r) maxRow = Math.max(maxRow, r.start + r.span);
  4427. });
  4428.  
  4429. const childOpts = { tz: 'UTC', serverName: '' };
  4430. const rendered = renderWidget(w, childOpts);
  4431. setGridPos(rendered, 0, maxRow, Math.min(w.w || 6, GRID_COLS), w.h || 2);
  4432. rendered.dataset.widgetJson = JSON.stringify(w);
  4433. rendered.dataset.isAdded = '1';
  4434.  
  4435. addResizeHandle(rendered);
  4436. rendered.appendChild(makeHideBtn('Remove widget', (ev) => {
  4437. ev.stopPropagation();
  4438. rendered.remove();
  4439. }));
  4440.  
  4441. root.appendChild(rendered);
  4442. } catch (e) {
  4443. alert('Invalid widget JSON: ' + e.message);
  4444. }
  4445. });
  4446. root.appendChild(importBtn);
  4447.  
  4448. root.addEventListener('click', onClickBlock, true);
  4449.  
  4450. root.addEventListener('mousedown', onMouseDown);
  4451. document.addEventListener('mousemove', onMouseMove);
  4452. document.addEventListener('mouseup', onMouseUp);
  4453.  
  4454. // Store cleanup ref
  4455. root._editCleanup = () => {
  4456. root.removeEventListener('click', onClickBlock, true);
  4457. root.removeEventListener('mousedown', onMouseDown);
  4458. document.removeEventListener('mousemove', onMouseMove);
  4459. document.removeEventListener('mouseup', onMouseUp);
  4460. };
  4461. }
  4462.  
  4463. function exitEditMode(root, editBtn, bar, templateWidgets) {
  4464. const templatePos = {};
  4465. (templateWidgets || []).forEach(w => {
  4466. templatePos[w.id] = { x: w.x | 0, y: w.y | 0, w: w.w | 0, h: w.h | 0 };
  4467. });
  4468.  
  4469. const overrides = {};
  4470. root.querySelectorAll('[data-widget-id]').forEach(el => {
  4471. const id = el.dataset.widgetId;
  4472. const pos = getGridPos(el);
  4473. if (!pos) return;
  4474.  
  4475. const isHidden = el.dataset.isHidden === '1';
  4476. const isAdded = el.dataset.isAdded === '1';
  4477.  
  4478. if (isAdded || isHidden) {
  4479. const entry = { x: pos.x, y: pos.y, w: pos.w, h: pos.h };
  4480. if (isHidden) entry.hidden = true;
  4481. if (isAdded) entry.added = true;
  4482. if (el.dataset.widgetJson) {
  4483. try { entry.widget = JSON.parse(el.dataset.widgetJson); } catch {}
  4484. }
  4485. overrides[id] = entry;
  4486. return;
  4487. }
  4488.  
  4489. const orig = templatePos[id];
  4490. if (!orig) return;
  4491. if (pos.x !== orig.x || pos.y !== orig.y || pos.w !== orig.w || pos.h !== orig.h) {
  4492. overrides[id] = { x: pos.x, y: pos.y, w: pos.w, h: pos.h };
  4493. }
  4494. });
  4495.  
  4496. setLayoutOverride(Object.keys(overrides).length ? overrides : null);
  4497.  
  4498. if (root._editCleanup) { root._editCleanup(); delete root._editCleanup; }
  4499.  
  4500. // Invalidate cache and re-render all posts with updated overrides
  4501. _layoutOverrideCache = undefined;
  4502. reprocessAllPosts();
  4503. }
  4504.  
  4505. const TZ_AFFECTED_WIDGETS = new Set([
  4506. 'timeline',
  4507. 'countdown',
  4508. ]);
  4509.  
  4510. function getTzFor(servers, id) {
  4511. const s = servers.find(x => x.id === id);
  4512. return (s && s.tz) || 'UTC';
  4513. }
  4514. function getServerName(servers, id) {
  4515. const s = servers.find(x => x.id === id);
  4516. return (s && s.name) || '';
  4517. }
  4518. function serverChoiceStorageKey(servers) {
  4519. const ids = servers.map(s => s.id).slice().sort().join('|');
  4520. return 'grid-server-choice:' + ids;
  4521. }
  4522. function getStoredServerChoice(servers) {
  4523. try {
  4524. const v = window.localStorage.getItem(serverChoiceStorageKey(servers));
  4525. if (v && servers.some(s => s.id === v)) return v;
  4526. } catch (e) { /* storage unavailable */ }
  4527. return null;
  4528. }
  4529. function setStoredServerChoice(servers, id) {
  4530. try { window.localStorage.setItem(serverChoiceStorageKey(servers), id); }
  4531. catch (e) { /* ignore */ }
  4532. }
  4533.  
  4534. function buildGridContent(data, opts, tz, serverName) {
  4535. const root = document.createElement('div');
  4536. root.className = 'gr-root';
  4537. if (opts.interactive === false) root.classList.add('gr-non-interactive');
  4538. const orderOf = w => (w.order == null ? Infinity : w.order);
  4539. const widgets = data.widgets.slice().sort((a, b) =>
  4540. (orderOf(a) - orderOf(b)) || (a.y - b.y) || (a.x - b.x)
  4541. );
  4542. const childOpts = Object.assign({}, opts, { tz: tz, serverName: serverName });
  4543.  
  4544. const groups = [
  4545. { type: 'link', className: 'gr-link-group', collected: [], lastAnchor: null },
  4546. { type: 'countdown', className: 'gr-countdown-group', collected: [], lastAnchor: null },
  4547. ];
  4548. function groupFor(type) { return groups.find(g => g.type === type) || null; }
  4549.  
  4550. widgets.forEach(w => {
  4551. const overrides = getLayoutOverride();
  4552. if (overrides && overrides[w.id] && overrides[w.id].hidden) return;
  4553.  
  4554. try {
  4555. const rendered = renderWidget(w, childOpts);
  4556. const g = groupFor(w.type);
  4557. if (g) {
  4558. g.collected.push(rendered);
  4559. const marker = document.createComment('gr-group-anchor:' + w.type);
  4560. root.appendChild(marker);
  4561. g.lastAnchor = marker;
  4562. } else {
  4563. root.appendChild(rendered);
  4564. }
  4565. } catch (e) {
  4566. console.error('[grid-renderer] widget render failed:', w, e);
  4567. const err = document.createElement('div');
  4568. err.className = 'gr-widget gr-error';
  4569. err.textContent = `Widget error: ${e.message}`;
  4570. root.appendChild(err);
  4571. }
  4572. });
  4573.  
  4574. groups.forEach(g => {
  4575. if (!g.lastAnchor) return;
  4576. const container = document.createElement('div');
  4577. container.className = g.className;
  4578. g.collected.forEach(el => container.appendChild(el));
  4579. g.lastAnchor.parentNode.replaceChild(container, g.lastAnchor);
  4580. });
  4581.  
  4582. Array.from(root.childNodes).forEach(n => {
  4583. if (n.nodeType === 8 /* Comment */ && typeof n.data === 'string' && n.data.startsWith('gr-group-anchor:')) {
  4584. n.remove();
  4585. }
  4586. });
  4587.  
  4588. // Render user-imported custom widgets from the override
  4589. const overrides = getLayoutOverride();
  4590. if (overrides) {
  4591. const templateIds = new Set(data.widgets.map(w => w.id));
  4592. Object.entries(overrides).forEach(([id, ov]) => {
  4593. if (!ov.added || !ov.widget || ov.hidden) return;
  4594. if (templateIds.has(id)) return;
  4595. try {
  4596. const w = { ...ov.widget, id, x: ov.x, y: ov.y, w: ov.w, h: ov.h };
  4597. const el = renderWidget(w, childOpts);
  4598. el.dataset.isAdded = '1';
  4599. el.dataset.widgetJson = JSON.stringify(ov.widget);
  4600. root.appendChild(el);
  4601. } catch (e) {
  4602. console.error('[grid-renderer] imported widget failed:', id, e);
  4603. }
  4604. });
  4605. }
  4606.  
  4607. return root;
  4608. }
  4609.  
  4610. function findCrossThreadLink(msgEl) {
  4611. const links = msgEl.querySelectorAll('a.quoteLink');
  4612. for (const a of links) {
  4613. if (a.querySelector('span.qmark-ct')) return a;
  4614. }
  4615. return links.length > 0 ? links[0] : null;
  4616. }
  4617.  
  4618. function processPost(postEl) {
  4619. const msgEl = postEl.querySelector(MSG_SELECTOR);
  4620. if (!msgEl) return;
  4621.  
  4622. const extracted = extractGridData(postEl, msgEl);
  4623.  
  4624. if (!extracted) {
  4625. if (state.rendered.has(postEl)) return;
  4626. const hasOverride = !!getLayoutOverride();
  4627.  
  4628. const wrap = document.createElement('div');
  4629. wrap.className = 'gr-wrap gr-wrap-empty';
  4630. const bar = document.createElement('div');
  4631. bar.className = 'gr-statusbar';
  4632. wrap.appendChild(bar);
  4633.  
  4634. const emptyData = { meta: {}, widgets: [] };
  4635.  
  4636. if (hasOverride) {
  4637. wrap.appendChild(buildGridContent(emptyData, {}, 'UTC', ''));
  4638. }
  4639.  
  4640. const editBtn = document.createElement('span');
  4641. editBtn.className = 'gr-layout-edit-btn gr-edit-toggle coloredIcon';
  4642. editBtn.title = 'Edit layout';
  4643. bar.appendChild(editBtn);
  4644.  
  4645. editBtn.addEventListener('click', () => {
  4646. const root = wrap.querySelector('.gr-root');
  4647. if (root && root.classList.contains('gr-edit-mode')) {
  4648. exitEditMode(root, editBtn, bar, []);
  4649. } else {
  4650. let gridRoot = root;
  4651. if (!gridRoot) {
  4652. gridRoot = buildGridContent(emptyData, {}, 'UTC', '');
  4653. wrap.appendChild(gridRoot);
  4654. }
  4655. enterEditMode(gridRoot, [], editBtn, bar);
  4656. }
  4657. });
  4658.  
  4659. if (!state.originals.has(postEl)) {
  4660. state.originals.set(postEl, msgEl.innerHTML);
  4661. }
  4662. msgEl.appendChild(wrap);
  4663. state.rendered.add(postEl);
  4664. return;
  4665. }
  4666.  
  4667. if (!state.originals.has(postEl)) {
  4668. state.originals.set(postEl, msgEl.innerHTML);
  4669. }
  4670.  
  4671. let crossThreadLink = findCrossThreadLink(msgEl);
  4672. if (crossThreadLink) {
  4673. crossThreadLink.remove();
  4674. }
  4675.  
  4676. const codeBlock = msgEl.querySelector('span.blockCode');
  4677. const contentFragment = document.createDocumentFragment();
  4678. const children = Array.from(msgEl.childNodes);
  4679. children.forEach(node => {
  4680. if (node === codeBlock || node.contains?.(codeBlock) || codeBlock?.contains(node)) return;
  4681. contentFragment.appendChild(node);
  4682. });
  4683.  
  4684. const grid = renderGrid(extracted.data, { postEl, crossThreadLink, contentFragment });
  4685.  
  4686. msgEl.innerHTML = '';
  4687. msgEl.appendChild(grid);
  4688.  
  4689. state.rendered.add(postEl);
  4690. }
  4691.  
  4692. function restorePost(postEl) {
  4693. const msgEl = postEl.querySelector(MSG_SELECTOR);
  4694. if (!msgEl) return;
  4695. const moved = state.movedAttachments.get(postEl);
  4696. if (moved) {
  4697. moved.forEach(({ cell, originalParent }) => {
  4698. if (originalParent && originalParent.isConnected) {
  4699. originalParent.appendChild(cell);
  4700. }
  4701. });
  4702. state.movedAttachments.delete(postEl);
  4703. }
  4704. const orig = state.originals.get(postEl);
  4705. if (orig !== undefined) {
  4706. msgEl.innerHTML = orig;
  4707. }
  4708. postEl.querySelectorAll('.opUploadPanel[data-gr-hidden="1"]').forEach(p => {
  4709. p.style.display = '';
  4710. delete p.dataset.grHidden;
  4711. });
  4712. state.rendered.delete(postEl);
  4713. }
  4714.  
  4715. function scan() {
  4716. const ops = document.querySelectorAll(OP_SELECTOR);
  4717. ops.forEach(op => {
  4718. if (state.enabled) {
  4719. if (!state.rendered.has(op)) processPost(op);
  4720. } else {
  4721. if (state.rendered.has(op) || state.originals.has(op)) restorePost(op);
  4722. }
  4723. });
  4724. }
  4725.  
  4726. function getGridCSS() {
  4727. return `
  4728. :root {
  4729. --grid-radius: 0px;
  4730. --gr-text-outline: 1px 1px 2px black, -1px -1px 2px black, 1px -1px 2px black, -1px 1px 2px black;
  4731. }
  4732. .gr-root {
  4733. display: grid;
  4734. grid-template-columns: repeat(${GRID_COLS}, 1fr);
  4735. grid-auto-rows: ${ROW_HEIGHT_PX}px;
  4736. gap: 4px;
  4737. width: 100%;
  4738. box-sizing: border-box;
  4739. container-type: inline-size;
  4740. isolation: isolate;
  4741. }
  4742.  
  4743. .gr-root div > span.embedButton { display: none; } /* hide [Embed]s */
  4744. @supports (container-type: inline-size) {
  4745. .gr-root {
  4746. grid-auto-rows: calc(100cqw * ${ROW_TO_COL_RATIO} / ${GRID_COLS});
  4747. }
  4748. }
  4749. .gr-link-group,
  4750. .gr-countdown-group { display: contents; }
  4751. .gr-widget {
  4752. background: var(--contrast-color, #eef2ff);
  4753. border-radius: var(--grid-radius);
  4754. padding: 6px;
  4755. overflow: hidden;
  4756. box-sizing: border-box;
  4757. position: relative;
  4758. z-index: 1;
  4759. display: flex;
  4760. flex-direction: column;
  4761. }
  4762. .gr-widget.gr-no-bg {
  4763. background: transparent;
  4764. padding: 0;
  4765. }
  4766. .gr-widget-title {
  4767. font-weight: bold;
  4768. color: var(--subject-color, #0f0c5d);
  4769. font-size: clamp(12px, 0.8vw, 36px);
  4770. margin-bottom: 4px;
  4771. padding-bottom: 3px;
  4772. border-bottom: 1px solid var(--horizon-sep-color, #b7c5d9);
  4773. flex-shrink: 0;
  4774. white-space: nowrap;
  4775. overflow: hidden;
  4776. text-overflow: ellipsis;
  4777. }
  4778. .gr-widget-body { flex: 1; overflow: auto; min-height: 0; }
  4779.  
  4780. .gr-generic { position: relative; }
  4781. .gr-generic .gr-widget-body { position: relative; overflow: hidden; }
  4782. .gr-generic-text-inner {
  4783. position: relative;
  4784. white-space: pre-wrap;
  4785. word-wrap: break-word;
  4786. max-height: 100%;
  4787. overflow: auto;
  4788. }
  4789.  
  4790. .gr-generic-bg {
  4791. position: absolute;
  4792. inset: 0;
  4793. z-index: 0;
  4794. background-repeat: no-repeat;
  4795. pointer-events: none;
  4796. }
  4797.  
  4798. .gr-bg-cover { background-size: cover; background-position: center; }
  4799. .gr-bg-contain { background-size: contain; background-position: center; }
  4800. .gr-bg-corner-tl,
  4801. .gr-bg-corner-tr,
  4802. .gr-bg-corner-bl,
  4803. .gr-bg-corner-br { background-size: contain; }
  4804. .gr-bg-corner-tl { background-position: left top; }
  4805. .gr-bg-corner-tr { background-position: right top; }
  4806. .gr-bg-corner-bl { background-position: left bottom; }
  4807. .gr-bg-corner-br { background-position: right bottom; }
  4808. .gr-generic-bg.gr-bg-corner-tl, .gr-generic-bg.gr-bg-corner-tr,
  4809. .gr-generic-bg.gr-bg-corner-bl, .gr-generic-bg.gr-bg-corner-br {
  4810. width: 50%;
  4811. height: 80%;
  4812. inset: auto;
  4813. }
  4814. .gr-generic-bg.gr-bg-corner-tl { top: 0; left: 0; }
  4815. .gr-generic-bg.gr-bg-corner-tr { top: 0; right: 0; }
  4816. .gr-generic-bg.gr-bg-corner-bl { bottom: 0; left: 0; }
  4817. .gr-generic-bg.gr-bg-corner-br { bottom: 0; right: 0; }
  4818.  
  4819. .gr-bg-tile { background-repeat: repeat; background-size: auto; }
  4820.  
  4821. .gr-countdown { position: relative; }
  4822. .gr-countdown-body {
  4823. position: relative;
  4824. overflow: hidden;
  4825. display: flex;
  4826. align-items: center;
  4827. justify-content: center;
  4828. flex: 1;
  4829. min-height: 0;
  4830. }
  4831. .gr-countdown-timer {
  4832. position: relative;
  4833. font-weight: bold;
  4834. font-variant-numeric: tabular-nums;
  4835. font-size: clamp(14pt, 2.1vw, 40pt);
  4836. color: var(--text-color, black);
  4837. text-align: center;
  4838. line-height: 1.1;
  4839. white-space: nowrap;
  4840. }
  4841. .gr-countdown.has-bg .gr-countdown-timer {
  4842. color: white;
  4843. text-shadow: var(--gr-text-outline);
  4844. }
  4845. .gr-countdown-timer.gr-countdown-ended {
  4846. font-size: clamp(12pt, 2vw, 24pt);
  4847. font-style: italic;
  4848. }
  4849.  
  4850. .gr-countdown-label {
  4851. padding: 0;
  4852. }
  4853. .gr-countdown-label .gr-countdown-body {
  4854. flex-direction: column;
  4855. gap: 0;
  4856. }
  4857. .gr-countdown-label-title {
  4858. position: relative;
  4859. font-weight: bold;
  4860. font-size: clamp(10px, 1.2vw, 16px);
  4861. color: var(--text-color, black);
  4862. text-align: center;
  4863. white-space: nowrap;
  4864. line-height: 1.3;
  4865. }
  4866. .gr-countdown-label .gr-countdown-timer {
  4867. font-size: clamp(10pt, 1.6vw, 24pt);
  4868. }
  4869. .gr-countdown-label.has-bg .gr-countdown-label-title {
  4870. color: white;
  4871. text-shadow: var(--gr-text-outline);
  4872. }
  4873.  
  4874. .gr-link {
  4875. padding: 0;
  4876. text-decoration: none;
  4877. color: var(--link-color, #34345C);
  4878. cursor: pointer;
  4879. }
  4880. .gr-link:hover { border-color: var(--border-highlight-color, #d00); }
  4881. .gr-link-inner {
  4882. flex: 1;
  4883. display: flex;
  4884. align-items: center;
  4885. justify-content: center;
  4886. text-align: center;
  4887. padding: 6px;
  4888. background-repeat: no-repeat;
  4889. color: inherit;
  4890. font-weight: bold;
  4891. word-break: break-word;
  4892. font-size: clamp(18px, 1.3vw, 54px);
  4893. }
  4894. .gr-link-inner.has-bg {
  4895. color: white;
  4896. text-shadow: var(--gr-text-outline);
  4897. }
  4898.  
  4899. .gr-list-strip {
  4900. display: flex;
  4901. align-items: stretch;
  4902. gap: 4px;
  4903. overflow-x: auto;
  4904. overflow-y: hidden;
  4905. height: 100%;
  4906. scrollbar-width: none;
  4907. }
  4908. .gr-list-strip::-webkit-scrollbar { display: none; }
  4909. .gr-list-vstrip {
  4910. display: flex;
  4911. flex-direction: column;
  4912. gap: 4px;
  4913. overflow-x: hidden;
  4914. overflow-y: auto;
  4915. height: 100%;
  4916. scrollbar-width: none;
  4917. }
  4918. .gr-list-vstrip::-webkit-scrollbar { display: none; }
  4919. .gr-list-vstrip .gr-list-card {
  4920. width: auto;
  4921. flex: 0 0 40%;
  4922. min-height: 80px;
  4923. }
  4924.  
  4925. .gr-list-accordion {
  4926. display: flex;
  4927. flex-direction: column;
  4928. gap: 2px;
  4929. overflow-x: hidden;
  4930. overflow-y: auto;
  4931. height: 100%;
  4932. scrollbar-width: none;
  4933. }
  4934. .gr-list-accordion::-webkit-scrollbar { display: none; }
  4935. .gr-list-acc-item {
  4936. flex: 0 0 auto;
  4937. border-radius: var(--grid-radius);
  4938. background: var(--menu-color, #eef2ff);
  4939. overflow: hidden;
  4940. }
  4941. .gr-list-acc-summary {
  4942. cursor: pointer;
  4943. padding: 6px 8px 6px 6px;
  4944. font-weight: bold;
  4945. font-size: clamp(12px, 0.7vw, 36px);
  4946. color: var(--text-color, #000);
  4947. user-select: none;
  4948. position: relative;
  4949. }
  4950. .gr-list-acc-summary::-webkit-details-marker { display: none; } /* Safari */
  4951. /*.gr-list-acc-summary::before {
  4952. content: 'โ–ธ';
  4953. position: absolute;
  4954. left: 8px;
  4955. top: 50%;
  4956. transform: translateY(-50%);
  4957. font-size: 10pt;
  4958. opacity: 0.7;
  4959. }*/
  4960. .gr-list-acc-item[open] > .gr-list-acc-summary::before {
  4961. transform: translateY(-50%) rotate(90deg);
  4962. }
  4963. .gr-list-acc-content {
  4964. padding: 6px 10px 10px;
  4965. border-top: 1px solid var(--horizon-sep-color, #b7c5d9);
  4966. display: flex;
  4967. flex-direction: column;
  4968. gap: 8px;
  4969. }
  4970. .gr-list-acc-img {
  4971. max-width: 100%;
  4972. max-height: 200px;
  4973. width: auto;
  4974. height: auto;
  4975. border-radius: var(--grid-radius);
  4976. align-self: flex-start;
  4977. }
  4978. .gr-list-acc-text {
  4979. font-size: 10pt;
  4980. color: var(--text-color, #000);
  4981. white-space: pre-wrap;
  4982. word-wrap: break-word;
  4983. }
  4984. .gr-list-acc-link {
  4985. display: inline-block;
  4986. align-self: flex-start;
  4987. padding: 4px 10px;
  4988. background: var(--menu-color, #eef2ff);
  4989. border: 1px solid var(--border-color, black);
  4990. border-radius: var(--grid-radius);
  4991. color: var(--link-color, #34345C);
  4992. text-decoration: none;
  4993. font-size: 9pt;
  4994. max-width: 100%;
  4995. overflow: hidden;
  4996. text-overflow: ellipsis;
  4997. white-space: nowrap;
  4998. }
  4999. .gr-list-acc-link:hover {
  5000. border-color: var(--border-highlight-color, #d00);
  5001. }
  5002.  
  5003. .gr-list-carousel {
  5004. position: relative;
  5005. height: 100%;
  5006. outline: none;
  5007. display: flex;
  5008. flex-direction: column;
  5009. }
  5010. .gr-list-car-empty {
  5011. height: 100%;
  5012. display: flex;
  5013. align-items: center;
  5014. justify-content: center;
  5015. color: var(--text-color, black);
  5016. opacity: 0.5;
  5017. font-style: italic;
  5018. }
  5019. .gr-list-car-btn {
  5020. position: absolute;
  5021. top: 50%;
  5022. transform: translateY(-50%);
  5023. width: 32px;
  5024. height: 48px;
  5025. border: none;
  5026. border-radius: var(--grid-radius);
  5027. background: rgba(0, 0, 0, 0.45);
  5028. color: white;
  5029. font-size: 18px;
  5030. line-height: 1;
  5031. padding: 0;
  5032. cursor: pointer;
  5033. display: flex;
  5034. align-items: center;
  5035. justify-content: center;
  5036. opacity: 0;
  5037. pointer-events: none;
  5038. transition: opacity 0.15s ease;
  5039. }
  5040. .gr-widget:hover .gr-list-car-btn {
  5041. opacity: 0.85;
  5042. pointer-events: auto;
  5043. }
  5044. .gr-list-car-btn:hover {
  5045. background: rgba(0, 0, 0, 0.65);
  5046. opacity: 1 !important;
  5047. }
  5048. .gr-list-car-prev { left: 6px; }
  5049. .gr-list-car-next { right: 6px; }
  5050. .gr-list-car-slot {
  5051. flex: 1;
  5052. min-height: 0;
  5053. display: flex;
  5054. align-items: stretch;
  5055. }
  5056. .gr-list-carousel .gr-list-card {
  5057. width: 100%;
  5058. min-height: 0;
  5059. }
  5060. .gr-list-car-counter {
  5061. position: absolute;
  5062. left: 50%;
  5063. transform: translateX(-50%);
  5064. bottom: 36px;
  5065. font-size: 9pt;
  5066. padding: 2px 8px;
  5067. background: rgba(0, 0, 0, 0.6);
  5068. color: white;
  5069. border-radius: var(--grid-radius);
  5070. pointer-events: none;
  5071. opacity: 0;
  5072. transition: opacity 0.15s ease;
  5073. }
  5074. .gr-widget:hover .gr-list-car-counter {
  5075. opacity: 1;
  5076. }
  5077. @media (hover: none) {
  5078. .gr-list-car-btn {
  5079. opacity: 0.85;
  5080. pointer-events: auto;
  5081. }
  5082. .gr-list-car-counter { opacity: 1; }
  5083. }
  5084. .gr-list-card {
  5085. flex: 0 0 auto;
  5086. width: 35%;
  5087. border-radius: var(--grid-radius);
  5088. background-size: cover;
  5089. background-position: center;
  5090. background-color: var(--contrast-color, #d6daf0);
  5091. position: relative;
  5092. overflow: hidden;
  5093. text-decoration: none;
  5094. color: inherit;
  5095. display: block;
  5096. min-height: 100px;
  5097. }
  5098. .gr-list-card img,
  5099. .gr-list-card-title,
  5100. .gr-list-lang-badge,
  5101. .gr-list-acc-img {
  5102. pointer-events: none;
  5103. }
  5104. .gr-list-card-title {
  5105. position: absolute;
  5106. bottom: 0; left: 0; right: 0;
  5107. background: rgba(0,0,0,0.7);
  5108. color: white;
  5109. font-size: 9pt;
  5110. padding: 4px 6px;
  5111. text-align: center;
  5112. }
  5113. .gr-list-card:not(.has-bg) .gr-list-card-title {
  5114. position: static;
  5115. background: transparent;
  5116. color: var(--text-color, black);
  5117. height: 100%;
  5118. display: flex;
  5119. align-items: center;
  5120. justify-content: center;
  5121. }
  5122. .gr-list-yt-play {
  5123. position: absolute;
  5124. top: 50%; left: 50%;
  5125. transform: translate(-50%, -50%);
  5126. width: 48px; height: 34px;
  5127. opacity: 0.85;
  5128. pointer-events: none;
  5129. transition: opacity 0.1s;
  5130. }
  5131. .gr-list-yt-play svg { width: 100%; height: 100%; display: block; }
  5132. .gr-list-card:hover .gr-list-yt-play { opacity: 1; }
  5133.  
  5134. .gr-list-cycle-btn {
  5135. position: absolute;
  5136. top: 2px;
  5137. right: 4px;
  5138. width: 20px;
  5139. height: 20px;
  5140. border: none;
  5141. color: var(--text-color, #000);
  5142. font-size: 14px;
  5143. line-height: 1;
  5144. cursor: pointer;
  5145. align-items: center;
  5146. justify-content: center;
  5147. margin: 0;
  5148. }
  5149. .gr-widget-list .gr-widget-title { padding-right: 28px; }
  5150.  
  5151. .gr-list-lang-btn {
  5152. position: absolute;
  5153. top: 2px;
  5154. right: 26px;
  5155. border: none;
  5156. background: none;
  5157. color: var(--text-color, #000);
  5158. font-size: 10px;
  5159. cursor: pointer;
  5160. padding: 0 4px;
  5161. opacity: 0.6;
  5162. letter-spacing: 0.5px;
  5163. }
  5164. .gr-list-lang-btn:hover { opacity: 1; }
  5165.  
  5166. .gr-list-lang-badge {
  5167. position: absolute;
  5168. top: 3px;
  5169. left: 3px;
  5170. font-size: 8px;
  5171. font-weight: 700;
  5172. letter-spacing: 0.5px;
  5173. background: rgba(0,0,0,0.55);
  5174. color: #fff;
  5175. padding: 1px 4px;
  5176. border-radius: 2px;
  5177. line-height: 1.3;
  5178. pointer-events: none;
  5179. z-index: 1;
  5180. }
  5181. .gr-list-acc-link .gr-list-lang-badge {
  5182. position: static;
  5183. display: inline-block;
  5184. vertical-align: middle;
  5185. margin-right: 2px;
  5186. }
  5187.  
  5188. .gr-list-placeholder-notice {
  5189. font-size: 9pt;
  5190. color: var(--text-color, black);
  5191. opacity: 0.6;
  5192. font-style: italic;
  5193. padding: 4px 6px;
  5194. text-align: center;
  5195. }
  5196.  
  5197. .gr-att-body summary,
  5198. .gr-att-body .imgExpanded,
  5199. .gr-att-body video,
  5200. .gr-att-body .hideLink { display: none !important; }
  5201. .gr-att-body details { font-size: 0; }
  5202. .gr-att-body details > * { font-size: 10pt; }
  5203.  
  5204. .gr-att-body .gr-list-car-slot {
  5205. height: 100%;
  5206. min-width: 0;
  5207. overflow: hidden;
  5208. }
  5209. .gr-att-body .gr-list-car-slot .uploadCell {
  5210. margin: 0;
  5211. width: 100%;
  5212. height: 100%;
  5213. min-width: 0;
  5214. min-height: 0;
  5215. display: flex;
  5216. align-items: center;
  5217. justify-content: center;
  5218. overflow: hidden;
  5219. }
  5220. .gr-att-body .gr-list-car-slot .uploadCell details {
  5221. width: 100%;
  5222. height: 100%;
  5223. min-width: 0;
  5224. min-height: 0;
  5225. display: flex;
  5226. align-items: center;
  5227. justify-content: center;
  5228. overflow: hidden;
  5229. }
  5230. .gr-att-body .gr-list-car-slot .uploadCell a.imgLink {
  5231. display: flex !important;
  5232. align-items: center;
  5233. justify-content: center;
  5234. width: 100%;
  5235. height: 100%;
  5236. min-width: 0;
  5237. min-height: 0;
  5238. overflow: hidden;
  5239. cursor: zoom-in;
  5240. }
  5241. .gr-att-body .gr-list-car-slot a.imgLink img {
  5242. width: 100% !important;
  5243. height: 100% !important;
  5244. max-width: 100% !important;
  5245. max-height: 100% !important;
  5246. object-fit: contain;
  5247. display: block !important;
  5248. box-sizing: border-box;
  5249. margin: 0 !important;
  5250. }
  5251.  
  5252. .gr-att-tile {
  5253. flex: 0 0 auto;
  5254. width: 180px;
  5255. border: 1px solid var(--border-color, black);
  5256. border-radius: var(--grid-radius);
  5257. background: var(--contrast-color, #d6daf0);
  5258. overflow: hidden;
  5259. position: relative;
  5260. display: flex;
  5261. align-items: center;
  5262. justify-content: center;
  5263. min-height: 100px;
  5264. }
  5265. .gr-att-body .gr-list-car-slot .gr-att-tile {
  5266. width: 100%;
  5267. height: 100%;
  5268. }
  5269. .gr-att-empty {
  5270. font-size: 10pt;
  5271. color: var(--text-color, black);
  5272. opacity: 0.5;
  5273. font-style: italic;
  5274. padding: 16px;
  5275. text-align: center;
  5276. width: 100%;
  5277. }
  5278. .gr-att-placeholder {
  5279. background: repeating-linear-gradient(
  5280. 45deg,
  5281. var(--contrast-color, #d6daf0),
  5282. var(--contrast-color, #d6daf0) 10px,
  5283. var(--menu-color, #eef2ff) 10px,
  5284. var(--menu-color, #eef2ff) 20px
  5285. );
  5286. color: var(--text-color, black);
  5287. opacity: 0.6;
  5288. font-size: 10pt;
  5289. font-style: italic;
  5290. }
  5291.  
  5292. .gr-thread-content {
  5293. overflow-y: auto;
  5294. overflow-x: hidden;
  5295. scrollbar-width: none;
  5296. word-wrap: break-word;
  5297. padding: 4px;
  5298. box-sizing: border-box;
  5299. position: relative;
  5300. z-index: 0;
  5301. }
  5302. .gr-thread-content::-webkit-scrollbar { display: none; }
  5303. .gr-content-placeholder {
  5304. color: var(--text-color, black);
  5305. opacity: 0.5;
  5306. font-style: italic;
  5307. padding: 16px;
  5308. text-align: center;
  5309. }
  5310.  
  5311. .gr-lightbox-backdrop {
  5312. position: fixed;
  5313. inset: 0;
  5314. background: rgba(0, 0, 0, 0.85);
  5315. z-index: 10000;
  5316. display: flex;
  5317. align-items: center;
  5318. justify-content: center;
  5319. padding: 48px 24px 24px;
  5320. box-sizing: border-box;
  5321. cursor: zoom-out;
  5322. }
  5323. .gr-lightbox-frame {
  5324. position: relative;
  5325. display: inline-block;
  5326. cursor: default;
  5327. max-width: calc(100vw - 48px);
  5328. max-height: calc(100vh - 72px);
  5329. }
  5330. .gr-lightbox-media {
  5331. display: block;
  5332. max-width: calc(100vw - 48px);
  5333. max-height: calc(100vh - 72px);
  5334. width: auto;
  5335. height: auto;
  5336. object-fit: contain;
  5337. }
  5338. .gr-lightbox-iframe {
  5339. aspect-ratio: 16 / 9;
  5340. width: min(calc(100vw - 48px), calc((100vh - 72px) * 16 / 9));
  5341. height: auto;
  5342. max-width: calc(100vw - 48px);
  5343. max-height: calc(100vh - 72px);
  5344. border: 0;
  5345. }
  5346. .gr-lightbox-close {
  5347. position: absolute;
  5348. top: -40px;
  5349. right: 0px;
  5350. width: 36px;
  5351. height: 36px;
  5352. border: none;
  5353. background: rgba(255, 255, 255, 0.15);
  5354. color: white;
  5355. font-size: 18px;
  5356. line-height: 1;
  5357. padding: 0;
  5358. margin: 0;
  5359. cursor: pointer;
  5360. display: flex;
  5361. align-items: center;
  5362. justify-content: center;
  5363. }
  5364. .gr-lightbox-close:hover {
  5365. background: rgba(255, 255, 255, 0.3);
  5366. }
  5367.  
  5368. .gr-timeline-canvas-wrap {
  5369. position: relative;
  5370. width: 100%;
  5371. height: 100%;
  5372. overflow: hidden;
  5373. overscroll-behavior: contain;
  5374. }
  5375. .gr-timeline-canvas {
  5376. display: block;
  5377. cursor: grab;
  5378. touch-action: pan-y pinch-zoom;
  5379. }
  5380. .gr-timeline-canvas.dragging { cursor: grabbing; }
  5381.  
  5382. .gr-popup {
  5383. position: absolute;
  5384. background: var(--menu-color, #eef2ff);
  5385. border: 1px solid var(--border-color, black);
  5386. border-radius: var(--grid-radius);
  5387. padding: 8px;
  5388. max-width: 320px;
  5389. min-width: 200px;
  5390. z-index: 9999;
  5391. box-shadow: 2px 2px 4px rgba(0,0,0,0.3);
  5392. }
  5393. .gr-popup-close {
  5394. position: absolute;
  5395. top: 2px; right: 4px;
  5396. cursor: pointer;
  5397. color: var(--link-color, #34345C);
  5398. font-weight: bold;
  5399. }
  5400. .gr-popup-title {
  5401. font-weight: bold;
  5402. color: var(--subject-color, #0f0c5d);
  5403. margin-bottom: 4px;
  5404. padding-right: 16px;
  5405. }
  5406. .gr-popup-time {
  5407. font-size: 9pt;
  5408. color: var(--text-color, black);
  5409. margin-bottom: 2px;
  5410. }
  5411. .gr-popup-time-local {
  5412. color: var(--text-color, black);
  5413. opacity: 0.7;
  5414. font-style: italic;
  5415. margin-bottom: 6px;
  5416. }
  5417. .gr-popup-countdown {
  5418. font-size: 9pt;
  5419. font-weight: bold;
  5420. margin-bottom: 6px;
  5421. }
  5422. .gr-popup-countdown.normal { color: var(--text-color, black); }
  5423. .gr-popup-countdown.future { color: var(--text-color, black); font-weight: normal; font-style: italic; }
  5424. .gr-popup-countdown.urgent { color: var(--alert-color, red); }
  5425. .gr-popup-countdown.ended { color: var(--text-color, #666); font-weight: normal; opacity: 0.7; }
  5426. .gr-popup-img {
  5427. max-width: 100%;
  5428. max-height: 160px;
  5429. display: block;
  5430. margin-bottom: 4px;
  5431. }
  5432. .gr-popup-text { font-size: 9pt; white-space: pre-wrap; }
  5433.  
  5434. .gr-error {
  5435. padding: 8px;
  5436. background: var(--marked-color, #d6bad0);
  5437. border: 1px solid var(--marked-border-color, #ba9dbf);
  5438. }
  5439.  
  5440. .gr-wrap { width: 100%; }
  5441. .gr-statusbar {
  5442. display: flex; align-items: center; gap: 8px;
  5443. padding: 4px 6px; margin-bottom: 4px;
  5444. font-size: 10pt; float: right;
  5445. }
  5446. .gr-statusbar-label { font-weight: bold; color: var(--subject-color, #0f0c5d); }
  5447. .gr-statusbar-select {
  5448. font: inherit; padding: 2px 4px;
  5449. border: 1px solid var(--border-color, black);
  5450. background: var(--contrast-color); color: var(--text-color, black);
  5451. }
  5452.  
  5453. .gr-statusbar-server-group {
  5454. display: inline-flex;
  5455. align-items: center;
  5456. gap: 6px;
  5457. margin-left: auto;
  5458. }
  5459.  
  5460. .gr-statusbar-xthread {
  5461. display: inline-flex;
  5462. align-items: center;
  5463. gap: 4px;
  5464. }
  5465. .gr-statusbar-xthread::before {
  5466. content: 'Previous:';
  5467. font-weight: bold;
  5468. }
  5469.  
  5470. .gr-non-interactive, .gr-non-interactive * { pointer-events: none !important; }
  5471. .gr-non-interactive .gr-timeline-canvas { cursor: default !important; }
  5472.  
  5473. /* Layout editor */
  5474. .gr-layout-edit-btn {
  5475. background: none;
  5476. border: none;
  5477. color: var(--text-color, #000);
  5478. cursor: pointer;
  5479. font-size: 14px;
  5480. padding: 0 4px;
  5481. opacity: 0.4;
  5482. line-height: 1;
  5483. }
  5484. .gr-layout-edit-btn:hover { opacity: 1; }
  5485. @media (max-width: 812px) {
  5486. .gr-layout-edit-btn { display: none; }
  5487. }
  5488.  
  5489. .gr-edit-toggle.coloredIcon::before { content: '\\e0A3'; }
  5490. .gr-edit-toggle.coloredIcon.is-active::before { content: '\\e033'; }
  5491. .gr-exit-edit-btn.coloredIcon::before { content: '\\e0DB'; }
  5492. .gr-layout-reset-btn.coloredIcon::before { content: '\\e08C'; }
  5493. .gr-json-btn.coloredIcon::before { content: '\\e040'; }
  5494.  
  5495. .gr-edit-mode [data-widget-id] {
  5496. cursor: grab;
  5497. outline: 2px dashed rgba(128,128,128,0.4);
  5498. outline-offset: -2px;
  5499. user-select: none;
  5500. }
  5501. .gr-edit-mode [data-widget-id]:hover {
  5502. outline-color: var(--ps-accent, #e94560);
  5503. }
  5504. .gr-edit-mode [data-widget-id] *:not(.gr-hide-btn):not(.gr-resize-handle):not(.gr-restore-btn) {
  5505. pointer-events: none !important;
  5506. }
  5507. .gr-edit-mode .gr-resize-handle {
  5508. pointer-events: auto !important;
  5509. }
  5510. .gr-edit-mode .gr-dragging,
  5511. .gr-edit-mode .gr-resizing {
  5512. opacity: 0.7;
  5513. z-index: 100;
  5514. }
  5515. .gr-edit-mode .gr-dragging { cursor: grabbing; }
  5516. .gr-resize-handle {
  5517. position: absolute;
  5518. bottom: 0;
  5519. right: 0;
  5520. width: 14px;
  5521. height: 14px;
  5522. cursor: nwse-resize;
  5523. z-index: 10;
  5524. background: linear-gradient(135deg, transparent 50%, var(--ps-accent, #e94560) 50%);
  5525. opacity: 0.6;
  5526. border-radius: 0 0 var(--grid-radius, 0) 0;
  5527. }
  5528. .gr-resize-handle:hover { opacity: 1; }
  5529.  
  5530. .gr-hide-btn {
  5531. position: absolute;
  5532. top: 4px;
  5533. right: 4px;
  5534. width: 18px;
  5535. height: 18px;
  5536. border: none;
  5537. background: rgba(0,0,0,0.4);
  5538. color: #fff;
  5539. font-size: 12px;
  5540. line-height: 18px;
  5541. text-align: center;
  5542. cursor: pointer;
  5543. border-radius: 50%;
  5544. z-index: 11;
  5545. padding: 0;
  5546. pointer-events: auto !important;
  5547. opacity: 0;
  5548. transition: opacity 0.15s;
  5549. }
  5550. .gr-edit-mode [data-widget-id]:hover .gr-hide-btn { opacity: 1; }
  5551. .gr-hide-btn:hover { background: var(--e-danger, #d33); }
  5552.  
  5553. .gr-widget-hidden {
  5554. background: repeating-linear-gradient(
  5555. 45deg, transparent, transparent 8px,
  5556. rgba(128,128,128,0.08) 8px, rgba(128,128,128,0.08) 16px
  5557. );
  5558. border: 2px dashed rgba(128,128,128,0.3);
  5559. display: flex;
  5560. align-items: center;
  5561. justify-content: center;
  5562. gap: 8px;
  5563. opacity: 0.5;
  5564. min-height: 40px;
  5565. }
  5566. .gr-hidden-label {
  5567. font-size: 11px;
  5568. color: var(--text-color, #666);
  5569. pointer-events: none;
  5570. }
  5571. .gr-restore-btn {
  5572. font-size: 11px;
  5573. border: 1px solid rgba(128,128,128,0.3);
  5574. background: var(--contrast-color, #eef2ff);
  5575. color: var(--text-color, #333);
  5576. padding: 2px 8px;
  5577. border-radius: 3px;
  5578. cursor: pointer;
  5579. pointer-events: auto !important;
  5580. }
  5581. .gr-restore-btn:hover { border-color: var(--ps-accent, #e94560); }
  5582.  
  5583. .gr-add-widget-btn {
  5584. grid-column: 1 / -1;
  5585. border: 2px dashed rgba(128,128,128,0.3);
  5586. background: none;
  5587. color: var(--text-color, #666);
  5588. font-size: 16px;
  5589. cursor: pointer;
  5590. border-radius: var(--grid-radius, 0);
  5591. opacity: 0.5;
  5592. font-weight: bold;
  5593. display: inline-flex;
  5594. align-items: center;
  5595. justify-content: center;
  5596. }
  5597. .gr-add-widget-btn:hover {
  5598. opacity: 1;
  5599. border-color: var(--ps-accent, #e94560);
  5600. }
  5601.  
  5602. .gr-json-panel {
  5603. background: var(--menu-color, #eef2ff);
  5604. border: 1px solid var(--border-color, #ccc);
  5605. border-top: none;
  5606. padding: 6px;
  5607. box-sizing: border-box;
  5608. }
  5609. .gr-json-textarea {
  5610. width: 100%;
  5611. min-height: 120px;
  5612. max-height: 300px;
  5613. font-family: "SF Mono", "Fira Code", monospace;
  5614. font-size: 11px;
  5615. background: var(--contrast-color, #fff);
  5616. color: var(--text-color, #000);
  5617. border: 1px solid var(--border-color, #ccc);
  5618. padding: 6px;
  5619. box-sizing: border-box;
  5620. resize: vertical;
  5621. tab-size: 2;
  5622. }
  5623. .gr-json-apply {
  5624. font-size: 11px;
  5625. padding: 3px 10px;
  5626. border: 1px solid var(--border-color, #ccc);
  5627. background: var(--contrast-color, #fff);
  5628. color: var(--text-color, #000);
  5629. cursor: pointer;
  5630. border-radius: 2px;
  5631. }
  5632. .gr-json-apply:hover {
  5633. border-color: var(--ps-accent, #e94560);
  5634. }
  5635.  
  5636. .gr-widget-restored {
  5637. border: 2px dashed rgba(100,200,100,0.5);
  5638. background: rgba(100,200,100,0.05);
  5639. display: flex;
  5640. align-items: center;
  5641. justify-content: center;
  5642. opacity: 0.6;
  5643. }
  5644.  
  5645. @media (max-width: ${MOBILE_BREAKPOINT}px) {
  5646. .gr-root {
  5647. display: flex;
  5648. flex-direction: column;
  5649. grid-auto-rows: auto;
  5650. }
  5651. .gr-widget {
  5652. grid-column: unset !important;
  5653. grid-row: unset !important;
  5654. min-height: 120px;
  5655. height: auto;
  5656. }
  5657. .gr-widget-attachments { order: -1; }
  5658. .gr-thread-content {
  5659. grid-column: unset !important;
  5660. grid-row: unset !important;
  5661. order: -2;
  5662. }
  5663. .gr-statusbar {
  5664. flex-direction: column;
  5665. align-items: stretch;
  5666. gap: 4px;
  5667. }
  5668. .gr-statusbar-server-group {
  5669. margin-left: 0;
  5670. }
  5671.  
  5672. .gr-widget-timeline .gr-widget-body { min-height: 200px; }
  5673. .gr-list-carousel { min-height: 200px; }
  5674. .gr-link-group {
  5675. display: grid;
  5676. grid-template-columns: repeat(3, 1fr);
  5677. gap: 4px;
  5678. }
  5679. .gr-link-group .gr-widget-link {
  5680. min-height: 60px !important;
  5681. padding: 4px;
  5682. }
  5683. .gr-countdown-group {
  5684. display: grid;
  5685. grid-template-columns: repeat(2, 1fr);
  5686. gap: 4px;
  5687. }
  5688. .gr-countdown-group .gr-widget-countdown {
  5689. min-height: 100px !important;
  5690. }
  5691. }
  5692. `;
  5693. }
  5694.  
  5695. function onStart(api) {
  5696. api.addStyle(getGridCSS());
  5697.  
  5698. const processLinkForEmbedOrig = embed.processLinkForEmbed;
  5699. // patch to prevent native script from rendering [Embed] on youtube links inside OP
  5700. embed.processLinkForEmbed = function (anchor) {
  5701. if (anchor.classList.contains('gr-list-card'))
  5702. return;
  5703.  
  5704. processLinkForEmbedOrig(anchor);
  5705. }
  5706. state.enabled = true;
  5707. scan();
  5708. }
  5709. function onStop(api) {
  5710. state.enabled = false;
  5711. scan();
  5712. }
  5713. function onSettingChange() { }
  5714.  
  5715. return {
  5716. id: 'interactive-op',
  5717. name: 'Interactive OP',
  5718. description: 'Makes thread OP interactive',
  5719. settings: {},
  5720. onStart,
  5721. onStop,
  5722. onSettingChange,
  5723. renderGrid,
  5724. getGridCSS,
  5725. GRID_COLS,
  5726. ROW_HEIGHT_PX,
  5727. ROW_TO_COL_RATIO,
  5728. }
  5729. }
  5730.  
  5731.  
  5732. window.InteractiveOP = InteractiveOP();
  5733. document.head.appendChild(Object.assign(document.createElement('style'), { textContent: window.InteractiveOP.getGridCSS() }));
  5734. // ---------------------------------------------------------------------------
  5735.  
  5736. // ===== SERVERS ============================================================
  5737. // Common IANA timezones relevant to gacha game regions. "Custom" allows free entry.
  5738. const TZ_OPTIONS = [
  5739. { v: 'UTC', label: 'UTC' },
  5740. { v: 'America/Los_Angeles', label: 'America/Los_Angeles (NA West / PST/PDT)' },
  5741. { v: 'America/New_York', label: 'America/New_York (NA East / EST/EDT)' },
  5742. { v: 'Europe/London', label: 'Europe/London (GMT/BST)' },
  5743. { v: 'Europe/Berlin', label: 'Europe/Berlin (CET/CEST)' },
  5744. { v: 'Asia/Tokyo', label: 'Asia/Tokyo (JST +9)' },
  5745. { v: 'Asia/Shanghai', label: 'Asia/Shanghai (CST +8)' },
  5746. { v: 'Asia/Seoul', label: 'Asia/Seoul (KST +9)' },
  5747. { v: 'Asia/Taipei', label: 'Asia/Taipei (+8)' },
  5748. { v: 'Asia/Singapore', label: 'Asia/Singapore (+8)' },
  5749. { v: 'Australia/Sydney', label: 'Australia/Sydney (AEST/AEDT)' },
  5750. ];
  5751.  
  5752. // All IANA zones the browser knows. Falls back to TZ_OPTIONS values if the
  5753. // browser is too old to expose supportedValuesOf. Newer browsers return ~400
  5754. // entries covering every region; older ones get the short curated list.
  5755. const ALL_TZ_ZONES = (function() {
  5756. try {
  5757. if (typeof Intl.supportedValuesOf === 'function') {
  5758. return Intl.supportedValuesOf('timeZone');
  5759. }
  5760. } catch (e) { /* fall through */ }
  5761. return TZ_OPTIONS.map(o => o.v);
  5762. })();
  5763.  
  5764. // Compute current offset (in minutes, positive = ahead of UTC) for a given
  5765. // IANA zone at a given moment. Uses the DateTimeFormat trick: format the same
  5766. // instant in both the target zone and UTC, subtract.
  5767. function offsetMinutesAt(tz, whenMs) {
  5768. try {
  5769. const d = new Date(whenMs);
  5770. const fmt = new Intl.DateTimeFormat('en-US', {
  5771. timeZone: tz, hour12: false,
  5772. year: 'numeric', month: '2-digit', day: '2-digit',
  5773. hour: '2-digit', minute: '2-digit', second: '2-digit',
  5774. });
  5775. const parts = fmt.formatToParts(d).reduce((a, p) => {
  5776. if (p.type !== 'literal') a[p.type] = p.value;
  5777. return a;
  5778. }, {});
  5779. const asUtcMs = Date.UTC(
  5780. Number(parts.year), Number(parts.month) - 1, Number(parts.day),
  5781. Number(parts.hour) % 24, Number(parts.minute), Number(parts.second)
  5782. );
  5783. return Math.round((asUtcMs - whenMs) / 60000);
  5784. } catch (e) { return 0; }
  5785. }
  5786.  
  5787. // Format a signed offset in minutes as "UTC+2", "UTC-5:30", "UTC".
  5788. function formatOffset(mins) {
  5789. if (mins === 0) return 'UTC';
  5790. const sign = mins > 0 ? '+' : '-';
  5791. const abs = Math.abs(mins);
  5792. const h = Math.floor(abs / 60);
  5793. const m = abs % 60;
  5794. return m === 0 ? `UTC${sign}${h}` : `UTC${sign}${h}:${String(m).padStart(2, '0')}`;
  5795. }
  5796.  
  5797. // Precompute offset metadata for all zones once per session. Cache keeps the
  5798. // picker snappy โ€” 400 zones ร— 2 offset calls = ~800 Intl calls, done once.
  5799. const TZ_METADATA = (function() {
  5800. const now = Date.now();
  5801. const jan = Date.UTC(new Date().getUTCFullYear(), 0, 15);
  5802. return ALL_TZ_ZONES.map(tz => {
  5803. const nowOff = offsetMinutesAt(tz, now);
  5804. const janOff = offsetMinutesAt(tz, jan);
  5805. return {
  5806. tz,
  5807. offsetMins: nowOff,
  5808. offsetLabel: formatOffset(nowOff),
  5809. hasDst: nowOff !== janOff,
  5810. // Lowercase searchable string: "europe/paris utc+2"
  5811. search: (tz + ' ' + formatOffset(nowOff)).toLowerCase(),
  5812. };
  5813. }).sort((a, b) => a.offsetMins - b.offsetMins || a.tz.localeCompare(b.tz));
  5814. })();
  5815.  
  5816. function isValidTz(tz) {
  5817. if (!tz) return false;
  5818. try { new Intl.DateTimeFormat('en', { timeZone: tz }); return true; }
  5819. catch (e) { return false; }
  5820. }
  5821.  
  5822. // Searchable timezone picker. Returns a wrapper element containing a text
  5823. // input (for filter/manual entry) plus a dropdown list that appears when
  5824. // focused. Each row shows "Zone/Name ยท UTC+N (DST)" so the author immediately
  5825. // sees which offset a zone corresponds to without leaving the editor.
  5826. function createTzPicker(initial, onChange) {
  5827. const wrap = document.createElement('div');
  5828. wrap.className = 'tz-picker';
  5829.  
  5830. const input = document.createElement('input');
  5831. input.type = 'text';
  5832. input.className = 'tz-picker-input';
  5833. input.placeholder = 'Search zones (e.g. Tokyo, Paris, UTC+9)';
  5834. input.value = initial || 'UTC';
  5835. input.autocomplete = 'off';
  5836. wrap.appendChild(input);
  5837.  
  5838. // Small label showing the current offset for the typed value. Updates as
  5839. // user types; clearly shows when their input doesn't match a valid zone.
  5840. const badge = document.createElement('span');
  5841. badge.className = 'tz-picker-badge';
  5842. wrap.appendChild(badge);
  5843.  
  5844. const list = document.createElement('div');
  5845. list.className = 'tz-picker-list';
  5846. list.setAttribute('role', 'listbox');
  5847. wrap.appendChild(list);
  5848.  
  5849. // Current committed value โ€” changes only when user picks from list or
  5850. // typed value is a real IANA zone when focus leaves.
  5851. let currentValue = initial || 'UTC';
  5852.  
  5853. function updateBadge(value) {
  5854. if (!value) { badge.textContent = ''; return; }
  5855. const meta = TZ_METADATA.find(m => m.tz === value);
  5856. if (meta) {
  5857. badge.textContent = meta.offsetLabel + (meta.hasDst ? ' ยท DST' : '');
  5858. badge.classList.remove('tz-picker-badge-invalid');
  5859. } else if (isValidTz(value)) {
  5860. // Valid IANA but not in metadata (rare โ€” supportedValuesOf missing it)
  5861. const off = offsetMinutesAt(value, Date.now());
  5862. badge.textContent = formatOffset(off);
  5863. badge.classList.remove('tz-picker-badge-invalid');
  5864. } else {
  5865. badge.textContent = 'invalid';
  5866. badge.classList.add('tz-picker-badge-invalid');
  5867. }
  5868. }
  5869. updateBadge(initial);
  5870.  
  5871. function renderList(query) {
  5872. list.innerHTML = '';
  5873. const q = query.toLowerCase().trim();
  5874. // Filter: if query is empty, show all; otherwise match substring in `search`
  5875. const matches = q
  5876. ? TZ_METADATA.filter(m => m.search.indexOf(q) !== -1)
  5877. : TZ_METADATA;
  5878. // Cap to avoid huge DOM โ€” first 200 matches is plenty for search
  5879. const shown = matches.slice(0, 200);
  5880. shown.forEach(m => {
  5881. const row = document.createElement('div');
  5882. row.className = 'tz-picker-row';
  5883. if (m.tz === currentValue) row.classList.add('tz-picker-row-selected');
  5884. row.setAttribute('role', 'option');
  5885. row.dataset.tz = m.tz;
  5886.  
  5887. const name = document.createElement('span');
  5888. name.className = 'tz-picker-row-name';
  5889. name.textContent = m.tz;
  5890. row.appendChild(name);
  5891.  
  5892. const off = document.createElement('span');
  5893. off.className = 'tz-picker-row-off';
  5894. off.textContent = m.offsetLabel + (m.hasDst ? ' ยท DST' : '');
  5895. row.appendChild(off);
  5896.  
  5897. row.addEventListener('mousedown', (e) => {
  5898. // mousedown instead of click so it fires before blur hides the list
  5899. e.preventDefault();
  5900. commit(m.tz);
  5901. list.classList.remove('tz-picker-list-open');
  5902. });
  5903. list.appendChild(row);
  5904. });
  5905. if (matches.length > shown.length) {
  5906. const more = document.createElement('div');
  5907. more.className = 'tz-picker-more';
  5908. more.textContent = `+${matches.length - shown.length} more โ€” refine search`;
  5909. list.appendChild(more);
  5910. }
  5911. if (matches.length === 0) {
  5912. const none = document.createElement('div');
  5913. none.className = 'tz-picker-more';
  5914. none.textContent = 'No matches. Type any valid IANA zone to use it.';
  5915. list.appendChild(none);
  5916. }
  5917. }
  5918.  
  5919. function commit(newTz) {
  5920. currentValue = newTz;
  5921. input.value = newTz;
  5922. updateBadge(newTz);
  5923. onChange(newTz);
  5924. }
  5925.  
  5926. input.addEventListener('focus', () => {
  5927. renderList(input.value);
  5928. list.classList.add('tz-picker-list-open');
  5929. });
  5930. input.addEventListener('input', () => {
  5931. updateBadge(input.value);
  5932. renderList(input.value);
  5933. list.classList.add('tz-picker-list-open');
  5934. });
  5935. input.addEventListener('blur', () => {
  5936. // On blur, commit the typed value if it's a valid IANA zone; otherwise
  5937. // revert the input text to the last committed value. Use a short delay
  5938. // so a mousedown on a list row can fire first.
  5939. setTimeout(() => {
  5940. list.classList.remove('tz-picker-list-open');
  5941. const typed = input.value.trim();
  5942. if (typed && isValidTz(typed)) {
  5943. if (typed !== currentValue) commit(typed);
  5944. } else {
  5945. input.value = currentValue;
  5946. updateBadge(currentValue);
  5947. }
  5948. }, 150);
  5949. });
  5950. input.addEventListener('keydown', (e) => {
  5951. if (e.key === 'Escape') {
  5952. input.value = currentValue;
  5953. updateBadge(currentValue);
  5954. list.classList.remove('tz-picker-list-open');
  5955. input.blur();
  5956. } else if (e.key === 'Enter') {
  5957. // If there's exactly one match, commit it. Otherwise just accept
  5958. // whatever's typed (blur handler will validate).
  5959. const q = input.value.toLowerCase().trim();
  5960. const matches = q ? TZ_METADATA.filter(m => m.search.indexOf(q) !== -1) : [];
  5961. if (matches.length >= 1) {
  5962. e.preventDefault();
  5963. commit(matches[0].tz);
  5964. list.classList.remove('tz-picker-list-open');
  5965. }
  5966. }
  5967. });
  5968.  
  5969. return wrap;
  5970. }
  5971.  
  5972. function updateServerButton() {
  5973. const btn = document.getElementById('btn-servers');
  5974. const n = (state.meta.servers || []).length;
  5975. btn.textContent = n > 0 ? `Servers (${n})` : 'Servers';
  5976. btn.classList.toggle('has-config', n > 0);
  5977. }
  5978.  
  5979. // Working copy of servers used while editing the dialog; applied on Save.
  5980. let serversDraft = null;
  5981. let serversDraftDefault = null;
  5982.  
  5983. function openServersDialog() {
  5984. // Clone current state so Cancel truly cancels
  5985. serversDraft = (state.meta.servers || []).map(s => ({ ...s }));
  5986. serversDraftDefault = state.meta.defaultServerId;
  5987. renderServerList();
  5988. document.getElementById('dlg-servers').showModal();
  5989. }
  5990.  
  5991. function renderServerList() {
  5992. const list = document.getElementById('sv-list');
  5993. list.innerHTML = '';
  5994. if (serversDraft.length === 0) {
  5995. const empty = document.createElement('div');
  5996. empty.style.cssText = 'color:var(--e-muted);font-size:12px;padding:8px;text-align:center;';
  5997. empty.textContent = 'No servers configured. Times will display in UTC.';
  5998. list.appendChild(empty);
  5999. return;
  6000. }
  6001. serversDraft.forEach((srv, idx) => {
  6002. const row = document.createElement('div');
  6003. row.className = 'sv-row';
  6004.  
  6005. const defLabel = document.createElement('label');
  6006. defLabel.className = 'sv-default';
  6007. const defRadio = document.createElement('input');
  6008. defRadio.type = 'radio';
  6009. defRadio.name = 'sv-default-radio';
  6010. defRadio.checked = srv.id === serversDraftDefault;
  6011. defRadio.addEventListener('change', () => { serversDraftDefault = srv.id; });
  6012. defLabel.appendChild(defRadio);
  6013. defLabel.appendChild(document.createTextNode('default'));
  6014.  
  6015. const nameEl = document.createElement('input');
  6016. nameEl.type = 'text';
  6017. nameEl.placeholder = 'Name (e.g. JP Server)';
  6018. nameEl.value = srv.name || '';
  6019. nameEl.addEventListener('input', () => { srv.name = nameEl.value; });
  6020.  
  6021. const tzEl = createTzPicker(srv.tz || 'UTC', (newTz) => {
  6022. srv.tz = newTz;
  6023. });
  6024.  
  6025. const del = document.createElement('button');
  6026. del.className = 'mini-btn danger';
  6027. del.textContent = 'ร—';
  6028. del.title = 'Remove';
  6029. del.addEventListener('click', () => {
  6030. serversDraft.splice(idx, 1);
  6031. if (serversDraftDefault === srv.id) {
  6032. serversDraftDefault = serversDraft[0] ? serversDraft[0].id : null;
  6033. }
  6034. renderServerList();
  6035. });
  6036.  
  6037. row.appendChild(defLabel);
  6038. row.appendChild(nameEl);
  6039. row.appendChild(tzEl);
  6040. row.appendChild(del);
  6041. list.appendChild(row);
  6042. });
  6043. }
  6044.  
  6045. document.getElementById('btn-servers').addEventListener('click', openServersDialog);
  6046. document.getElementById('sv-add').addEventListener('click', () => {
  6047. const newSrv = { id: uid('s'), name: 'New Server', tz: 'UTC' };
  6048. serversDraft.push(newSrv);
  6049. if (!serversDraftDefault) serversDraftDefault = newSrv.id;
  6050. renderServerList();
  6051. });
  6052. document.getElementById('sv-save').addEventListener('click', () => {
  6053. // Sanity: ensure defaultServerId still refers to an existing server
  6054. if (serversDraftDefault && !serversDraft.find(s => s.id === serversDraftDefault)) {
  6055. serversDraftDefault = serversDraft[0] ? serversDraft[0].id : null;
  6056. }
  6057. state.meta.servers = serversDraft;
  6058. state.meta.defaultServerId = serversDraftDefault;
  6059. document.getElementById('dlg-servers').close();
  6060. updateServerButton();
  6061. // Timeline event list previews show TZ-dependent dates, so rerender props panel if open
  6062. renderPropsPanel();
  6063. // Rebuild widget content previews to reflect new TZ in date formatting
  6064. state.widgets.forEach(w => refreshWidgetContent(w));
  6065. onStateChanged();
  6066. toast('Servers saved');
  6067. });
  6068.  
  6069. // Export JSON (just the schema, not the wrapped OP)
  6070. document.getElementById('btn-export').addEventListener('click', () => {
  6071. const payload = buildPayloadWithResolvedLanes();
  6072. const text = JSON.stringify(payload, null, 2);
  6073. const blob = new Blob([text], { type: 'application/json' });
  6074. const url = URL.createObjectURL(blob);
  6075. const a = document.createElement('a');
  6076. a.href = url;
  6077. a.download = 'op-grid.json';
  6078. a.click();
  6079. setTimeout(() => URL.revokeObjectURL(url), 1000);
  6080. });
  6081.  
  6082. // Import
  6083. document.getElementById('btn-import').addEventListener('click', () => {
  6084. document.getElementById('import-text').value = '';
  6085. document.getElementById('import-error').textContent = '';
  6086. document.getElementById('dlg-import').showModal();
  6087. });
  6088. document.getElementById('import-confirm').addEventListener('click', () => {
  6089. const text = document.getElementById('import-text').value;
  6090. const errEl = document.getElementById('import-error');
  6091. const data = tryExtractImport(text);
  6092. if (!data) {
  6093. errEl.textContent = 'Could not find valid grid data. Paste either a full OP (with [code]@@grid-v1@@ ...[/code]) or just the JSON.';
  6094. return;
  6095. }
  6096. applyImportedData(data);
  6097. document.getElementById('dlg-import').close();
  6098. toast('Imported');
  6099. });
  6100.  
  6101. function tryExtractImport(text) {
  6102. // 1) Full OP with [code] block
  6103. const re = /\[code\]([\s\S]*?)\[\/code\]/gi;
  6104. let m, last = null;
  6105. while ((m = re.exec(text)) !== null) {
  6106. if (m[1].includes(MAGIC)) last = m;
  6107. }
  6108. if (last) {
  6109. const inner = last[1].trim();
  6110. const idx = inner.indexOf(MAGIC);
  6111. const json = inner.slice(idx + MAGIC.length).trim();
  6112. try { const d = JSON.parse(json); if (d && Array.isArray(d.widgets)) return d; } catch {}
  6113. }
  6114. // 2) Maybe they pasted just the marker + JSON
  6115. const idx2 = text.indexOf(MAGIC);
  6116. if (idx2 !== -1) {
  6117. try { const d = JSON.parse(text.slice(idx2 + MAGIC.length).trim()); if (d && Array.isArray(d.widgets)) return d; } catch {}
  6118. }
  6119. // 3) Raw JSON
  6120. try {
  6121. const d = JSON.parse(text.trim());
  6122. if (d && Array.isArray(d.widgets)) return d;
  6123. } catch {}
  6124. return null;
  6125. }
  6126.  
  6127. function applyImportedData(data) {
  6128. state.meta = data.meta || { title: '' };
  6129. if (!Array.isArray(state.meta.servers)) state.meta.servers = [];
  6130. if (!('defaultServerId' in state.meta)) state.meta.defaultServerId = null;
  6131. // Drop defaultServerId if it references a nonexistent server
  6132. if (state.meta.defaultServerId && !state.meta.servers.find(s => s.id === state.meta.defaultServerId)) {
  6133. state.meta.defaultServerId = state.meta.servers[0] ? state.meta.servers[0].id : null;
  6134. }
  6135. state.widgets = (data.widgets || []).map(w => ({ ...w, id: w.id || uid('w') }));
  6136. state.selectedId = null;
  6137. updateServerButton();
  6138. reloadGrid();
  6139. renderPropsPanel();
  6140. onStateChanged();
  6141. }
  6142.  
  6143. // ===== BOTTOM PANEL TOGGLE ================================================
  6144. document.getElementById('bottom-header').addEventListener('click', () => {
  6145. document.getElementById('bottom').classList.toggle('collapsed');
  6146. });
  6147.  
  6148. // ===== INIT ===============================================================
  6149. let threadFetched = false;
  6150.  
  6151. function init() {
  6152. initGrid();
  6153. const saved = load();
  6154. if (saved) {
  6155. applyImportedData(saved);
  6156. } else {
  6157. updateServerButton();
  6158. updateOutput();
  6159. // Auto-fetch current thread's template on first open
  6160. if (!threadFetched && typeof threadId !== 'undefined' && threadId) {
  6161. threadFetched = true;
  6162. fetchThreadTemplate(threadId);
  6163. }
  6164. }
  6165. }
  6166.  
  6167. function fetchThreadTemplate(tid) {
  6168. const boardPath = window.location.pathname.split('/res/')[0];
  6169. fetch(`${boardPath}/res/${tid}.json`)
  6170. .then(r => {
  6171. if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
  6172. return r.json();
  6173. })
  6174. .then(json => {
  6175. const message = json.message;
  6176. if (!message) return;
  6177. const data = tryExtractImport(message);
  6178. if (data) {
  6179. applyImportedData(data);
  6180. toast('Loaded thread template');
  6181. }
  6182. })
  6183. .catch(e => {
  6184. console.warn('[grid-editor] Could not fetch thread template:', e.message);
  6185. });
  6186. }
  6187.  
  6188. init();
  6189. })();
  6190. }
  6191. })();
Add Comment
Please, Sign In to add comment