Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // ==UserScript==
- // @name Grid OP Editor
- // @namespace grid-op-editor
- // @version 1.0
- // @description Visual grid layout editor for forum OPs
- // @match https://8chan.st/*/res/*.html
- // @match https://8chan.moe/*/res/*.html
- // @match http://alephchvkipd2houttjirmgivro5pxullvcgm4c47ptm7mhubbja6kad.onion/*/res/*.html
- // @require https://cdn.jsdelivr.net/npm/gridstack@12/dist/gridstack-all.js
- // @resource gridstackCSS https://cdn.jsdelivr.net/npm/gridstack@12/dist/gridstack.min.css
- // @grant GM_addStyle
- // @grant GM_getResourceText
- // @grant GM_registerMenuCommand
- // ==/UserScript==
- (function() {
- 'use strict';
- // Load Gridstack CSS
- GM_addStyle(GM_getResourceText('gridstackCSS'));
- // Editor CSS (scoped to #grid-editor-overlay)
- const EDITOR_CSS = `
- #grid-editor-overlay {
- position: fixed;
- inset: 0;
- z-index: 99999;
- display: none;
- overflow: hidden;
- }
- #grid-editor-overlay.is-open {
- display: block;
- }
- :root {
- /* Editor's own theme โ not using forum vars here because the editor is a standalone tool */
- --e-bg: var(--background-color, #f4f5f8);
- --e-panel: var(--menu-color, #ffffff);
- --e-border: var(--border-color, #cfd3dc);
- --e-text: var(--text-color, #1a1a1a);
- --e-muted: var(--text-color-muted, #666);
- --e-accent: var(--subject-color, #34345C);
- --e-accent-hover: var(--link-color, #1f1f3d);
- --e-danger: var(--alert-color, #c0392b);
- --e-ok: #2d7a2d;
- --e-warn: #b38600;
- --e-radius: 3px;
- --e-gap: 8px;
- }
- #grid-editor-overlay, #grid-editor-overlay *, #grid-editor-overlay *::before, #grid-editor-overlay *::after { box-sizing: border-box; }
- #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; }
- .app { display: grid; grid-template-rows: auto 1fr auto; height: 100%; }
- .topbar {
- display: flex; align-items: center; gap: var(--e-gap);
- padding: 8px 12px; background: var(--e-panel); border-bottom: 1px solid var(--e-border);
- flex-wrap: wrap;
- }
- .topbar h1 { margin: 0; font-size: 15px; font-weight: 600; margin-right: 12px; }
- .topbar .sep { flex: 1; }
- .topbar input[type="text"] { flex: 0 1 280px; min-width: 180px; }
- .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; }
- .topbar .count.warn { background: color-mix(in srgb, var(--e-warn) 15%, var(--e-panel)); color: var(--e-warn); }
- .topbar .count.over { background: color-mix(in srgb, var(--e-danger) 15%, var(--e-panel)); color: var(--e-danger); }
- .main { display: grid; grid-template-columns: 200px 1fr 320px; overflow: hidden; }
- .sidebar {
- background: var(--e-panel); border-right: 1px solid var(--e-border);
- padding: 12px; overflow-y: auto;
- }
- .sidebar-right { border-right: none; border-left: 1px solid var(--e-border); }
- .sidebar h3 { margin: 0 0 8px; font-size: 12px; text-transform: uppercase; color: var(--e-muted); letter-spacing: 0.5px; }
- .sidebar .add-btn {
- display: block; width: 100%; text-align: left; padding: 8px 10px; margin-bottom: 4px;
- background: var(--e-panel); border: 1px solid var(--e-border); border-radius: var(--e-radius);
- cursor: pointer; font-size: 13px;
- }
- .sidebar .add-btn:hover { border-color: var(--e-accent); background: color-mix(in srgb, var(--e-accent) 8%, transparent); }
- .sidebar .add-btn .ic { display: inline-block; width: 14px; text-align: center; margin-right: 6px; font-weight: bold; color: var(--e-accent); }
- .grid-container {
- overflow: auto; padding: 0px; background: var(--e-bg);
- background-image:
- linear-gradient(to right, rgba(0,0,0,0.04) 1px, transparent 1px),
- linear-gradient(to bottom, rgba(0,0,0,0.04) 1px, transparent 1px);
- background-size: calc(100% / 18) 50px;
- }
- .grid-stack { background: transparent; }
- .grid-stack-item-content {
- background: var(--e-panel); border: 1px solid var(--e-border); border-radius: var(--e-radius);
- padding: 6px; overflow: hidden; display: flex; flex-direction: column;
- font-size: 12px;
- }
- .grid-stack-item-content:hover { border-color: var(--e-accent); }
- .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); }
- .w-type-tag {
- font-size: 9px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--e-muted);
- margin-bottom: 3px; flex-shrink: 0;
- }
- .w-title { font-weight: 600; font-size: 12px; margin-bottom: 4px; flex-shrink: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
- .w-preview { font-size: 11px; color: var(--e-muted); flex: 1; overflow: hidden; line-height: 1.3; }
- .w-preview .chip {
- display: inline-block; background: color-mix(in srgb, var(--e-accent) 10%, var(--e-panel)); border-radius: 2px; padding: 1px 5px; margin: 1px;
- font-size: 10px;
- }
- .w-delete {
- position: absolute; top: 2px; right: 2px; z-index: 2;
- background: var(--e-panel); border: 1px solid var(--e-border); border-radius: 50%;
- width: 18px; height: 18px; line-height: 14px; text-align: center; font-size: 14px;
- cursor: pointer; padding: 0; color: var(--e-danger); display: none;
- }
- .grid-stack-item:hover .w-delete, .grid-stack-item.selected .w-delete { display: block; }
- .w-delete:hover { background: var(--e-danger); color: white; }
- /* Properties panel */
- .props-panel { padding: 12px; }
- .props-empty { color: var(--e-muted); font-style: italic; text-align: center; padding: 24px 8px; }
- .field { margin-bottom: 10px; }
- .field label { display: block; font-size: 11px; font-weight: 600; margin-bottom: 3px; color: var(--e-muted); text-transform: uppercase; letter-spacing: 0.5px; }
- .field input[type="text"], .field input[type="url"], .field input[type="datetime-local"], .field textarea, .field select {
- width: 100%; padding: 5px 7px; border: 1px solid var(--e-border); border-radius: var(--e-radius);
- font: inherit; font-size: 12px; background: var(--e-bg); color: var(--e-text);
- }
- .field textarea { resize: vertical; min-height: 60px; font-family: inherit; }
- .field input[type="color"] { width: 40px; height: 26px; padding: 0; border: 1px solid var(--e-border); border-radius: var(--e-radius); cursor: pointer; }
- .field-row { display: flex; gap: 6px; align-items: center; }
- .field-row > * { flex: 1; }
- .field-row .fixed { flex: 0 0 auto; }
- .list-header {
- display: flex; justify-content: space-between; align-items: center;
- margin: 12px 0 6px; padding-bottom: 4px; border-bottom: 1px solid var(--e-border);
- }
- .list-header h4 { margin: 0; font-size: 12px; text-transform: uppercase; color: var(--e-muted); letter-spacing: 0.5px; }
- .list-items { display: flex; flex-direction: column; gap: 4px; }
- .list-item {
- padding: 0px 8px; background: var(--e-bg); border: 1px solid var(--e-border); border-radius: var(--e-radius);
- font-size: 11px; display: flex; justify-content: space-between; align-items: center;
- cursor: pointer;
- }
- .list-item:hover { background: color-mix(in srgb, var(--e-accent) 8%, transparent); border-color: var(--e-accent); }
- .list-item .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 4px; }
- .list-item .actions { display: flex; gap: 2px; }
- .mini-btn {
- background: none; border: 1px solid var(--e-border); border-radius: 2px;
- width: 20px; height: 20px; cursor: pointer; font-size: 11px; padding: 0;
- color: var(--e-muted);
- }
- .mini-btn:hover { background: var(--e-accent); color: white; border-color: var(--e-accent); }
- .mini-btn.danger:hover { background: var(--e-danger); border-color: var(--e-danger); }
- .lang-url-row {
- display: flex; gap: 4px; align-items: center; margin-top: 4px;
- }
- .lang-url-row input[type="text"] {
- width: 40px; flex: 0 0 40px; text-transform: uppercase; text-align: center;
- padding: 5px 4px; font-size: 11px; font-weight: 600;
- border: 1px solid var(--e-border); border-radius: var(--e-radius);
- }
- .lang-url-row input[type="url"] {
- flex: 1; min-width: 0;
- padding: 5px 7px; font: inherit; font-size: 12px;
- border: 1px solid var(--e-border); border-radius: var(--e-radius);
- }
- .grid-stack > .grid-stack-item > .grid-stack-item-content { overflow: hidden !important;}
- button.btn {
- padding: 5px 10px; font: inherit; font-size: 12px; cursor: pointer;
- border: 1px solid var(--e-border); border-radius: var(--e-radius); background: var(--e-panel);
- }
- button.btn:hover { background: color-mix(in srgb, var(--e-accent) 8%, transparent); border-color: var(--e-accent); }
- button.btn.primary { background: var(--e-accent); color: white; border-color: var(--e-accent); }
- button.btn.primary:hover { background: var(--e-accent-hover); }
- button.btn.danger { color: var(--e-danger); }
- button.btn.danger:hover { background: var(--e-danger); color: white; border-color: var(--e-danger); }
- /* Bottom panel - output preview */
- .bottom {
- background: var(--e-panel); border-top: 1px solid var(--e-border);
- max-height: 40vh; display: flex; flex-direction: column;
- }
- .bottom-header {
- padding: 6px 12px; display: flex; align-items: center; gap: 8px;
- cursor: pointer; user-select: none; background: var(--e-bg); border-bottom: 1px solid var(--e-border);
- }
- .bottom-header h3 { margin: 0; font-size: 12px; text-transform: uppercase; color: var(--e-muted); letter-spacing: 0.5px; }
- .bottom-header .arrow { transition: transform 0.15s; }
- .bottom.collapsed .bottom-header .arrow { transform: rotate(-90deg); }
- .bottom.collapsed .bottom-body { display: none; }
- .bottom-body { padding: 10px 12px; overflow: auto; display: flex; gap: 10px; flex: 1; min-height: 0; }
- .bottom-body textarea {
- flex: 1; min-height: 140px; font: 11px/1.4 Consolas, Monaco, monospace;
- border: 1px solid var(--e-border); border-radius: var(--e-radius); padding: 6px; background: var(--e-bg);
- resize: none; white-space: pre; overflow: auto;
- }
- /* Dialogs */
- #grid-editor-overlay dialog {
- border: 1px solid var(--e-border); border-radius: var(--e-radius); padding: 0;
- max-width: 90vw; width: 500px; background: var(--e-panel); color: var(--e-text);
- }
- #grid-editor-overlay dialog::backdrop { background: rgba(0,0,0,0.35); }
- .dlg-header { padding: 10px 14px; border-bottom: 1px solid var(--e-border); display: flex; justify-content: space-between; align-items: center; }
- .dlg-header h3 { margin: 0; font-size: 14px; }
- .dlg-body { padding: 14px; max-height: 70vh; overflow-y: auto; }
- .dlg-footer { padding: 10px 14px; border-top: 1px solid var(--e-border); display: flex; justify-content: flex-end; gap: 6px; }
- #grid-editor-overlay .toast {
- position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
- background: var(--e-text); color: white; padding: 8px 16px; border-radius: var(--e-radius);
- font-size: 12px; z-index: 10000; opacity: 0; transition: opacity 0.2s;
- pointer-events: none;
- }
- #grid-editor-overlay .toast.show { opacity: 0.95; }
- /* Server rows in the Servers dialog */
- .sv-row {
- display: grid; grid-template-columns: auto 1fr 1fr auto;
- gap: 6px; align-items: center;
- padding: 6px; border: 1px solid var(--e-border); border-radius: var(--e-radius);
- background: var(--e-bg);
- }
- .sv-row input, .sv-row select {
- padding: 4px 6px; font: inherit; font-size: 12px;
- border: 1px solid var(--e-border); border-radius: var(--e-radius); background: white;
- width: 100%;
- }
- .sv-row .sv-default {
- display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--e-muted);
- white-space: nowrap; cursor: pointer;
- }
- #btn-servers.has-config { border-color: var(--e-accent); }
- /* Timezone picker โ searchable combobox with live offset badge and dropdown. */
- .tz-picker { position: relative; display: flex; align-items: center; gap: 4px; min-width: 0; }
- .tz-picker-input {
- flex: 1; min-width: 0;
- padding: 4px 6px; font: inherit; font-size: 12px;
- border: 1px solid var(--e-border); border-radius: var(--e-radius); background: white;
- }
- .tz-picker-badge {
- flex: 0 0 auto; font-size: 10px; color: var(--e-muted);
- padding: 2px 5px; background: color-mix(in srgb, var(--e-accent) 12%, var(--e-panel)); border-radius: 3px;
- min-width: 42px; text-align: center; white-space: nowrap;
- }
- .tz-picker-badge-invalid { background: color-mix(in srgb, var(--e-danger) 15%, var(--e-panel)); color: var(--e-danger); }
- .tz-picker-list {
- display: none;
- position: absolute; left: 0; top: 100%; margin-top: 2px; z-index: 50;
- background: white; border: 1px solid var(--e-border); border-radius: var(--e-radius);
- max-height: 260px; width: max(100%, 320px); overflow-y: auto;
- box-shadow: 0 4px 16px rgba(0,0,0,0.12);
- font-size: 12px;
- }
- .tz-picker-list-open { display: block; }
- .tz-picker-row {
- display: flex; align-items: center; justify-content: space-between;
- gap: 10px; padding: 4px 8px; cursor: pointer;
- }
- .tz-picker-row:hover { background: var(--e-accent-tint, #eef2ff); }
- .tz-picker-row-selected { background: var(--e-accent-tint, #eef2ff); font-weight: bold; }
- .tz-picker-row-name { color: var(--e-fg, #000); }
- .tz-picker-row-off { color: var(--e-muted); font-size: 11px; white-space: nowrap; }
- .tz-picker-more {
- padding: 4px 8px; font-size: 11px; font-style: italic; color: var(--e-muted);
- text-align: center;
- }
- /* Preview overlay: full-screen, forum-themed container for the live renderer */
- #grid-editor-overlay #preview-overlay {
- position: fixed; inset: 0; z-index: 9999;
- background: rgba(20, 22, 35, 0.75);
- display: flex; flex-direction: column;
- }
- #grid-editor-overlay #preview-overlay[hidden] { display: none; }
- .preview-topbar {
- display: flex; align-items: center; gap: 12px;
- padding: 8px 14px; background: var(--e-panel); border-bottom: 1px solid var(--e-border);
- }
- .preview-title { flex: 1; font-size: 13px; color: var(--e-muted); }
- .preview-toggles { display: flex; gap: 12px; font-size: 12px; }
- .preview-toggles label { cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
- .preview-stage {
- flex: 1; overflow: auto;
- display: flex; justify-content: center; align-items: flex-start;
- padding: 20px;
- }
- .preview-post {
- /* Inherit the forum's theme variables so the preview matches the actual page */
- width: 90%;
- background: var(--background-gradient, var(--background-color, #eef2ff));
- border: 1px solid var(--horizon-sep-color, #b7c5d9);
- padding: 10px;
- color: var(--text-color, black);
- font: 10pt Arial, Helvetica, sans-serif;
- }
- .preview-post.mobile { max-width: 420px; }
- /* When mobile preview is active, force the renderer's single-column layout
- regardless of viewport size (the renderer's own media query uses innerWidth). */
- .preview-post.mobile .gr-root {
- display: flex !important;
- flex-direction: column !important;
- grid-auto-rows: auto !important;
- }
- .preview-post.mobile .gr-widget {
- grid-column: unset !important;
- grid-row: unset !important;
- min-height: 120px;
- height: auto;
- }
- .preview-post.mobile .gr-widget-timeline .gr-widget-body { min-height: 200px; }
- @media (max-width: 900px) {
- .main { grid-template-columns: 150px 1fr 260px; }
- }
- @media (max-width: 700px) {
- .main { grid-template-columns: 1fr; grid-template-rows: auto auto 1fr; }
- .sidebar, .sidebar-right { max-height: 200px; }
- .sidebar-right { order: 3; }
- }
- `;
- GM_addStyle(EDITOR_CSS);
- let editorOpen = false;
- let editorInitialized = false;
- function toggleEditor() {
- if (!editorInitialized) {
- initEditor();
- editorInitialized = true;
- }
- editorOpen = !editorOpen;
- document.getElementById('grid-editor-overlay').classList.toggle('is-open', editorOpen);
- document.body.style.overflow = editorOpen ? 'hidden' : '';
- }
- GM_registerMenuCommand('Toggle Grid Editor', toggleEditor);
- // Keyboard shortcut: Escape closes the editor
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape' && editorOpen) {
- // Don't close if a dialog is open
- const openDialog = document.querySelector('#grid-editor-overlay dialog[open]');
- if (openDialog) return;
- toggleEditor();
- }
- });
- function initEditor() {
- // Create overlay
- const overlay = document.createElement('div');
- overlay.id = 'grid-editor-overlay';
- overlay.innerHTML = `<div class="app">
- <div class="topbar">
- <h1>Grid OP Editor</h1>
- <span class="count" id="char-count" title="Total OP characters">OP: 0 / 12,000</span>
- <span class="count" id="code-count" title="Code block characters">[code]: 0</span>
- <span class="sep"></span>
- <button class="btn" id="btn-servers">Servers</button>
- <button class="btn" id="btn-fetch-thread">Fetch</button>
- <button class="btn" id="btn-import">Import</button>
- <button class="btn" id="btn-export">Export JSON</button>
- <button class="btn" id="btn-preview">Preview</button>
- <button class="btn primary" id="btn-copy">Copy OP</button>
- <button class="btn" id="btn-copy-code">Copy [code]</button>
- <button class="btn danger" id="btn-clear">Clear</button>
- <button class="btn" id="btn-close-editor" style="margin-left:auto;">โ Close</button>
- </div>
- <div class="main">
- <div class="sidebar">
- <h3>Add widget</h3>
- <button class="add-btn" data-add="generic"><span class="ic">๐</span>Generic</button>
- <button class="add-btn" data-add="timeline"><span class="ic">๐ </span>Timeline</button>
- <button class="add-btn" data-add="countdown"><span class="ic">โฑ๏ธ</span>Countdown</button>
- <button class="add-btn" data-add="list"><span class="ic">๐</span>List</button>
- <button class="add-btn" data-add="link"><span class="ic">๐</span>Link</button>
- <button class="add-btn" data-add="attachments"><span class="ic">๐</span>Attachments</button>
- <button class="add-btn" data-add="content"><span class="ic">๐</span>Thread text</button>
- <h3 style="margin-top: 20px;">Tips</h3>
- <div style="font-size: 11px; color: var(--e-muted); line-height: 1.5;">
- Drag widgets to move. Drag the bottom-right corner to resize. Click to edit properties. Mobile view collapses everything to a single column.
- </div>
- </div>
- <div class="grid-container">
- <div class="grid-stack"></div>
- </div>
- <div class="sidebar sidebar-right">
- <div class="props-panel" id="props-panel">
- <div class="props-empty">Select a widget to edit its properties, or click "Add widget" to create one.</div>
- </div>
- </div>
- </div>
- <div class="bottom collapsed" id="bottom">
- <div class="bottom-header" id="bottom-header">
- <span class="arrow">โผ</span>
- <h3>Output preview</h3>
- <span style="color: var(--e-muted); font-size: 11px;">(click to toggle)</span>
- </div>
- <div class="bottom-body">
- <textarea id="output-preview" readonly spellcheck="false"></textarea>
- </div>
- </div>
- </div>
- <div class="toast" id="toast"></div>
- <!-- Preview overlay: shows a live render of the current grid using renderer.js (non-interactive) -->
- <div id="preview-overlay" hidden>
- <div class="preview-topbar">
- <div class="preview-title">Preview โ press Esc or click outside to close</div>
- <div class="preview-toggles">
- <label><input type="radio" name="preview-width" value="desktop" checked> Desktop</label>
- <label><input type="radio" name="preview-width" value="mobile"> Mobile</label>
- </div>
- <button class="btn" id="preview-close">Close</button>
- </div>
- <div class="preview-stage" id="preview-stage">
- <div class="preview-post" id="preview-post"></div>
- </div>
- </div>
- <!-- Dialog: import -->
- <dialog id="dlg-import">
- <div class="dlg-header"><h3>Import</h3></div>
- <div class="dlg-body">
- <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>
- <textarea id="import-text" style="width:100%;height:220px;font:11px/1.4 Consolas,Monaco,monospace;padding:6px;"></textarea>
- <div id="import-error" style="color:var(--e-danger);font-size:12px;margin-top:6px;"></div>
- </div>
- <div class="dlg-footer">
- <button class="btn" data-close>Cancel</button>
- <button class="btn primary" id="import-confirm">Import</button>
- </div>
- </dialog>
- <!-- Dialog: timeline event editor -->
- <dialog id="dlg-event">
- <div class="dlg-header"><h3 id="dlg-event-title">Edit event</h3></div>
- <div class="dlg-body">
- <div class="field">
- <label>Name</label>
- <input type="text" id="evt-name">
- </div>
- <div class="field">
- <label>Start</label>
- <input type="datetime-local" id="evt-start">
- </div>
- <div class="field">
- <label><input type="checkbox" id="evt-has-end" style="vertical-align:middle;"> Has end date</label>
- </div>
- <div class="field" id="evt-end-wrap">
- <label>End</label>
- <input type="datetime-local" id="evt-end">
- </div>
- <div class="field-row">
- <div class="field" style="flex:0 0 auto;">
- <label>Color</label>
- <input type="color" id="evt-color">
- </div>
- <div class="field" style="flex:1;">
- <label>Lane (optional)</label>
- <input type="text" id="evt-lane" inputmode="numeric" pattern="[0-9]*" placeholder="auto">
- </div>
- </div>
- <div class="field">
- <label>Background image URL (optional)</label>
- <input type="url" id="evt-bg" placeholder="https://...">
- </div>
- <div class="field">
- <label>Popup description (optional)</label>
- <textarea id="evt-text"></textarea>
- </div>
- </div>
- <div class="dlg-footer">
- <button class="btn" data-close>Cancel</button>
- <button class="btn primary" id="evt-save">Save</button>
- </div>
- </dialog>
- <!-- Dialog: list item editor -->
- <dialog id="dlg-list-item">
- <div class="dlg-header"><h3 id="dlg-list-item-title">Edit list item</h3></div>
- <div class="dlg-body">
- <div class="field"><label>Title</label><input type="text" id="li-title"></div>
- <div id="li-url-section">
- <div class="field" id="li-url-single">
- <label>Link URL (optional)</label>
- <div class="field-row">
- <input type="url" id="li-url" placeholder="https://..." style="flex:1;">
- <button class="btn fixed" id="li-add-lang" type="button" style="font-size:11px;" title="Convert to multilingual URLs">+ Lang</button>
- </div>
- </div>
- <div id="li-url-multi" style="display:none;">
- <div class="field">
- <label>Link URLs by language</label>
- <div id="li-lang-urls"></div>
- <div style="margin-top:4px;">
- <button class="btn" id="li-add-lang-row" type="button" style="font-size:11px;">+ Language</button>
- </div>
- </div>
- </div>
- </div>
- <div class="field">
- <label>Background image URL (optional)</label>
- <input type="url" id="li-bg" placeholder="https://...">
- </div>
- <div class="field">
- <label>Text (optional, shown in some display modes)</label>
- <textarea id="li-text" rows="4"></textarea>
- </div>
- </div>
- <div class="dlg-footer">
- <button class="btn" data-close>Cancel</button>
- <button class="btn primary" id="li-save">Save</button>
- </div>
- </dialog>
- <!-- Dialog: countdown phase editor -->
- <dialog id="dlg-phase">
- <div class="dlg-header"><h3 id="dlg-phase-title">Edit phase</h3></div>
- <div class="dlg-body">
- <div class="field"><label>Phase title</label><input type="text" id="ph-title"></div>
- <div class="field">
- <label>Target date/time (server wall time)</label>
- <input type="datetime-local" id="ph-target">
- </div>
- <div class="field">
- <label>Ended message (optional)</label>
- <input type="text" id="ph-endedtext" placeholder="Ended">
- </div>
- <div class="field"><label>Background image URL (optional)</label><input type="url" id="ph-bg" placeholder="https://..."></div>
- <div class="field" id="ph-bg-options" style="display:none;">
- <label>Position</label>
- <select id="ph-bgmode">
- <option value="cover">Cover (fill widget, may crop)</option>
- <option value="contain">Contain (fit inside, no crop)</option>
- <option value="corner-tl">Corner: top-left</option>
- <option value="corner-tr">Corner: top-right</option>
- <option value="corner-bl">Corner: bottom-left</option>
- <option value="corner-br">Corner: bottom-right</option>
- <option value="tile">Tile (repeat)</option>
- </select>
- </div>
- </div>
- <div class="dlg-footer">
- <button class="btn" data-close>Cancel</button>
- <button class="btn primary" id="ph-save">Save</button>
- </div>
- </dialog>
- <!-- Dialog: servers (game regions/timezones) -->
- <dialog id="dlg-servers">
- <div class="dlg-header"><h3>Game servers / timezones</h3></div>
- <div class="dlg-body">
- <p style="margin-top:0;font-size:12px;color:var(--e-muted);">
- 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.
- </p>
- <div class="list-items" id="sv-list" style="gap:6px;"></div>
- <button class="btn" id="sv-add" style="margin-top:8px;">+ Add server</button>
- </div>
- <div class="dlg-footer">
- <button class="btn" data-close>Cancel</button>
- <button class="btn primary" id="sv-save">Save</button>
- </div>
- </dialog>`;
- document.body.appendChild(overlay);
- // Close button
- document.getElementById('btn-close-editor').addEventListener('click', toggleEditor);
- // ===== EMBEDDED RENDERER ===================================================
- // If InteractiveOP is available globally (from custom.js), use it.
- // Otherwise the preview will show placeholders.
- if (typeof InteractiveOP === 'function' && !window.InteractiveOP) {
- window.InteractiveOP = InteractiveOP();
- const style = document.createElement('style');
- style.textContent = window.InteractiveOP.getGridCSS();
- document.head.appendChild(style);
- }
- // Run the editor logic
- (function () {
- 'use strict';
- // ===== CONFIG =============================================================
- const MAX_CHARS = 12000;
- const STORAGE_KEY = 'grid-op-editor-state-v1';
- const MAGIC = '@@grid-v1@@';
- const COLUMNS = 18;
- const CELL_HEIGHT = 50;
- // ===== STATE ==============================================================
- // Canonical state. Grid positions are synced from/to Gridstack on changes.
- const state = {
- meta: { title: '', servers: [], defaultServerId: null },
- widgets: [], // array of widget objects (each has id, type, x, y, w, h, ...)
- selectedId: null,
- };
- let grid = null; // Gridstack instance
- let suspendPersist = false;
- // ===== UTILS ==============================================================
- const uid = (() => {
- const used = new Set();
- return (prefix) => {
- let id;
- do {
- id = (prefix || 'w') + Math.random().toString(36).slice(2, 6);
- } while (used.has(id));
- used.add(id);
- return id;
- };
- })();
- function randomPastel() {
- const h = Math.floor(Math.random() * 360);
- const s = 60 + Math.floor(Math.random() * 20); // 60โ80
- const l = 65 + Math.floor(Math.random() * 10); // 65โ75
- return hslToHex(h, s, l);
- }
- function hslToHex(h, s, l) {
- s /= 100; l /= 100;
- const k = n => (n + h / 30) % 12;
- const a = s * Math.min(l, 1 - l);
- const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
- const toHex = x => Math.round(255 * x).toString(16).padStart(2, '0');
- return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
- }
- const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
- function toast(msg, ms = 1800) {
- const t = document.getElementById('toast');
- t.textContent = msg;
- t.classList.add('show');
- clearTimeout(toast._h);
- toast._h = setTimeout(() => t.classList.remove('show'), ms);
- }
- // datetime-local <-> ISO helpers, TZ-aware.
- // tz is an IANA timezone string like "UTC" or "Asia/Tokyo".
- // "datetime-local" inputs carry no TZ info โ we always treat the typed value
- // as wall-clock time in `tz` and convert to/from UTC ISO for storage.
- function isoToInputInTz(iso, tz) {
- if (!iso) return '';
- const d = new Date(iso);
- if (isNaN(d)) return '';
- // Use Intl to extract Y/M/D/h/m as they appear in `tz`.
- const parts = new Intl.DateTimeFormat('en-CA', {
- timeZone: tz || 'UTC',
- year: 'numeric', month: '2-digit', day: '2-digit',
- hour: '2-digit', minute: '2-digit', hour12: false,
- }).formatToParts(d).reduce((o, p) => { o[p.type] = p.value; return o; }, {});
- // hour may come back as "24" in some locales at midnight; normalize.
- const hh = parts.hour === '24' ? '00' : parts.hour;
- return `${parts.year}-${parts.month}-${parts.day}T${hh}:${parts.minute}`;
- }
- // Treat the typed "YYYY-MM-DDTHH:mm" string as wall-clock in `tz` and return UTC ISO.
- // Strategy: guess UTC by offsetting once, then correct using the actual offset at that instant.
- function inputInTzToIso(v, tz) {
- if (!v) return '';
- const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(v);
- if (!m) return '';
- const [_, Y, Mo, D, h, mi] = m.map(x => typeof x === 'string' ? x : x);
- // Build a UTC instant with those numbers (pretend the typed time IS UTC).
- const pretendUtc = Date.UTC(+Y, +Mo - 1, +D, +h, +mi, 0);
- // Find what that instant actually looks like in `tz`, compute offset, correct.
- const offsetMs = tzOffsetMs(new Date(pretendUtc), tz || 'UTC');
- const correctedUtc = pretendUtc - offsetMs;
- return new Date(correctedUtc).toISOString();
- }
- // How many ms is `tz` ahead of UTC at the given instant? (Tokyo โ +9*3600*1000)
- function tzOffsetMs(date, tz) {
- if (!tz || tz === 'UTC') return 0;
- // Format the date in the target TZ and parse it back as if it were UTC.
- const parts = new Intl.DateTimeFormat('en-CA', {
- timeZone: tz,
- year: 'numeric', month: '2-digit', day: '2-digit',
- hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
- }).formatToParts(date).reduce((o, p) => { o[p.type] = p.value; return o; }, {});
- const hh = parts.hour === '24' ? '00' : parts.hour;
- const asIfUtc = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +hh, +parts.minute, +parts.second);
- return asIfUtc - date.getTime();
- }
- // Returns the TZ to use for date inputs and display: default server's TZ, or UTC.
- function currentTz() {
- const servers = (state.meta && state.meta.servers) || [];
- if (servers.length === 0) return 'UTC';
- const def = servers.find(s => s.id === state.meta.defaultServerId) || servers[0];
- return def.tz || 'UTC';
- }
- function currentServerLabel() {
- const servers = (state.meta && state.meta.servers) || [];
- if (servers.length === 0) return 'UTC';
- const def = servers.find(s => s.id === state.meta.defaultServerId) || servers[0];
- return `${def.name} / ${def.tz}`;
- }
- // ===== WIDGET DEFAULTS ====================================================
- function newWidget(type) {
- const base = { id: uid('w'), type, x: 0, y: 0, w: 4, h: 2 };
- // For countdown: default target = 7 days from today at 00:00 server wall time.
- const seed = new Date();
- seed.setDate(seed.getDate() + 7);
- const defaultTarget = `${seed.getFullYear()}-${String(seed.getMonth()+1).padStart(2,'0')}-${String(seed.getDate()).padStart(2,'0')}T00:00`;
- switch (type) {
- case 'generic': return { ...base, title: 'New widget', text: '' };
- case 'timeline': return { ...base, w: 12, h: 5, title: 'Timeline', events: [] };
- case 'countdown': return { ...base, w: 3, h: 2, isCyclic: false, interval: [0, 0, 0], phases: [{ id: uid('ph'), title: 'Countdown', target: defaultTarget, endedText: '', bg: '' }] };
- case 'list': return { ...base, w: 12, h: 3, title: 'List', items: [], displayMode: 'horizontal' };
- case 'link': return { ...base, w: 3, h: 1, title: 'Link', url: '' };
- case 'attachments': return { ...base, w: 12, h: 3, title: 'Attachments' };
- case 'content': return { ...base, w: 12, h: 6, title: '' };
- }
- }
- // ===== PERSISTENCE ========================================================
- function save() {
- if (suspendPersist) return;
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify({ meta: state.meta, widgets: state.widgets }));
- } catch (e) { console.warn('save failed', e); }
- }
- function load() {
- try {
- const s = localStorage.getItem(STORAGE_KEY);
- if (!s) return null;
- return JSON.parse(s);
- } catch (e) { return null; }
- }
- // ===== GRID SYNC ==========================================================
- function buildItemContent(w) {
- // Summary preview inside the grid cell
- let body = '';
- if (w.type === 'generic') {
- body = `<div class="w-preview">${escapeHtml((w.text || '(no text)').slice(0, 120))}${(w.text||'').length>120?'โฆ':''}</div>`;
- } else if (w.type === 'timeline') {
- const count = (w.events || []).length;
- body = `<div class="w-preview"><span class="chip">${count} event${count===1?'':'s'}</span></div>`;
- } else if (w.type === 'list') {
- const count = (w.items || []).length;
- body = `<div class="w-preview"><span class="chip">${count} item${count===1?'':'s'}</span></div>`;
- } else if (w.type === 'link') {
- body = `<div class="w-preview">${escapeHtml(w.url || '(no url)')}</div>`;
- } else if (w.type === 'attachments') {
- body = `<div class="w-preview"><span class="chip">thread attachments</span></div>`;
- } else if (w.type === 'content') {
- body = `<div class="w-preview"><span class="chip">thread text</span></div>`;
- }
- return `
- <button class="w-delete" data-del="${w.id}" title="Delete">ร</button>
- <div class="w-type-tag">${w.type}</div>
- <div class="w-title">${escapeHtml(w.title || (w.phases && w.phases[0] ? w.phases[0].title : '') || '(no title)')}</div>
- ${body}
- `;
- }
- function initGrid() {
- // v11+ removed the default innerHTML-from-content behavior for XSS safety.
- // We trust our own generated content, so reinstate the classic behavior.
- GridStack.renderCB = function (el, w) {
- if (w && typeof w.content === 'string') el.innerHTML = w.content;
- };
- grid = GridStack.init({
- column: COLUMNS,
- cellHeight: CELL_HEIGHT,
- margin: 4,
- float: true, // allow free placement, no auto-compact
- animate: true,
- disableOneColumnMode: true, // we preview desktop layout; mobile collapses in renderer
- handle: '.grid-stack-item-content',
- resizable: { handles: 'se' },
- });
- grid.on('change', (e, items) => {
- // items is array of GridStack nodes that changed
- items.forEach(node => {
- const w = state.widgets.find(x => x.id === node.id);
- if (w) {
- w.x = node.x; w.y = node.y; w.w = node.w; w.h = node.h;
- }
- });
- onStateChanged();
- });
- // Click to select widget (but not when clicking delete button)
- document.querySelector('.grid-stack').addEventListener('click', (e) => {
- if (e.target.closest('.w-delete')) return;
- const gsItem = e.target.closest('.grid-stack-item');
- if (gsItem && gsItem.gridstackNode) {
- selectWidget(gsItem.gridstackNode.id);
- }
- });
- // Delete button
- document.querySelector('.grid-stack').addEventListener('click', (e) => {
- const del = e.target.closest('.w-delete');
- if (del) {
- e.stopPropagation();
- removeWidget(del.dataset.del);
- }
- });
- }
- function addWidgetToGrid(w, opts) {
- // Append via Gridstack so it handles DOM + engine state
- const spec = {
- id: w.id,
- x: w.x, y: w.y, w: w.w, h: w.h,
- content: buildItemContent(w),
- };
- if (opts && opts.autoPosition) {
- spec.autoPosition = true;
- delete spec.x;
- delete spec.y;
- }
- const el = grid.addWidget(spec);
- return el;
- }
- function refreshWidgetContent(w) {
- // Update just the content of an existing grid item
- const el = document.querySelector(`.grid-stack-item[gs-id="${w.id}"]`);
- if (!el) return;
- const content = el.querySelector('.grid-stack-item-content');
- if (content) content.innerHTML = buildItemContent(w);
- // Re-apply selected state visual
- el.classList.toggle('selected', w.id === state.selectedId);
- }
- function reloadGrid() {
- // Wipe and rebuild grid from state.widgets
- suspendPersist = true;
- grid.removeAll();
- state.widgets.forEach(w => addWidgetToGrid(w));
- suspendPersist = false;
- // Re-apply selection highlight
- if (state.selectedId) {
- const el = document.querySelector(`.grid-stack-item[gs-id="${state.selectedId}"]`);
- if (el) el.classList.add('selected');
- }
- }
- function removeWidget(id) {
- const idx = state.widgets.findIndex(w => w.id === id);
- if (idx === -1) return;
- state.widgets.splice(idx, 1);
- const el = document.querySelector(`.grid-stack-item[gs-id="${id}"]`);
- if (el) grid.removeWidget(el);
- if (state.selectedId === id) selectWidget(null);
- onStateChanged();
- }
- // ===== SELECTION / PROPS PANEL ============================================
- function selectWidget(id) {
- state.selectedId = id;
- document.querySelectorAll('.grid-stack-item').forEach(el => el.classList.remove('selected'));
- if (id) {
- const el = document.querySelector(`.grid-stack-item[gs-id="${id}"]`);
- if (el) el.classList.add('selected');
- }
- renderPropsPanel();
- }
- function renderPropsPanel() {
- const panel = document.getElementById('props-panel');
- if (!state.selectedId) {
- panel.innerHTML = '<div class="props-empty">Select a widget to edit its properties, or click "Add widget" to create one.</div>';
- return;
- }
- const w = state.widgets.find(x => x.id === state.selectedId);
- if (!w) { panel.innerHTML = ''; return; }
- // Dispatch by type
- switch (w.type) {
- case 'generic': renderGenericProps(panel, w); break;
- case 'timeline': renderTimelineProps(panel, w); break;
- case 'countdown': renderCountdownProps(panel, w); break;
- case 'list': renderListProps(panel, w); break;
- case 'link': renderLinkProps(panel, w); break;
- case 'attachments': renderAttachmentsProps(panel, w); break;
- case 'content': renderContentProps(panel, w); break;
- }
- // Shared order field appended to every widget's props panel.
- appendOrderField(panel, w);
- // Copy widget JSON button
- const copyWrap = document.createElement('div');
- copyWrap.style.cssText = 'margin-top:8px;';
- const copyBtn = document.createElement('button');
- copyBtn.className = 'btn';
- copyBtn.textContent = 'Copy widget JSON';
- copyBtn.style.cssText = 'width:100%;font-size:11px;';
- copyBtn.addEventListener('click', async () => {
- const json = JSON.stringify(w, null, 2);
- try {
- await navigator.clipboard.writeText(json);
- toast('Widget JSON copied');
- } catch (e) {
- prompt('Copy widget JSON:', json);
- }
- });
- copyWrap.appendChild(copyBtn);
- panel.appendChild(copyWrap);
- }
- // Manual widget order controls both text-output position (within its type group)
- // and mobile view position. Unset = auto-order (falls to end of group).
- function appendOrderField(panel, w) {
- const wrap = document.createElement('div');
- wrap.innerHTML = `
- <hr style="margin:14px 0 8px;border:none;border-top:1px solid var(--e-border);">
- <div class="field" style="display:flex;gap:12px;">
- <label><input type="checkbox" id="p-hideTitle" ${w.hideTitle ? 'checked' : ''}> Hide title</label>
- <label><input type="checkbox" id="p-hideBg" ${w.hideBg ? 'checked' : ''}> Hide background</label>
- </div>
- <div class="field">
- <label>Order (optional)</label>
- <input type="text" id="p-order" inputmode="numeric" pattern="-?[0-9]*" value="${w.order == null ? '' : escapeHtml(String(w.order))}" placeholder="auto">
- <p style="color:var(--e-muted);font-size:11px;margin:3px 0 0;">
- Lower numbers first. Affects text output (within each widget type) and mobile column order. Leave blank for automatic ordering.
- </p>
- </div>
- `;
- panel.appendChild(wrap);
- document.getElementById('p-hideTitle').addEventListener('change', (e) => {
- w.hideTitle = e.target.checked || undefined;
- refreshWidgetContent(w);
- onStateChanged();
- });
- document.getElementById('p-hideBg').addEventListener('change', (e) => {
- w.hideBg = e.target.checked || undefined;
- refreshWidgetContent(w);
- onStateChanged();
- });
- const input = document.getElementById('p-order');
- input.addEventListener('input', () => {
- const raw = input.value.trim();
- if (raw === '') {
- delete w.order;
- } else {
- const n = parseInt(raw, 10);
- if (!Number.isNaN(n)) w.order = n;
- }
- onStateChanged();
- });
- }
- // --- Generic props ---
- function renderGenericProps(panel, w) {
- const bgMode = w.bgMode || 'cover';
- panel.innerHTML = `
- <h3>Generic widget</h3>
- <div class="field"><label>Title *</label><input type="text" id="p-title" value="${escapeHtml(w.title || '')}"></div>
- <div class="field"><label>Background image URL</label><input type="url" id="p-bg" value="${escapeHtml(w.bg || '')}"></div>
- <div id="p-bg-options" style="${w.bg ? '' : 'display:none;'}">
- <div class="field">
- <label>Position</label>
- <select id="p-bgmode">
- <option value="cover">Cover (fill widget, may crop)</option>
- <option value="contain">Contain (fit inside, no crop)</option>
- <option value="corner-tl">Corner: top-left</option>
- <option value="corner-tr">Corner: top-right</option>
- <option value="corner-bl">Corner: bottom-left</option>
- <option value="corner-br">Corner: bottom-right</option>
- <option value="tile">Tile (repeat)</option>
- </select>
- </div>
- <p style="color:var(--e-muted);font-size:11px;margin:4px 0 0;">
- For best readability, use images with transparent backgrounds or dark/subtle areas where text will appear.
- </p>
- </div>
- <div class="field"><label>Hover background URL</label><input type="url" id="p-hoverBg" value="${escapeHtml(w.hoverBg || '')}"></div>
- <div class="field"><label>Text</label><textarea id="p-text" rows="8">${escapeHtml(w.text || '')}</textarea></div>
- `;
- document.getElementById('p-bgmode').value = bgMode;
- bindInput('p-title', v => { w.title = v; refreshWidgetContent(w); onStateChanged(); });
- bindBgInput('p-bg', v => {
- w.bg = v;
- document.getElementById('p-bg-options').style.display = v ? '' : 'none';
- onStateChanged();
- });
- bindInput('p-bgmode', v => { w.bgMode = v; onStateChanged(); });
- bindBgInput('p-hoverBg', v => { w.hoverBg = v; refreshWidgetContent(w); onStateChanged(); });
- bindInput('p-text', v => { w.text = v; refreshWidgetContent(w); onStateChanged(); });
- }
- // --- Link props ---
- function renderLinkProps(panel, w) {
- const bgMode = w.bgMode || 'cover';
- panel.innerHTML = `
- <h3>Link widget</h3>
- <div class="field"><label>Title</label><input type="text" id="p-title" value="${escapeHtml(w.title || '')}"></div>
- <div class="field"><label>URL *</label><input type="url" id="p-url" value="${escapeHtml(w.url || '')}"></div>
- <div class="field"><label>Background image URL</label><input type="url" id="p-bg" value="${escapeHtml(w.bg || '')}"></div>
- <div id="p-bg-options" style="${w.bg ? '' : 'display:none;'}">
- <div class="field">
- <label>Position</label>
- <select id="p-bgmode">
- <option value="cover">Cover (fill widget, may crop)</option>
- <option value="contain">Contain (fit inside, no crop)</option>
- <option value="corner-tl">Corner: top-left</option>
- <option value="corner-tr">Corner: top-right</option>
- <option value="corner-bl">Corner: bottom-left</option>
- <option value="corner-br">Corner: bottom-right</option>
- <option value="tile">Tile (repeat)</option>
- </select>
- </div>
- </div>
- <div class="field"><label>Hover background URL</label><input type="url" id="p-hoverBg" value="${escapeHtml(w.hoverBg || '')}"></div>
- <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>
- `;
- document.getElementById('p-bgmode').value = bgMode;
- bindInput('p-title', v => { w.title = v; refreshWidgetContent(w); onStateChanged(); });
- bindInput('p-url', v => { w.url = v; refreshWidgetContent(w); onStateChanged(); });
- bindBgInput('p-bg', v => {
- w.bg = v;
- document.getElementById('p-bg-options').style.display = v ? '' : 'none';
- refreshWidgetContent(w);
- onStateChanged();
- });
- bindInput('p-bgmode', v => { w.bgMode = v; refreshWidgetContent(w); onStateChanged(); });
- bindBgInput('p-hoverBg', v => { w.hoverBg = v; refreshWidgetContent(w); onStateChanged(); });
- }
- // --- Attachments props ---
- function renderAttachmentsProps(panel, w) {
- panel.innerHTML = `
- <h3>Attachments widget</h3>
- <div class="field"><label>Title</label><input type="text" id="p-title" value="${escapeHtml(w.title || '')}"></div>
- <p style="color:var(--e-muted);font-size:11px;margin-top:12px;line-height:1.5;">
- 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.
- </p>
- `;
- bindInput('p-title', v => { w.title = v; refreshWidgetContent(w); onStateChanged(); });
- }
- function renderContentProps(panel, w) {
- panel.innerHTML = `
- <h3>Thread text widget</h3>
- <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>
- <p style="color:var(--e-muted);font-size:11px;margin-top:12px;line-height:1.5;">
- 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.
- </p>
- `;
- bindInput('p-title', v => { w.title = v; refreshWidgetContent(w); onStateChanged(); });
- }
- // --- Countdown props ---
- function renderCountdownProps(panel, w) {
- // Migrate legacy flat fields into phases array
- if (!w.phases || w.phases.length === 0) {
- w.phases = [{
- id: uid('ph'),
- title: w.title || 'Countdown',
- target: w.target || '',
- endedText: w.endedText || '',
- bg: w.bg || '',
- bgMode: w.bgMode || '',
- }];
- delete w.target;
- delete w.endedText;
- delete w.bg;
- delete w.bgMode;
- delete w.title;
- }
- // Migrate: if w.title exists but phase0 has a generic title, move it
- if (w.title && w.phases[0]) {
- if (!w.phases[0].title || w.phases[0].title === 'Countdown') {
- w.phases[0].title = w.title;
- }
- delete w.title;
- }
- const isSingle = w.phases.length === 1;
- const phase0 = w.phases[0];
- const bgMode = phase0.bgMode || 'cover';
- panel.innerHTML = `
- <h3>Countdown widget</h3>
- <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>
- <div id="p-cd-single" style="${isSingle ? '' : 'display:none'}">
- <div class="field" style="display:flex;gap:8px;align-items:center;">
- <label style="white-space:nowrap;">
- <input type="checkbox" id="p-rawTz" ${w.rawTz != null ? 'checked' : ''}>
- Absolute time
- </label>
- <label id="p-rawTz-offset-label" style="white-space:nowrap;${w.rawTz != null ? '' : 'display:none'}">
- UTC offset:
- <input type="number" id="p-rawTz-offset" style="width:60px;" min="-12" max="14" step="1" value="${w.rawTz ?? 0}">
- </label>
- </div>
- <div class="field">
- <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>
- <input type="datetime-local" id="p-target" value="${escapeHtml(toInputValue(phase0.target || ''))}">
- </div>
- <div class="field">
- <label><input type="checkbox" id="p-isCyclic" ${(w.isCyclic ? "checked" : "")}> Cyclic</label>
- </div>
- <div id="p-cyclic-options" style="${w.isCyclic ? '' : 'display:none'}">
- <div class="field" style="display:flex;gap:12px;">
- <label><input type="radio" name="p-cyclicMode" value="timespan" ${(w.cyclicMode || 'timespan') === 'timespan' ? 'checked' : ''}> Time span</label>
- <label><input type="radio" name="p-cyclicMode" value="datetime" ${w.cyclicMode === 'datetime' ? 'checked' : ''}> Recurring dates</label>
- </div>
- <div id="p-cyclic-timespan" style="${(w.cyclicMode || 'timespan') === 'timespan' ? '' : 'display:none'}">
- <div class="field" style="display:flex;gap:5px;">
- <label>Days: <input style="width:50px;" type="number" id="p-intervalDays" min="0" value="${(w.interval || [])[0] || 0}"></label>
- <label>Hours: <input type="number" id="p-intervalHours" min="0" max="23" value="${(w.interval || [])[1] || 0}"></label>
- <label>Minutes: <input type="number" id="p-intervalMinutes" min="0" max="59" value="${(w.interval || [])[2] || 0}"></label>
- </div>
- </div>
- <div id="p-cyclic-datetime" style="${w.cyclicMode === 'datetime' ? '' : 'display:none'}">
- <div id="p-cyclic-dates"></div>
- <button class="btn" id="p-add-cyclic-date" type="button" style="font-size:11px;margin-top:4px;">+ Add date</button>
- </div>
- </div>
- <div class="field">
- <label>Ended message (optional)</label>
- <input type="text" id="p-endedtext" value="${escapeHtml(phase0.endedText || '')}" placeholder="Ended">
- </div>
- <div class="field"><label>Background image URL</label><input type="url" id="p-bg" value="${escapeHtml(phase0.bg || '')}"></div>
- <div id="p-bg-options" style="${phase0.bg ? '' : 'display:none;'}">
- <div class="field">
- <label>Position</label>
- <select id="p-bgmode">
- <option value="cover">Cover (fill widget, may crop)</option>
- <option value="contain">Contain (fit inside, no crop)</option>
- <option value="corner-tl">Corner: top-left</option>
- <option value="corner-tr">Corner: top-right</option>
- <option value="corner-bl">Corner: bottom-left</option>
- <option value="corner-br">Corner: bottom-right</option>
- <option value="tile">Tile (repeat)</option>
- </select>
- </div>
- </div>
- <div class="field">
- <label>Style</label>
- <select id="p-cdstyle">
- <option value="">Default</option>
- <option value="label">Label</option>
- </select>
- </div>
- <div class="field"><label>Hover background URL</label><input type="url" id="p-hoverBg" value="${escapeHtml(w.hoverBg || '')}"></div>
- <div class="field">
- <button class="btn" id="p-add-phases" type="button">+ Convert to series</button>
- </div>
- </div>
- <div id="p-cd-phases" style="${isSingle ? 'display:none' : ''}">
- <div class="list-header">
- <h4>Phases (${w.phases.length})</h4>
- <span style="display:flex;gap:4px;">
- <button class="btn" id="p-add-phase">+ Phase</button>
- <button class="btn" id="p-remove-phases" title="Convert back to single countdown">ร Series</button>
- </span>
- </div>
- <div class="list-items" id="p-phase-list"></div>
- </div>
- `;
- document.getElementById('p-bgmode').value = bgMode;
- document.getElementById('p-cdstyle').value = w.cdStyle || '';
- // Widget-level bindings
- bindInput('p-title', v => { phase0.title = v; refreshWidgetContent(w); onStateChanged(); });
- bindInput('p-cdstyle', v => { w.cdStyle = v || undefined; refreshWidgetContent(w); onStateChanged(); });
- bindBgInput('p-hoverBg', v => { w.hoverBg = v; refreshWidgetContent(w); onStateChanged(); });
- // Cyclic toggle
- document.getElementById('p-isCyclic').addEventListener('change', (e) => {
- w.isCyclic = e.target.checked;
- document.getElementById('p-cyclic-options').style.display = e.target.checked ? '' : 'none';
- refreshWidgetContent(w);
- onStateChanged();
- });
- // Cyclic mode radio
- document.querySelectorAll('input[name="p-cyclicMode"]').forEach(r => {
- r.addEventListener('change', (e) => {
- w.cyclicMode = e.target.value;
- document.getElementById('p-cyclic-timespan').style.display = e.target.value === 'timespan' ? '' : 'none';
- document.getElementById('p-cyclic-datetime').style.display = e.target.value === 'datetime' ? '' : 'none';
- refreshWidgetContent(w);
- onStateChanged();
- });
- });
- // Timespan inputs
- bindInput('p-intervalDays', v => { if (!w.interval) w.interval = [0,0,0]; w.interval[0] = v; refreshWidgetContent(w); onStateChanged(); });
- bindInput('p-intervalHours', v => { if (!w.interval) w.interval = [0,0,0]; w.interval[1] = v; refreshWidgetContent(w); onStateChanged(); });
- bindInput('p-intervalMinutes', v => { if (!w.interval) w.interval = [0,0,0]; w.interval[2] = v; refreshWidgetContent(w); onStateChanged(); });
- // Recurring dates
- function renderCyclicDates() {
- const container = document.getElementById('p-cyclic-dates');
- container.innerHTML = '';
- (w.cyclicDates || []).forEach((entry, idx) => {
- const row = document.createElement('div');
- row.style.cssText = 'display:flex;gap:4px;align-items:center;margin-top:4px;';
- row.innerHTML = `
- <label style="white-space:nowrap;">Day:
- <input type="number" min="1" max="31" style="width:50px;" value="${entry.day || 1}">
- </label>
- <label style="white-space:nowrap;">Time:
- <input type="time" value="${entry.time || '00:00'}">
- </label>
- <button class="mini-btn danger" type="button" title="Remove">ร</button>
- `;
- row.querySelector('input[type="number"]').addEventListener('input', (e) => {
- entry.day = Number(e.target.value) || 1;
- refreshWidgetContent(w);
- onStateChanged();
- });
- row.querySelector('input[type="time"]').addEventListener('input', (e) => {
- entry.time = e.target.value;
- refreshWidgetContent(w);
- onStateChanged();
- });
- row.querySelector('button').addEventListener('click', () => {
- w.cyclicDates.splice(idx, 1);
- renderCyclicDates();
- refreshWidgetContent(w);
- onStateChanged();
- });
- container.appendChild(row);
- });
- }
- renderCyclicDates();
- document.getElementById('p-add-cyclic-date').addEventListener('click', () => {
- if (!w.cyclicDates) w.cyclicDates = [];
- w.cyclicDates.push({ day: 1, time: '00:00' });
- renderCyclicDates();
- refreshWidgetContent(w);
- onStateChanged();
- });
- // Absolute time toggle
- document.getElementById('p-rawTz').addEventListener('change', (e) => {
- if (e.target.checked) {
- w.rawTz = Number(document.getElementById('p-rawTz-offset').value) || 0;
- } else {
- delete w.rawTz;
- }
- document.getElementById('p-rawTz-offset-label').style.display = e.target.checked ? '' : 'none';
- const label = document.getElementById('p-target-label');
- label.textContent = e.target.checked
- ? `Target date/time (UTC${w.rawTz >= 0 ? '+' : ''}${w.rawTz ?? 0})`
- : 'Target date/time (server wall time)';
- refreshWidgetContent(w);
- onStateChanged();
- });
- document.getElementById('p-rawTz-offset').addEventListener('input', (e) => {
- w.rawTz = Number(e.target.value) || 0;
- const label = document.getElementById('p-target-label');
- label.textContent = `Target date/time (UTC${w.rawTz >= 0 ? '+' : ''}${w.rawTz})`;
- refreshWidgetContent(w);
- onStateChanged();
- });
- // Single-phase bindings โ write to phases[0]
- bindInput('p-target', v => { phase0.target = v; refreshWidgetContent(w); onStateChanged(); });
- bindInput('p-endedtext', v => { phase0.endedText = v; refreshWidgetContent(w); onStateChanged(); });
- bindBgInput('p-bg', v => {
- phase0.bg = v;
- document.getElementById('p-bg-options').style.display = v ? '' : 'none';
- refreshWidgetContent(w);
- onStateChanged();
- });
- bindInput('p-bgmode', v => { phase0.bgMode = v; refreshWidgetContent(w); onStateChanged(); });
- // Convert single โ multi: add a second phase
- document.getElementById('p-add-phases').addEventListener('click', () => {
- const newPhase = { id: uid('ph'), title: '', target: '', endedText: '', bg: '' };
- w.phases.push(newPhase);
- // Clear cyclic since it doesn't apply to multi-phase
- delete w.isCyclic;
- delete w.interval;
- delete w.cyclicMode;
- delete w.cyclicDates;
- openPhaseDialog(w, newPhase, true);
- renderPropsPanel();
- refreshWidgetContent(w);
- onStateChanged();
- });
- // Convert multi โ single: keep only the first phase
- document.getElementById('p-remove-phases').addEventListener('click', () => {
- w.phases = [w.phases[0]];
- renderPropsPanel();
- refreshWidgetContent(w);
- onStateChanged();
- });
- // Add new phase
- document.getElementById('p-add-phase').addEventListener('click', () => {
- const phase = { id: uid('ph'), title: '', target: '', endedText: '', bg: '' };
- w.phases.push(phase);
- openPhaseDialog(w, phase, true);
- });
- // Render phase list (multi-phase mode)
- if (!isSingle) {
- const list = document.getElementById('p-phase-list');
- w.phases.forEach((phase, idx) => {
- const row = document.createElement('div');
- row.className = 'list-item';
- const dateStr = phase.target ? toInputValue(phase.target).replace('T', ' ') : '?';
- row.innerHTML = `
- <span class="name">${escapeHtml(phase.title || '(unnamed)')} ยท ${dateStr}</span>
- <span class="actions">
- <button class="mini-btn" data-up title="Up">โฒ</button>
- <button class="mini-btn" data-down title="Down">โผ</button>
- <button class="mini-btn danger" data-del title="Delete">ร</button>
- </span>
- `;
- row.addEventListener('click', (e) => {
- if (e.target.closest('[data-up]')) { e.stopPropagation(); moveItem(w.phases, idx, -1); renderPropsPanel(); refreshWidgetContent(w); onStateChanged(); return; }
- if (e.target.closest('[data-down]')) { e.stopPropagation(); moveItem(w.phases, idx, +1); renderPropsPanel(); refreshWidgetContent(w); onStateChanged(); return; }
- if (e.target.closest('[data-del]')) {
- e.stopPropagation();
- w.phases.splice(idx, 1);
- // Never let phases be empty โ if one left, switch to single view
- if (w.phases.length === 0) w.phases = [{ id: uid('ph'), title: '', target: '', endedText: '', bg: '' }];
- renderPropsPanel();
- refreshWidgetContent(w);
- onStateChanged();
- return;
- }
- openPhaseDialog(w, phase);
- });
- list.appendChild(row);
- });
- }
- }
- // --- List props ---
- function renderListProps(panel, w) {
- const displayMode = w.displayMode || 'horizontal';
- panel.innerHTML = `
- <h3>List widget</h3>
- <div class="field"><label>Title</label><input type="text" id="p-title" value="${escapeHtml(w.title || '')}"></div>
- <div class="field">
- <label>Default display mode</label>
- <select id="p-displaymode">
- <option value="horizontal">Horizontal strip</option>
- <option value="vertical">Vertical strip</option>
- <option value="accordion">Accordion (collapsible)</option>
- <option value="carousel">Carousel (one at a time)</option>
- </select>
- <p style="color:var(--e-muted);font-size:11px;margin:3px 0 0;">
- Readers can cycle through modes via a button on the widget; their choice is remembered per widget.
- </p>
- </div>
- <div class="list-header">
- <h4>Items (${(w.items||[]).length})</h4>
- <span style="display:flex;gap:4px;">
- <button class="btn" id="p-add-item-top" title="Add to top">+ Top</button>
- <button class="btn" id="p-add-item-end" title="Add to end">+ End</button>
- </span>
- </div>
- <div class="list-items" id="p-items"></div>
- `;
- document.getElementById('p-displaymode').value = displayMode;
- bindInput('p-title', v => { w.title = v; refreshWidgetContent(w); onStateChanged(); });
- bindInput('p-displaymode', v => { w.displayMode = v; refreshWidgetContent(w); onStateChanged(); });
- const list = document.getElementById('p-items');
- (w.items || []).forEach((item, idx) => {
- const row = document.createElement('div');
- row.className = 'list-item';
- row.innerHTML = `
- <span class="name">${escapeHtml(item.title || '(untitled)')}</span>
- <span class="actions">
- <button class="mini-btn" data-up title="Up">โฒ</button>
- <button class="mini-btn" data-down title="Down">โผ</button>
- <button class="mini-btn danger" data-del title="Delete">ร</button>
- </span>
- `;
- row.addEventListener('click', (e) => {
- if (e.target.closest('[data-up]')) { e.stopPropagation(); moveItem(w.items, idx, -1); renderPropsPanel(); refreshWidgetContent(w); onStateChanged(); return; }
- if (e.target.closest('[data-down]')) { e.stopPropagation(); moveItem(w.items, idx, +1); renderPropsPanel(); refreshWidgetContent(w); onStateChanged(); return; }
- if (e.target.closest('[data-del]')) { e.stopPropagation(); w.items.splice(idx, 1); renderPropsPanel(); refreshWidgetContent(w); onStateChanged(); return; }
- openListItemDialog(w, item);
- });
- list.appendChild(row);
- });
- document.getElementById('p-add-item-end').addEventListener('click', () => {
- const item = { id: uid('li'), title: 'New item', url: '', bg: '', text: '' };
- (w.items = w.items || []).push(item);
- openListItemDialog(w, item, true);
- });
- document.getElementById('p-add-item-top').addEventListener('click', () => {
- const item = { id: uid('li'), title: 'New item', url: '', bg: '', text: '' };
- (w.items = w.items || []).unshift(item);
- openListItemDialog(w, item, true);
- });
- }
- // --- Timeline props ---
- function renderTimelineProps(panel, w) {
- panel.innerHTML = `
- <h3>Timeline widget</h3>
- <div class="field"><label>Title</label><input type="text" id="p-title" value="${escapeHtml(w.title || '')}"></div>
- <div class="list-header">
- <h4>Events (${(w.events||[]).length})</h4>
- <button class="btn" id="p-add-event">+ Add</button>
- </div>
- <div class="list-items" id="p-events"></div>
- <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>
- `;
- bindInput('p-title', v => { w.title = v; refreshWidgetContent(w); onStateChanged(); });
- const list = document.getElementById('p-events');
- // Resolve lanes so auto-lane events show their actual computed lane
- const resolvedLanes = resolveLanes(w.events || []);
- // Sort view by resolved lane, then start date
- const viewEvents = (w.events || []).map((ev, idx) => ({ ev, idx, lane: resolvedLanes.get(ev.id) ?? 0 }));
- viewEvents.sort((a, b) => {
- if (a.lane !== b.lane) return a.lane - b.lane;
- return new Date(a.ev.start) - new Date(b.ev.start);
- });
- viewEvents.forEach(({ ev, idx, lane }) => {
- const row = document.createElement('div');
- row.className = 'list-item';
- const dateStr = (() => {
- const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(ev.start || '');
- if (!m) return '?';
- const d = new Date(+m[1], +m[2] - 1, +m[3]);
- const month = d.toLocaleString(undefined, { month: 'short' });
- return `${month} ${+m[3]}`;
- })();
- const laneLabel = ev.lane == null ? `L${lane}*` : `L${lane}`;
- row.innerHTML = `
- <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>
- <span class="actions">
- <button class="mini-btn danger" data-del title="Delete">ร</button>
- </span>
- `;
- row.addEventListener('click', (e) => {
- if (e.target.closest('[data-del]')) { e.stopPropagation(); w.events.splice(idx, 1); renderPropsPanel(); refreshWidgetContent(w); onStateChanged(); return; }
- openEventDialog(w, ev);
- });
- list.appendChild(row);
- });
- document.getElementById('p-add-event').addEventListener('click', () => {
- // Default to today 00:00 "server wall time" in the current server TZ.
- // Use Intl to extract today's Y-M-D as the selected server would see it,
- // then build "YYYY-MM-DDT00:00" strings directly (no UTC conversion โ these
- // are stored as TZ-less wall-clock times per the server-relative rule model).
- const tz = currentTz();
- const parts = new Intl.DateTimeFormat('en-CA', {
- timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
- }).formatToParts(new Date()).reduce((o, p) => { o[p.type] = p.value; return o; }, {});
- const startWall = `${parts.year}-${parts.month}-${parts.day}T00:00`;
- // End = same time + 7 days. Add via simple Date math on a throwaway UTC Date.
- const seed = new Date(Date.UTC(+parts.year, +parts.month - 1, +parts.day));
- seed.setUTCDate(seed.getUTCDate() + 7);
- const endWall = `${seed.getUTCFullYear()}-${String(seed.getUTCMonth()+1).padStart(2,'0')}-${String(seed.getUTCDate()).padStart(2,'0')}T00:00`;
- const ev = {
- id: uid('e'),
- name: 'New event',
- start: startWall,
- end: endWall,
- color: randomPastel(),
- // lane omitted โ auto-assigned at render/export time.
- bg: '',
- text: '',
- };
- (w.events = w.events || []).push(ev);
- openEventDialog(w, ev, true);
- });
- }
- function moveItem(arr, idx, dir) {
- const j = idx + dir;
- if (j < 0 || j >= arr.length) return;
- [arr[idx], arr[j]] = [arr[j], arr[idx]];
- }
- // Forum media references. The forum stores uploads at `/.media/<sha256>.<ext>`
- // and the same hash works on any of the forum's mirror domains. To save space
- // in the OP's 12000-char limit, we collapse pasted URLs that look like
- // forum media down to just `<hash>.<ext>` (or `<hash>` for extension-less
- // formats like apng). At render time the renderer expands them back using
- // the current origin. Match is by path-only โ if a URL on ANY host has the
- // shape `/.media/<hash>[.<ext>]`, it's treated as forum media and collapsed.
- const HASH_PATTERN = /^[0-9a-f]{64}(?:\.[a-z0-9]+)?$/i;
- const MEDIA_PATH_PATTERN = /^\/\.media\/([0-9a-f]{64}(?:\.[a-z0-9]+)?)$/i;
- function compactMediaUrl(url) {
- if (!url) return url;
- const trimmed = String(url).trim();
- if (!trimmed) return trimmed;
- // Already in compact form? leave as-is.
- if (HASH_PATTERN.test(trimmed)) return trimmed;
- // Try to parse as full URL and inspect its path.
- try {
- const u = new URL(trimmed);
- const m = MEDIA_PATH_PATTERN.exec(u.pathname);
- if (m) return m[1];
- } catch (e) { /* not a parseable URL โ leave as-is */ }
- return trimmed;
- }
- function bindInput(id, cb) {
- const el = document.getElementById(id);
- if (!el) return;
- el.addEventListener('input', () => el.type == "checkbox" ? cb(el.checked) : cb(el.value));
- }
- // Variant of bindInput for image-URL fields. Two behaviors:
- // 1. Stored value is always the compacted form (hash if it's a forum
- // media URL, otherwise the URL as-is). The state always has minimal data.
- // 2. On blur, the input's displayed text also collapses โ so the author
- // can paste a long forum URL and see it shrink to the hash form when
- // they tab out, confirming the compaction visually.
- function bindBgInput(id, cb) {
- const el = document.getElementById(id);
- if (!el) return;
- el.addEventListener('input', () => cb(compactMediaUrl(el.value)));
- el.addEventListener('blur', () => {
- const compact = compactMediaUrl(el.value);
- if (compact !== el.value) el.value = compact;
- });
- }
- // ===== DIALOGS ============================================================
- // Close-on-cancel for all dialogs
- document.addEventListener('click', (e) => {
- if (e.target.matches('[data-close]')) {
- const d = e.target.closest('dialog');
- if (d) d.close();
- }
- });
- let currentEventCtx = null;
- function openEventDialog(widget, ev, isNew) {
- currentEventCtx = { widget, ev, isNew };
- document.getElementById('dlg-event-title').textContent = isNew ? 'Add event' : 'Edit event';
- document.getElementById('evt-name').value = ev.name || '';
- // Wall-time strings are already in the format datetime-local expects.
- // Strip seconds/ms if present (e.g. "2026-04-20T04:00:00" -> "2026-04-20T04:00").
- document.getElementById('evt-start').value = toInputValue(ev.start);
- // "Has end" is checked by default for new events; mirror the stored value for existing.
- const hasEnd = isNew ? true : !!ev.end;
- document.getElementById('evt-has-end').checked = hasEnd;
- document.getElementById('evt-end-wrap').style.display = hasEnd ? '' : 'none';
- document.getElementById('evt-end').value = toInputValue(ev.end);
- document.getElementById('evt-color').value = ev.color || '#aaaaaa';
- // Empty string when lane is unset (auto-assigned at render/export time).
- document.getElementById('evt-lane').value = ev.lane == null ? '' : String(ev.lane);
- document.getElementById('evt-bg').value = ev.bg || '';
- document.getElementById('evt-text').value = ev.text || '';
- document.getElementById('dlg-event').showModal();
- }
- // Normalize a stored wall-time (or legacy UTC ISO) string to the HTML
- // datetime-local format "YYYY-MM-DDTHH:mm". Strips ":ss", ".sss", and "Z" if present.
- function toInputValue(s) {
- if (!s) return '';
- const m = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/.exec(s);
- return m ? m[1] : '';
- }
- document.getElementById('evt-has-end').addEventListener('change', (e) => {
- const wrap = document.getElementById('evt-end-wrap');
- wrap.style.display = e.target.checked ? '' : 'none';
- // If user just enabled end and end input is empty, seed with start+1 day at 00:00.
- if (e.target.checked && !document.getElementById('evt-end').value) {
- const startVal = document.getElementById('evt-start').value;
- if (startVal) {
- // Parse start's YMD, bump day by 1, keep 00:00 time. No TZ conversion โ
- // this is all wall-time arithmetic.
- const parts = /^(\d{4})-(\d{2})-(\d{2})/.exec(startVal);
- if (parts) {
- const d = new Date(Date.UTC(+parts[1], +parts[2] - 1, +parts[3]));
- d.setUTCDate(d.getUTCDate() + 1);
- const endInput = `${d.getUTCFullYear()}-${String(d.getUTCMonth()+1).padStart(2,'0')}-${String(d.getUTCDate()).padStart(2,'0')}T00:00`;
- document.getElementById('evt-end').value = endInput;
- }
- }
- }
- });
- document.getElementById('evt-save').addEventListener('click', () => {
- if (!currentEventCtx) return;
- const { widget, ev } = currentEventCtx;
- ev.name = document.getElementById('evt-name').value.trim() || 'Unnamed';
- // Store the wall-time string as typed. No UTC conversion โ this is the
- // server's clock face. The renderer converts to real UTC at display time
- // using the currently-selected server's TZ.
- ev.start = document.getElementById('evt-start').value;
- const hasEnd = document.getElementById('evt-has-end').checked;
- ev.end = hasEnd ? document.getElementById('evt-end').value : '';
- ev.color = document.getElementById('evt-color').value;
- // Empty lane input = auto-assign at export/render time (stored as undefined).
- const laneRaw = document.getElementById('evt-lane').value.trim();
- if (laneRaw === '') {
- delete ev.lane;
- } else {
- ev.lane = Math.max(0, parseInt(laneRaw, 10) || 0);
- }
- ev.bg = compactMediaUrl(document.getElementById('evt-bg').value.trim());
- ev.text = document.getElementById('evt-text').value;
- if (!ev.start) {
- alert('Start date is required');
- return;
- }
- document.getElementById('dlg-event').close();
- currentEventCtx = null;
- renderPropsPanel();
- refreshWidgetContent(widget);
- onStateChanged();
- });
- // ===== URL IMAGE PARSERS ==================================================
- // Each parser: { name, match(url)->bool, extract(url)->imageUrl|null }.
- // Only CORS-unrestricted sources can be used from the browser without a proxy.
- // Add more entries below as needed.
- let currentListItemCtx = null;
- function setUrlMode(multi) {
- document.getElementById('li-url-single').style.display = multi ? 'none' : '';
- document.getElementById('li-url-multi').style.display = multi ? '' : 'none';
- }
- // --- Phase dialog (countdown series) ---
- let currentPhaseCtx = null;
- function openPhaseDialog(widget, phase, isNew) {
- currentPhaseCtx = { widget, phase, isNew };
- document.getElementById('dlg-phase-title').textContent = isNew ? 'Add phase' : 'Edit phase';
- document.getElementById('ph-title').value = phase.title || '';
- document.getElementById('ph-target').value = toInputValue(phase.target || '');
- document.getElementById('ph-endedtext').value = phase.endedText || '';
- document.getElementById('ph-bg').value = phase.bg || '';
- document.getElementById('ph-bgmode').value = phase.bgMode || 'cover';
- document.getElementById('ph-bg-options').style.display = phase.bg ? '' : 'none';
- document.getElementById('ph-bg').addEventListener('input', function handler() {
- document.getElementById('ph-bg-options').style.display = this.value.trim() ? '' : 'none';
- });
- document.getElementById('dlg-phase').showModal();
- }
- document.getElementById('ph-save').addEventListener('click', () => {
- if (!currentPhaseCtx) return;
- const { widget, phase } = currentPhaseCtx;
- phase.title = document.getElementById('ph-title').value.trim() || 'Untitled';
- phase.target = document.getElementById('ph-target').value;
- phase.endedText = document.getElementById('ph-endedtext').value.trim();
- phase.bg = document.getElementById('ph-bg').value.trim();
- phase.bgMode = phase.bg ? document.getElementById('ph-bgmode').value : '';
- document.getElementById('dlg-phase').close();
- currentPhaseCtx = null;
- renderPropsPanel();
- refreshWidgetContent(widget);
- onStateChanged();
- });
- function openListItemDialog(widget, item, isNew) {
- currentListItemCtx = { widget, item, isNew };
- document.getElementById('dlg-list-item-title').textContent = isNew ? 'Add list item' : 'Edit list item';
- document.getElementById('li-title').value = item.title || '';
- document.getElementById('li-bg').value = item.bg || '';
- document.getElementById('li-text').value = item.text || '';
- const container = document.getElementById('li-lang-urls');
- container.innerHTML = '';
- if (item.urls && Object.keys(item.urls).length > 0) {
- // Multilingual mode
- document.getElementById('li-url').value = '';
- Object.entries(item.urls).forEach(([lang, url]) => {
- container.appendChild(createLangUrlRow(lang, url));
- });
- setUrlMode(true);
- } else {
- // Single URL mode
- document.getElementById('li-url').value = item.url || '';
- setUrlMode(false);
- }
- document.getElementById('dlg-list-item').showModal();
- }
- function createLangUrlRow(lang, url) {
- const row = document.createElement('div');
- row.className = 'lang-url-row';
- const langInput = document.createElement('input');
- langInput.type = 'text';
- langInput.placeholder = 'EN';
- langInput.value = lang || '';
- langInput.maxLength = 5;
- const urlInput = document.createElement('input');
- urlInput.type = 'url';
- urlInput.placeholder = 'https://...';
- urlInput.value = url || '';
- const del = document.createElement('button');
- del.className = 'mini-btn danger';
- del.textContent = 'ร';
- del.title = 'Remove';
- del.type = 'button';
- del.addEventListener('click', () => {
- row.remove();
- // If no language rows left, switch back to single URL mode
- const remaining = document.getElementById('li-lang-urls').querySelectorAll('.lang-url-row');
- if (remaining.length === 0) {
- setUrlMode(false);
- }
- });
- row.appendChild(langInput);
- row.appendChild(urlInput);
- row.appendChild(del);
- return row;
- }
- // Convert from single URL to multilingual
- document.getElementById('li-add-lang').addEventListener('click', () => {
- const container = document.getElementById('li-lang-urls');
- const existingUrl = document.getElementById('li-url').value.trim();
- // Move existing URL into the first language row
- if (existingUrl) {
- container.appendChild(createLangUrlRow('', existingUrl));
- }
- // Add a blank row for the new language
- container.appendChild(createLangUrlRow('', ''));
- document.getElementById('li-url').value = '';
- setUrlMode(true);
- // Focus the lang input โ first row if URL was moved, otherwise the only row
- const rows = container.querySelectorAll('.lang-url-row');
- const target = rows[0];
- if (target) target.querySelector('input[type="text"]').focus();
- });
- // Add another language row in multi mode
- document.getElementById('li-add-lang-row').addEventListener('click', () => {
- const container = document.getElementById('li-lang-urls');
- container.appendChild(createLangUrlRow('', ''));
- const rows = container.querySelectorAll('.lang-url-row');
- const last = rows[rows.length - 1];
- if (last) last.querySelector('input[type="text"]').focus();
- });
- document.getElementById('li-save').addEventListener('click', () => {
- if (!currentListItemCtx) return;
- const { widget, item } = currentListItemCtx;
- item.title = document.getElementById('li-title').value.trim() || 'Untitled';
- item.bg = compactMediaUrl(document.getElementById('li-bg').value.trim());
- item.text = document.getElementById('li-text').value;
- const isMulti = document.getElementById('li-url-multi').style.display !== 'none';
- if (isMulti) {
- // Multilingual mode: only use urls, clear url
- const langRows = document.getElementById('li-lang-urls').querySelectorAll('.lang-url-row');
- const urls = {};
- langRows.forEach(row => {
- const lang = row.querySelector('input[type="text"]').value.trim().toLowerCase();
- const url = row.querySelector('input[type="url"]').value.trim();
- if (lang && url) urls[lang] = url;
- });
- if (Object.keys(urls).length > 0) {
- item.urls = urls;
- delete item.url;
- } else {
- // All rows were empty, fall back to no URL
- delete item.urls;
- delete item.url;
- }
- } else {
- // Single URL mode
- item.url = document.getElementById('li-url').value.trim();
- delete item.urls;
- }
- document.getElementById('dlg-list-item').close();
- currentListItemCtx = null;
- renderPropsPanel();
- refreshWidgetContent(widget);
- onStateChanged();
- });
- // ===== OUTPUT GENERATION ==================================================
- // Generate the full OP paste-able text:
- // [human-readable sections grouped by type]
- // [code]
- // @@grid-v1@@
- // {minified json}
- // [/code]
- function generateOutput() {
- const sections = [];
- // First line of every exported OP is a manual placeholder for the link to
- // the previous thread. Author pastes the template into the new thread,
- // then types or quote-links the previous thread number after "Previous: ".
- // The renderer parses this back as a (Cross-thread) link and surfaces it
- // in the status bar.
- sections.push('Previous: ', '');
- // Group widgets by type.
- const byType = { generic: [], timeline: [], list: [], link: [] };
- state.widgets.forEach(w => { (byType[w.type] || (byType[w.type] = [])).push(w); });
- // Within each group, sort by manual `order` (unset = Infinity โ end of group).
- // Stable sort: widgets with equal orders keep their insertion order.
- const orderOf = w => (w.order == null ? Infinity : w.order);
- Object.keys(byType).forEach(k => {
- byType[k].sort((a, b) => orderOf(a) - orderOf(b));
- });
- // Emit each type's section as a function that returns its text lines.
- // Each emitter's "group order" is the min order of any widget in it (unset
- // members don't contribute). A group with no ordered members gets Infinity,
- // which sinks it below ordered groups โ and ties (including the all-unset
- // default case) break on the hardcoded natural sequence below.
- const emitters = [
- { type: 'generic', emit: emitGeneric },
- { type: 'timeline', emit: emitTimeline },
- { type: 'list', emit: emitList },
- { type: 'link', emit: emitLink },
- ];
- const groupMinOrder = widgets => {
- let min = Infinity;
- widgets.forEach(w => { if (w.order != null && w.order < min) min = w.order; });
- return min;
- };
- emitters.forEach((e, idx) => {
- e.min = groupMinOrder(byType[e.type] || []);
- e.tiebreak = idx; // hardcoded sequence fallback
- });
- emitters.sort((a, b) => (a.min - b.min) || (a.tiebreak - b.tiebreak));
- emitters.forEach(e => {
- const lines = e.emit(byType[e.type] || []);
- lines.forEach(l => sections.push(l));
- });
- // Code block with JSON โ auto-lanes are resolved here so exported payload
- // has explicit lane numbers for every event (renderer stays simple).
- const payload = buildPayloadWithResolvedLanes();
- const json = JSON.stringify(payload);
- sections.push('[code]');
- sections.push(MAGIC);
- sections.push(json);
- sections.push('[/code]');
- return sections.join('\n');
- }
- function emitGeneric(widgets) {
- const lines = [];
- widgets.forEach(w => {
- lines.push(`>[${w.title || 'Untitled'}]`);
- if (w.text) lines.push(w.text);
- lines.push('');
- });
- return lines;
- }
- function emitTimeline(widgets) {
- const lines = [];
- widgets.forEach(w => {
- let header = `>[${w.title || 'Timeline'}]`;
- if ((state.meta.servers || []).length > 0) {
- header += ` (${currentServerLabel()})`;
- }
- lines.push(header);
- (w.events || []).slice().sort((a, b) => new Date(a.start) - new Date(b.start)).forEach(ev => {
- lines.push(formatEventText(ev));
- });
- lines.push('');
- });
- return lines;
- }
- function emitList(widgets) {
- const lines = [];
- widgets.forEach(w => {
- lines.push(`>[${w.title || 'List'}]`);
- (w.items || []).forEach(item => {
- if (item.urls && Object.keys(item.urls).length > 0) {
- lines.push(item.title || 'Untitled');
- Object.entries(item.urls).forEach(([lang, url]) => {
- lines.push(`${lang.toUpperCase()} ${url}`);
- });
- } else {
- const bits = [item.title || 'Untitled'];
- if (item.url) bits.push(item.url);
- lines.push(bits.join(' | '));
- }
- });
- lines.push('');
- });
- return lines;
- }
- function emitLink(widgets) {
- if (widgets.length === 0) return [];
- const lines = ['>[Links]'];
- widgets.forEach(w => {
- const bits = [w.title || 'Link'];
- if (w.url) bits.push(w.url);
- lines.push(bits.join(' | '));
- });
- lines.push('');
- return lines;
- }
- // Assign auto-lanes (greedy interval scheduling). Events with explicit `lane`
- // numbers are pinned and block their lane for their time range. Events with
- // `lane == null` get the lowest-numbered lane that has no conflict at their
- // start moment. Returns a Map(eventId โ lane number) covering ALL events.
- function resolveLanes(events) {
- const out = new Map();
- if (!events || events.length === 0) return out;
- const DAY_MS = 86400000;
- // Use wall-time string directly for interval comparison โ since all events
- // in the same widget are interpreted under one "server relative" rule, two
- // wall-time strings can be compared as plain strings (ISO-like format makes
- // lexicographic == chronological).
- const rangeOf = ev => {
- const s = ev.start || '';
- const e = ev.end || '';
- // If no end, treat as half-day width โ but we don't know wall-time math here;
- // a coarse "1 day after start" estimation is fine for conflict-detection.
- if (!e) {
- // Bump last two chars of the day portion โ or just append 'Z' and parse.
- // Simpler: use Date parsing (wall time interpreted as local); we only
- // compare intervals for overlap, so TZ consistency within one call is
- // all that matters.
- const sMs = Date.parse(s + 'Z');
- return { s: sMs, e: sMs + DAY_MS * 0.5 };
- }
- return { s: Date.parse(s + 'Z'), e: Date.parse(e + 'Z') };
- };
- // Track per-lane the list of occupied [s, e] intervals, so we can check
- // whether a new event collides with anything in that lane.
- const laneIntervals = new Map(); // laneNum -> [{s, e}, ...]
- // First pass: place events with explicit lanes. They always win their slot.
- events.forEach(ev => {
- if (ev.lane == null) return;
- const r = rangeOf(ev);
- if (!laneIntervals.has(ev.lane)) laneIntervals.set(ev.lane, []);
- laneIntervals.get(ev.lane).push(r);
- out.set(ev.id, ev.lane);
- });
- // Second pass: assign auto-lane events. Sort by start so earlier events
- // take lower lanes by default (visually nicer stacking).
- const autos = events.filter(ev => ev.lane == null).map(ev => ({ ev, r: rangeOf(ev) }));
- autos.sort((a, b) => a.r.s - b.r.s);
- const overlaps = (a, b) => a.s < b.e && b.s < a.e;
- autos.forEach(({ ev, r }) => {
- // Try lanes 0, 1, 2, ... until we find one where no existing interval overlaps.
- for (let lane = 0; lane < 1000; lane++) {
- const ivs = laneIntervals.get(lane) || [];
- if (ivs.every(iv => !overlaps(iv, r))) {
- if (!laneIntervals.has(lane)) laneIntervals.set(lane, []);
- laneIntervals.get(lane).push(r);
- out.set(ev.id, lane);
- return;
- }
- }
- // Shouldn't reach here โ 1000 lanes is plenty.
- out.set(ev.id, 0);
- });
- return out;
- }
- // Apply resolved lanes by mutating a cloned event list โ used by serializers
- // so the exported JSON has explicit lane numbers for every event.
- function eventsWithResolvedLanes(events) {
- const lanes = resolveLanes(events || []);
- return (events || []).map(ev => ({ ...ev, lane: lanes.get(ev.id) ?? 0 }));
- }
- // Build a payload with auto-lanes filled in for every timeline widget.
- function buildPayloadWithResolvedLanes() {
- const widgets = state.widgets.map(w => {
- let out = { ...w };
- if (out.type === 'timeline') {
- out = { ...out, events: eventsWithResolvedLanes(out.events).map(cleanEvent) };
- }
- if (out.type === 'list') {
- out = { ...out, items: (out.items || []).map(cleanListItem) };
- }
- if (out.type === 'countdown') {
- out = cleanCountdown(out);
- }
- // Widget-level optional fields
- if (!out.bg) delete out.bg;
- if (!out.bgMode) delete out.bgMode;
- if (!out.endedText) delete out.endedText;
- if (!out.hideTitle) delete out.hideTitle;
- if (!out.hideBg) delete out.hideBg;
- if (!out.hoverBg) delete out.hoverBg;
- return out;
- });
- return { meta: state.meta, widgets };
- }
- function cleanEvent(ev) {
- const out = { ...ev };
- if (!out.bg) delete out.bg;
- if (!out.text) delete out.text;
- return out;
- }
- function cleanListItem(item) {
- const out = { ...item };
- if (!out.bg) delete out.bg;
- if (!out.text) delete out.text;
- if (out.urls && Object.keys(out.urls).length === 0) delete out.urls;
- return out;
- }
- function cleanCountdown(w) {
- const out = { ...w };
- delete out.title;
- if (!out.isCyclic) {
- delete out.isCyclic;
- delete out.interval;
- delete out.cyclicMode;
- delete out.cyclicDates;
- } else if (out.cyclicMode === 'datetime') {
- delete out.interval;
- if (!out.cyclicDates || out.cyclicDates.length === 0) delete out.cyclicDates;
- } else {
- // timespan mode (default)
- delete out.cyclicMode;
- delete out.cyclicDates;
- if (out.interval && out.interval.every(v => !v)) delete out.interval;
- }
- if (out.rawTz == null) delete out.rawTz;
- if (!out.cdStyle) delete out.cdStyle;
- if (out.phases) {
- out.phases = out.phases.map(p => {
- const cp = { ...p };
- if (!cp.bg) delete cp.bg;
- if (!cp.bgMode) delete cp.bgMode;
- if (!cp.endedText) delete cp.endedText;
- return cp;
- });
- }
- return out;
- }
- function formatEventText(ev /*, tz */) {
- // Wall-time strings are stored directly as the server sees them. Parse the
- // date portion straight from the string โ no TZ conversion needed.
- const parseParts = s => {
- const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s || '');
- return m ? { y: m[1], m: m[2], d: m[3] } : null;
- };
- const ps = parseParts(ev.start);
- const pe = parseParts(ev.end);
- if (ps && pe) {
- const full = p => `${p.y}-${p.m}-${p.d}`;
- const short = p => `${p.m}-${p.d}`;
- if (ps.y === pe.y) return `${full(ps)} - ${short(pe)} | ${ev.name}`;
- return `${full(ps)} - ${full(pe)} | ${ev.name}`;
- }
- return ps ? `${ps.y}-${ps.m}-${ps.d} | ${ev.name}` : `? | ${ev.name}`;
- }
- // ===== CHANGE HANDLER =====================================================
- function onStateChanged() {
- save();
- updateOutput();
- }
- function updateOutput() {
- const out = generateOutput();
- document.getElementById('output-preview').value = out;
- const count = out.length;
- const el = document.getElementById('char-count');
- el.textContent = `OP: ${count.toLocaleString()} / ${MAX_CHARS.toLocaleString()}`;
- el.classList.remove('warn', 'over');
- if (count > MAX_CHARS) el.classList.add('over');
- else if (count > MAX_CHARS * 0.9) el.classList.add('warn');
- const payload = buildPayloadWithResolvedLanes();
- const codeLen = JSON.stringify(payload).length + MAGIC.length + 14; // [code]\n@@magic@@\n{json}\n[/code]
- document.getElementById('code-count').textContent = `[code]: ${codeLen.toLocaleString()}`;
- }
- // ===== TOP BAR ============================================================
- // Add widget
- document.querySelectorAll('[data-add]').forEach(btn => {
- btn.addEventListener('click', () => {
- const type = btn.dataset.add;
- const w = newWidget(type);
- state.widgets.push(w);
- addWidgetToGrid(w, { autoPosition: true });
- // Pull back the actual placed position from Gridstack
- const node = grid.engine.nodes.find(n => n.id === w.id);
- if (node) { w.x = node.x; w.y = node.y; w.w = node.w; w.h = node.h; }
- selectWidget(w.id);
- // Scroll the new widget into view
- const el = document.querySelector(`.grid-stack-item[gs-id="${w.id}"]`);
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- onStateChanged();
- });
- });
- // Clear
- document.getElementById('btn-clear').addEventListener('click', () => {
- if (!confirm('Clear all widgets and server config?')) return;
- state.widgets = [];
- state.meta = { title: '', servers: [], defaultServerId: null };
- state.selectedId = null;
- grid.removeAll();
- updateServerButton();
- renderPropsPanel();
- onStateChanged();
- });
- // Copy OP
- document.getElementById('btn-copy').addEventListener('click', async () => {
- const text = generateOutput();
- try {
- await navigator.clipboard.writeText(text);
- toast('Copied to clipboard');
- } catch (e) {
- // Fallback
- const ta = document.getElementById('output-preview');
- ta.focus(); ta.select();
- document.execCommand('copy');
- toast('Copied (fallback)');
- }
- });
- // Copy [code] block only
- document.getElementById('btn-copy-code').addEventListener('click', async () => {
- const payload = buildPayloadWithResolvedLanes();
- const json = JSON.stringify(payload);
- const text = `[code]\n${MAGIC}\n${json}\n[/code]`;
- try {
- await navigator.clipboard.writeText(text);
- toast('Code block copied');
- } catch (e) {
- const ta = document.getElementById('output-preview');
- ta.value = text;
- ta.focus(); ta.select();
- document.execCommand('copy');
- toast('Code block copied (fallback)');
- }
- });
- // ===== PREVIEW ============================================================
- function openPreview() {
- if (!window.InteractiveOP) {
- toast('Renderer not loaded (renderer.js missing?)');
- return;
- }
- const overlay = document.getElementById('preview-overlay');
- const post = document.getElementById('preview-post');
- post.innerHTML = '';
- const grid = window.InteractiveOP.renderGrid(
- buildPayloadWithResolvedLanes(),
- { interactive: true, placeholder: true, placeholderCount: 5 }
- );
- post.appendChild(grid);
- overlay.hidden = false;
- document.addEventListener('keydown', onPreviewKey);
- }
- function closePreview() {
- document.getElementById('preview-overlay').hidden = true;
- document.getElementById('preview-post').innerHTML = '';
- document.removeEventListener('keydown', onPreviewKey);
- }
- function onPreviewKey(e) {
- if (e.key === 'Escape') closePreview();
- }
- document.getElementById('btn-preview').addEventListener('click', openPreview);
- document.getElementById('preview-close').addEventListener('click', closePreview);
- // Click outside the post content (on the dimmed stage) closes
- document.getElementById('preview-overlay').addEventListener('click', (e) => {
- if (e.target.id === 'preview-stage' || e.target.id === 'preview-overlay') closePreview();
- });
- // Mobile/desktop width toggle
- document.querySelectorAll('input[name="preview-width"]').forEach(r => {
- r.addEventListener('change', (e) => {
- const post = document.getElementById('preview-post');
- post.classList.toggle('mobile', e.target.value === 'mobile');
- });
- });
- // ===== EMBEDDED RENDERER ===================================================
- const InteractiveOP = function () {
- const OP_SELECTOR = '.opCell';
- const MSG_SELECTOR = '.divMessage';
- const MAGIC = '@@grid-v1@@';
- const GRID_COLS = 18;
- const MOBILE_BREAKPOINT = 812;
- const ROW_TO_COL_RATIO = 0.6;
- const ROW_HEIGHT_PX = 50;
- const state = {
- enabled: true,
- originals: new WeakMap(), // post element -> original innerHTML
- rendered: new WeakSet(), // post elements already processed
- movedAttachments: new WeakMap(), // post element -> [{link, originalParent}, ...]
- lightboxEl: null, // currently-open lightbox backdrop element
- lightboxKeyHandler: null, // keydown handler attached while lightbox is open
- };
- function decodeHtml(s) {
- const ta = document.createElement('textarea');
- ta.innerHTML = s;
- return ta.value;
- }
- function extractGridData(postEl, msgEl) {
- const codeBlock = msgEl.querySelector('span.blockCode code');
- if (!codeBlock) return null;
- const txt = codeBlock.textContent;
- const parsed = tryParseMarkerBlock(txt);
- if (parsed) return { data: parsed, source: 'dom' };
- return null;
- }
- function tryParseMarkerBlock(text) {
- if (!text) return null;
- const trimmed = text.trim();
- if (!trimmed.startsWith(MAGIC)) {
- const idx = trimmed.indexOf(MAGIC);
- if (idx === -1) return null;
- const jsonStr = trimmed.slice(idx + MAGIC.length).trim();
- return safeParseJson(jsonStr);
- }
- const jsonStr = trimmed.slice(MAGIC.length).trim();
- return safeParseJson(jsonStr);
- }
- function safeParseJson(s) {
- try {
- const data = JSON.parse(s);
- if (!data || !Array.isArray(data.widgets)) return null;
- return data;
- } catch (e) {
- console.warn('[grid-renderer] JSON parse failed:', e);
- return null;
- }
- }
- function isMobile() { return window.innerWidth < MOBILE_BREAKPOINT; }
- function observeVisibility(el, onVisible, onHidden) {
- if (typeof IntersectionObserver === 'undefined') {
- try { onVisible(); } catch (e) { console.error(e); }
- return () => { };
- }
- let isVisible = false;
- const io = new IntersectionObserver(entries => {
- for (const entry of entries) {
- const visibleNow = entry.isIntersecting;
- if (visibleNow === isVisible) continue;
- isVisible = visibleNow;
- try { (visibleNow ? onVisible : onHidden)(); }
- catch (e) { console.error('[grid-renderer] visibility callback failed:', e); }
- }
- }, { threshold: 0 });
- io.observe(el);
- return () => io.disconnect();
- }
- function lazyBgImage(el, url) {
- const cssUrl = `url("${url}")`;
- if (typeof IntersectionObserver === 'undefined') {
- el.style.backgroundImage = cssUrl;
- return;
- }
- const io = new IntersectionObserver(entries => {
- for (const entry of entries) {
- if (entry.isIntersecting) {
- el.style.backgroundImage = cssUrl;
- io.disconnect();
- break;
- }
- }
- }, { threshold: 0 });
- io.observe(el);
- }
- function escapeHtml(s) {
- return String(s).replace(/[&<>"']/g, c => (
- { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
- ));
- }
- function linkifyText(text) {
- const escaped = escapeHtml(text);
- return escaped.replace(/(https?:\/\/[^\s<>"]+)/g, (url) => {
- return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
- }).replace(/\n/g, '<br>');
- }
- let activePopup = null;
- function closePopup() {
- if (activePopup) { activePopup.remove(); activePopup = null; }
- document.removeEventListener('click', onDocClickForPopup, true);
- document.removeEventListener('keydown', onEscForPopup);
- }
- function onDocClickForPopup(e) {
- if (activePopup && !activePopup.contains(e.target)) closePopup();
- }
- function onEscForPopup(e) { if (e.key === 'Escape') closePopup(); }
- function computeCountdown(startDate, endDate) {
- const now = Date.now();
- const s = startDate ? startDate.getTime() : null;
- const e = endDate ? endDate.getTime() : null;
- if (s !== null && now < s) {
- return { text: 'starts in ' + humanDuration(s - now), state: 'future' };
- }
- if (e !== null) {
- if (now < e) {
- const remaining = e - now;
- return {
- text: humanDuration(remaining) + ' left',
- state: remaining < 86400000 ? 'urgent' : 'normal',
- };
- }
- return { text: 'ended ' + humanDuration(now - e) + ' ago', state: 'ended' };
- }
- return null;
- }
- // "2d 4h" / "5h 30m" / "45m" / "30s"
- function humanDuration(ms) {
- if (ms < 0) ms = 0;
- const sec = Math.floor(ms / 1000);
- const d = Math.floor(sec / 86400);
- const h = Math.floor((sec % 86400) / 3600);
- const m = Math.floor((sec % 3600) / 60);
- const s = sec % 60;
- if (d > 0) return `${d}d ${h}h`;
- if (h > 0) return `${h}h ${m}m`;
- if (m > 0) return `${m}m`;
- return `${s}s`;
- }
- // Shared "Mon DD, YYYY HH:MM" format options (24h).
- const FULL_DATETIME_OPTS = {
- year: 'numeric', month: 'short', day: '2-digit',
- hour: '2-digit', minute: '2-digit', hour12: false,
- };
- // Append a "<start> โ <end> (label)" (or single-time) line to a popup.
- function appendPopupTimeLine(popup, fmt, s, e, label, extraClass) {
- const line = document.createElement('div');
- line.className = 'gr-popup-time' + (extraClass ? ' ' + extraClass : '');
- line.textContent = e
- ? `${fmt.format(s)} โ ${fmt.format(e)} (${label})`
- : `${fmt.format(s)} (${label})`;
- popup.appendChild(line);
- }
- function showEventPopup(anchorEl, event, tz, serverName) {
- closePopup();
- tz = tz || 'UTC';
- const popup = document.createElement('div');
- popup.className = 'gr-popup';
- const close = document.createElement('span');
- close.className = 'gr-popup-close';
- close.textContent = 'โ';
- close.onclick = (e) => { e.stopPropagation(); closePopup(); };
- popup.appendChild(close);
- const title = document.createElement('div');
- title.className = 'gr-popup-title';
- title.textContent = event.name;
- popup.appendChild(title);
- const sMs = wallTimeToUtcMs(event.start, tz);
- const eMs = event.end ? wallTimeToUtcMs(event.end, tz) : null;
- const s = new Date(sMs);
- const e = eMs !== null ? new Date(eMs) : null;
- const serverLabel = serverName || ((tz && tz !== 'UTC') ? tz : 'UTC');
- appendPopupTimeLine(popup, tzFormatter(tz || 'UTC', FULL_DATETIME_OPTS), s, e, serverLabel);
- appendPopupTimeLine(popup, new Intl.DateTimeFormat(undefined, FULL_DATETIME_OPTS), s, e, 'local time', 'gr-popup-time-local');
- const countdown = computeCountdown(s, e);
- if (countdown) {
- const cd = document.createElement('div');
- cd.className = 'gr-popup-countdown ' + countdown.state;
- cd.textContent = countdown.text;
- popup.appendChild(cd);
- }
- if (event.bg) {
- const img = document.createElement('img');
- img.className = 'gr-popup-img';
- img.src = expandMediaUrl(event.bg);
- img.alt = '';
- img.onerror = () => img.remove();
- popup.appendChild(img);
- }
- if (event.text) {
- const text = document.createElement('div');
- text.className = 'gr-popup-text';
- text.innerHTML = linkifyText(event.text);
- popup.appendChild(text);
- }
- document.body.appendChild(popup);
- const rect = anchorEl.getBoundingClientRect();
- const popupRect = popup.getBoundingClientRect();
- let left = rect.left + window.scrollX;
- let top = rect.bottom + window.scrollY + 4;
- const maxLeft = window.scrollX + window.innerWidth - popupRect.width - 8;
- if (left > maxLeft) left = maxLeft;
- if (left < window.scrollX + 4) left = window.scrollX + 4;
- popup.style.left = left + 'px';
- popup.style.top = top + 'px';
- activePopup = popup;
- setTimeout(() => {
- document.addEventListener('click', onDocClickForPopup, true);
- document.addEventListener('keydown', onEscForPopup);
- }, 0);
- }
- function createWidgetShell(w, bodyClass, opts) {
- opts = opts || {};
- const el = document.createElement('div');
- el.className = 'gr-widget' + (opts.rootClass ? ' ' + opts.rootClass : '');
- if (w.hideBg) el.classList.add('gr-no-bg');
- let titleEl = null;
- if (w.hideTitle) {
- if (opts.keepTitleSlot) {
- titleEl = document.createElement('div');
- titleEl.style.display = 'none';
- el.appendChild(titleEl);
- }
- } else if (opts.keepTitleSlot) {
- titleEl = document.createElement('div');
- titleEl.className = 'gr-widget-title';
- titleEl.style.display = 'none'; // caller manages visibility
- el.appendChild(titleEl);
- } else if (w.title) {
- titleEl = document.createElement('div');
- titleEl.className = 'gr-widget-title';
- titleEl.textContent = w.title;
- el.appendChild(titleEl);
- }
- const body = document.createElement('div');
- body.className = 'gr-widget-body' + (bodyClass ? ' ' + bodyClass : '');
- el.appendChild(body);
- return { el, body, titleEl };
- }
- function applyBg(el, url, mode) {
- el.className = 'gr-generic-bg gr-bg-' + (mode || 'cover');
- lazyBgImage(el, expandMediaUrl(url));
- }
- function makeBgLayer(url, mode) {
- const bg = document.createElement('div');
- applyBg(bg, url, mode);
- return bg;
- }
- function setupHoverBg(el, hoverBgUrl, bgTarget) {
- if (!hoverBgUrl) return;
- const target = bgTarget || el;
- const hoverUrl = expandMediaUrl(hoverBgUrl);
- // Preload
- const img = new Image();
- img.src = hoverUrl;
- let originalBg = '';
- el.addEventListener('mouseenter', () => {
- originalBg = target.style.backgroundImage;
- target.style.backgroundImage = `url("${hoverUrl}")`;
- });
- el.addEventListener('mouseleave', () => {
- target.style.backgroundImage = originalBg;
- });
- }
- function setupExternalLink(a, url, opts) {
- opts = opts || {};
- a.href = url;
- a.target = '_blank';
- a.rel = 'noopener noreferrer';
- a.dataset.enhanced = '1'; // force 8chanSS to ignore it
- if (opts.youtube && isYouTubeUrl(url)) {
- a.addEventListener('click', (e) => {
- if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.button !== 0) return;
- if (isYouTubeUrl(a.href)) {
- e.preventDefault();
- openYouTubeLightbox(a.href);
- }
- });
- }
- }
- function makeLangBadge(code) {
- const badge = document.createElement('span');
- badge.className = 'gr-list-lang-badge';
- badge.textContent = code.toUpperCase();
- return badge;
- }
- function fillLangLink(link, url, hasLangs, itemLang) {
- link.textContent = '';
- if (hasLangs && itemLang) {
- link.appendChild(makeLangBadge(itemLang));
- link.appendChild(document.createTextNode(' '));
- }
- link.appendChild(document.createTextNode(url));
- }
- function renderGeneric(w) {
- const { el, body } = createWidgetShell(w, 'gr-generic-text', { rootClass: 'gr-generic' });
- if (w.bg) {
- const bgLayer = makeBgLayer(w.bg, w.bgMode);
- body.appendChild(bgLayer);
- setupHoverBg(el, w.hoverBg, bgLayer);
- } else if (w.hoverBg) {
- setupHoverBg(el, w.hoverBg, el);
- }
- if (w.text) {
- const textEl = document.createElement('div');
- textEl.className = 'gr-generic-text-inner';
- textEl.innerHTML = linkifyText(w.text);
- body.appendChild(textEl);
- }
- return el;
- }
- function renderCountdown(w, opts) {
- opts = opts || {};
- const tz = opts.tz || 'UTC';
- const serverName = opts.serverName || '';
- const { el, body, titleEl } = createWidgetShell(w, 'gr-countdown-body', { rootClass: 'gr-countdown', keepTitleSlot: true });
- const isLabel = w.cdStyle === 'label';
- if (isLabel) el.classList.add('gr-countdown-label');
- const bgLayer = document.createElement('div');
- bgLayer.className = 'gr-generic-bg gr-bg-cover';
- bgLayer.style.display = 'none';
- body.appendChild(bgLayer);
- setupHoverBg(el, w.hoverBg, bgLayer);
- let labelTitle = null;
- if (isLabel) {
- labelTitle = document.createElement('div');
- labelTitle.className = 'gr-countdown-label-title';
- body.appendChild(labelTitle);
- }
- const timerEl = document.createElement('div');
- timerEl.className = 'gr-countdown-timer';
- body.appendChild(timerEl);
- const phases = (w.phases && w.phases.length > 0)
- ? w.phases
- : [{ title: w.title, target: w.target, endedText: w.endedText, bg: w.bg, bgMode: w.bgMode }];
- const isMultiPhase = phases.length > 1;
- function phaseToUtcMs(target) {
- if (w.rawTz != null) {
- if (!target) return NaN;
- const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(target);
- if (!m) return NaN;
- const pretend = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] || 0));
- return pretend - (Number(w.rawTz) || 0) * 3600000;
- }
- return wallTimeToUtcMs(target, tz);
- }
- const intervalMs = (!isMultiPhase && w.isCyclic && w.cyclicMode !== 'datetime')
- ? ((Number((w.interval || [])[0]) || 0) * 86400000
- + (Number((w.interval || [])[1]) || 0) * 3600000
- + (Number((w.interval || [])[2]) || 0) * 60000)
- : 0;
- function nextCyclicDateMs() {
- const dates = w.cyclicDates;
- if (!dates || !dates.length) return NaN;
- const now = new Date();
- const candidates = [];
- for (const entry of dates) {
- const day = Number(entry.day) || 1;
- const [hh, mm] = (entry.time || '00:00').split(':').map(Number);
- for (let mOff = 0; mOff <= 1; mOff++) {
- const d = new Date(now.getFullYear(), now.getMonth() + mOff, day, hh, mm || 0, 0, 0);
- if (d.getDate() !== day) continue;
- if (d.getTime() > Date.now()) {
- candidates.push(d.getTime());
- break;
- }
- }
- }
- return candidates.length ? Math.min(...candidates) : NaN;
- }
- function resolvePhase() {
- const now = Date.now();
- for (const phase of phases) {
- const ms = phaseToUtcMs(phase.target);
- if (ms > now) return phase;
- }
- return phases[phases.length - 1];
- }
- let currentPhase = resolvePhase();
- let targetMs = phaseToUtcMs(currentPhase.target);
- let endedText = currentPhase.endedText || 'Ended';
- function applyPhaseAppearance() {
- if (w.hideTitle || isLabel) {
- if (titleEl) titleEl.style.display = 'none';
- if (isLabel && labelTitle) {
- labelTitle.textContent = currentPhase.title || '';
- labelTitle.style.display = currentPhase.title ? '' : 'none';
- }
- } else {
- if (currentPhase.title) {
- titleEl.textContent = currentPhase.title;
- titleEl.style.display = '';
- } else {
- titleEl.style.display = 'none';
- }
- }
- const phaseBg = currentPhase.bg || w.bg;
- if (phaseBg) {
- el.classList.add('has-bg');
- applyBg(bgLayer, phaseBg, currentPhase.bgMode || w.bgMode);
- bgLayer.style.display = '';
- } else {
- el.classList.remove('has-bg');
- bgLayer.style.display = 'none';
- }
- }
- applyPhaseAppearance();
- function advanceCyclicTarget() {
- if (w.cyclicMode === 'datetime' && w.isCyclic && !isMultiPhase) {
- const next = nextCyclicDateMs();
- if (Number.isFinite(next)) targetMs = next;
- return;
- }
- if (intervalMs <= 0) return;
- const now = Date.now();
- if (targetMs > now) return;
- const elapsed = now - targetMs;
- const cyclesPassed = Math.floor(elapsed / intervalMs) + 1;
- targetMs += cyclesPassed * intervalMs;
- }
- advanceCyclicTarget();
- let intervalId = null;
- let timeoutId = null;
- let lastRenderedLen = -1;
- const fmtTooltip = new Intl.DateTimeFormat(undefined, FULL_DATETIME_OPTS);
- function updateTooltip() {
- if (!Number.isFinite(targetMs)) { timerEl.removeAttribute('title'); return; }
- timerEl.title = `${fmtTooltip.format(new Date(targetMs))} (local time)`;
- }
- let lastTooltipTarget = null;
- function updateTimer() {
- if (!Number.isFinite(targetMs)) {
- timerEl.textContent = '';
- timerEl.removeAttribute('title');
- return;
- }
- let remainingMs = targetMs - Date.now();
- if (remainingMs <= 0 && (intervalMs > 0 || (w.cyclicMode === 'datetime' && w.isCyclic))) {
- advanceCyclicTarget();
- remainingMs = targetMs - Date.now();
- }
- if (remainingMs <= 0 && isMultiPhase) {
- const newPhase = resolvePhase();
- if (newPhase !== currentPhase) {
- currentPhase = newPhase;
- targetMs = phaseToUtcMs(currentPhase.target);
- endedText = currentPhase.endedText || 'Ended';
- applyPhaseAppearance();
- remainingMs = targetMs - Date.now();
- }
- }
- let nextText;
- if (remainingMs <= 0) {
- nextText = endedText;
- timerEl.classList.add('gr-countdown-ended');
- } else {
- timerEl.classList.remove('gr-countdown-ended');
- nextText = formatCountdown(remainingMs);
- }
- timerEl.textContent = nextText;
- if (targetMs !== lastTooltipTarget) {
- lastTooltipTarget = targetMs;
- updateTooltip();
- }
- if (nextText.length !== lastRenderedLen) {
- lastRenderedLen = nextText.length;
- fitTimerText();
- }
- }
- function fitTimerText() {
- timerEl.style.fontSize = '';
- if (!timerEl.isConnected) return;
- const avail = timerEl.clientWidth;
- const needed = timerEl.scrollWidth;
- if (avail <= 0 || needed <= avail) return;
- const cs = window.getComputedStyle(timerEl);
- const currentPx = parseFloat(cs.fontSize) || 24;
- const scaled = Math.max(10, Math.floor(currentPx * (avail / needed)));
- timerEl.style.fontSize = scaled + 'px';
- }
- updateTimer();
- setTimeout(fitTimerText, 0);
- try {
- const ro = new ResizeObserver(() => fitTimerText());
- ro.observe(timerEl);
- } catch (e) { /* ResizeObserver missing on very old browsers โ skip */ }
- function chooseCadenceMs() {
- const remaining = targetMs - Date.now();
- return remaining > 0 && remaining < 3600000 ? 1000 : 60000;
- }
- let currentCadenceMs = 0; // 0 = not yet scheduled
- let isVisible = true;
- function tick() {
- if (!el.isConnected) {
- if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
- if (intervalId) { clearInterval(intervalId); intervalId = null; }
- return;
- }
- if (!isVisible) return;
- updateTimer();
- if (chooseCadenceMs() !== currentCadenceMs) {
- scheduleTicks();
- }
- }
- function scheduleTicks() {
- if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
- if (intervalId) { clearInterval(intervalId); intervalId = null; }
- currentCadenceMs = chooseCadenceMs();
- const msUntilNext = currentCadenceMs - (Date.now() % currentCadenceMs);
- timeoutId = setTimeout(() => {
- tick();
- intervalId = setInterval(tick, currentCadenceMs);
- }, msUntilNext);
- }
- scheduleTicks();
- observeVisibility(el,
- () => {
- isVisible = true;
- if (el.isConnected) updateTimer();
- },
- () => { isVisible = false; }
- );
- return el;
- }
- // Display formats by magnitude:
- // >= 24h: "12d 4h" ("12d 35m" when hour remainder is 0)
- // >= 1h: "3h 45m"
- // >= 1m: "15m 40s"
- // < 1m: "40s"
- // <= 0: "0s"
- function formatCountdown(ms) {
- if (ms <= 0) return '0s';
- const totalSec = Math.floor(ms / 1000);
- const totalMin = Math.floor(ms / 60000);
- const totalHr = Math.floor(ms / 3600000);
- const totalDay = Math.floor(ms / 86400000);
- if (totalHr >= 24) {
- const hRem = totalHr - totalDay * 24;
- if (hRem === 0) {
- const mRem = totalMin - totalDay * 24 * 60;
- return `${totalDay}d ${String(mRem).padStart(2, '0')}m`;
- }
- return `${totalDay}d ${hRem}h`;
- }
- if (totalHr >= 1) {
- const mRem = totalMin - totalHr * 60;
- return `${totalHr}h ${String(mRem).padStart(2, '0')}m`;
- }
- if (totalMin >= 1) {
- const sRem = totalSec - totalMin * 60;
- return `${totalMin}m ${String(sRem).padStart(2, '0')}s`;
- }
- return `${totalSec}s`;
- }
- function fitTextToWidth(ctx, text, maxW) {
- if (maxW <= 0 || !text) return '';
- if (ctx.measureText(text).width <= maxW) return text;
- const ellipsis = 'โฆ';
- const ellipsisW = ctx.measureText(ellipsis).width;
- if (ellipsisW > maxW) return '';
- let lo = 0, hi = text.length;
- while (lo < hi) {
- const mid = (lo + hi + 1) >> 1;
- const candidate = text.slice(0, mid) + ellipsis;
- if (ctx.measureText(candidate).width <= maxW) lo = mid;
- else hi = mid - 1;
- }
- return lo === 0 ? ellipsis : text.slice(0, lo) + ellipsis;
- }
- function renderLink(w) {
- const el = document.createElement('a');
- el.className = 'gr-widget gr-link';
- if (w.hideBg) el.classList.add('gr-no-bg');
- setupExternalLink(el, w.url || '#');
- el.addEventListener('click', (e) => {
- if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.button !== 0) return;
- e.preventDefault();
- if (confirm(el.href)) {
- window.open(el.href, '_blank', 'noopener,noreferrer');
- }
- });
- const inner = document.createElement('div');
- inner.className = 'gr-link-inner';
- inner.textContent = w.title || w.url || '';
- el.appendChild(inner);
- if (w.bg) {
- inner.classList.add('has-bg');
- const mode = w.bgMode || 'cover';
- inner.classList.add('gr-bg-' + mode);
- lazyBgImage(inner, expandMediaUrl(w.bg));
- setupHoverBg(el, w.hoverBg, inner);
- } else if (w.hoverBg) {
- setupHoverBg(el, w.hoverBg, inner);
- }
- return el;
- }
- const LIST_MODES = ['horizontal', 'vertical', 'accordion', 'carousel'];
- const LIST_MODE_LABELS = {
- horizontal: 'Horizontal',
- vertical: 'Vertical',
- accordion: 'Accordion',
- carousel: 'Carousel',
- };
- function listModeStorageKey(widgetId) {
- return 'grid-list-mode:' + widgetId;
- }
- function getEffectiveListMode(w) {
- try {
- const stored = window.localStorage.getItem(listModeStorageKey(w.id));
- if (stored && LIST_MODES.includes(stored)) return stored;
- } catch (e) { /* storage may be unavailable */ }
- const defaultMode = w.displayMode && LIST_MODES.includes(w.displayMode) ? w.displayMode : 'horizontal';
- return defaultMode;
- }
- function setListModeOverride(widgetId, mode) {
- try {
- window.localStorage.setItem(listModeStorageKey(widgetId), mode);
- } catch (e) { /* ignore */ }
- }
- function enableDragScroll(el, axis) {
- const scrollX = axis === 'x' || axis === 'both';
- const scrollY = axis === 'y' || axis === 'both';
- let isDown = false;
- let startX, startY, scrollLeft, scrollTop;
- let moved = false;
- const DRAG_THRESHOLD = 5;
- el.style.cursor = 'grab';
- el.addEventListener('dragstart', (e) => e.preventDefault());
- el.addEventListener('mousedown', (e) => {
- if (e.button !== 0) return;
- isDown = true;
- moved = false;
- startX = e.clientX;
- startY = e.clientY;
- scrollLeft = el.scrollLeft;
- scrollTop = el.scrollTop;
- el.style.cursor = 'grabbing';
- el.style.userSelect = 'none';
- });
- document.addEventListener('mousemove', (e) => {
- if (!isDown) return;
- const dx = e.clientX - startX;
- const dy = e.clientY - startY;
- if (!moved && Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
- moved = true;
- if (scrollX) el.scrollLeft = scrollLeft - dx;
- if (scrollY) el.scrollTop = scrollTop - dy;
- });
- document.addEventListener('mouseup', () => {
- if (!isDown) return;
- isDown = false;
- el.style.cursor = 'grab';
- el.style.userSelect = '';
- });
- el.addEventListener('click', (e) => {
- if (moved) {
- e.preventDefault();
- e.stopPropagation();
- moved = false;
- }
- }, true);
- }
- function langStorageKey(widgetId) {
- return 'grid-list-lang:' + widgetId;
- }
- function getSelectedLang(widgetId) {
- try {
- return window.localStorage.getItem(langStorageKey(widgetId)) || null;
- } catch { return null; }
- }
- function setSelectedLang(widgetId, lang) {
- try {
- if (lang) window.localStorage.setItem(langStorageKey(widgetId), lang);
- else window.localStorage.removeItem(langStorageKey(widgetId));
- } catch { /* ignore */ }
- }
- function collectLangs(w) {
- const langSet = new Set();
- (w.items || []).forEach(item => {
- if (item.urls && typeof item.urls === 'object') {
- Object.keys(item.urls).forEach(k => langSet.add(k));
- }
- });
- return [...langSet];
- }
- function resolveItemUrl(item, lang) {
- if (item.urls && typeof item.urls === 'object') {
- const keys = Object.keys(item.urls);
- if (keys.length === 0) return '';
- if (lang && item.urls[lang]) return item.urls[lang];
- return item.urls[keys[0]];
- }
- return item.url || '';
- }
- function resolveItemLang(item, lang) {
- if (!item.urls || typeof item.urls !== 'object') return null;
- const keys = Object.keys(item.urls);
- if (keys.length === 0) return null;
- if (lang && item.urls[lang]) return lang;
- return keys[0];
- }
- function renderList(w) {
- const { el, body } = createWidgetShell(w, 'gr-list-body');
- // Language state
- const langs = collectLangs(w);
- const hasLangs = langs.length > 0;
- let currentLang = hasLangs ? (getSelectedLang(w.id) || langs[0]) : null;
- // Language switcher (only when multilingual items exist)
- let langBtn = null;
- if (hasLangs) {
- langBtn = document.createElement('button');
- langBtn.className = 'gr-list-lang-btn';
- langBtn.type = 'button';
- langBtn.title = 'Switch language';
- langBtn.textContent = currentLang.toUpperCase();
- el.appendChild(langBtn);
- langBtn.addEventListener('click', (e) => {
- e.preventDefault();
- e.stopPropagation();
- const idx = langs.indexOf(currentLang);
- currentLang = langs[(idx + 1) % langs.length];
- langBtn.textContent = currentLang.toUpperCase();
- setSelectedLang(w.id, currentLang);
- updateLang();
- });
- }
- function updateLang() {
- const items = w.items || [];
- body.querySelectorAll('.gr-list-card[data-item-idx]').forEach(card => {
- const item = items[+card.dataset.itemIdx];
- if (!item) return;
- const url = resolveItemUrl(item, currentLang);
- if (card.tagName === 'A' && url) card.href = url;
- if (!item.bg) {
- const autoUrl = getAutoPreviewUrl(url);
- if (autoUrl) {
- card.classList.add('has-bg');
- lazyBgImage(card, autoUrl);
- }
- }
- const oldBadge = card.querySelector('.gr-list-lang-badge');
- const itemLang = resolveItemLang(item, currentLang);
- if (hasLangs && itemLang) {
- if (oldBadge) {
- oldBadge.textContent = itemLang.toUpperCase();
- } else {
- // Insert before the title
- const title = card.querySelector('.gr-list-card-title');
- card.insertBefore(makeLangBadge(itemLang), title);
- }
- } else if (oldBadge) {
- oldBadge.remove();
- }
- });
- body.querySelectorAll('.gr-list-acc-item[data-item-idx]').forEach(det => {
- const item = items[+det.dataset.itemIdx];
- if (!item) return;
- const url = resolveItemUrl(item, currentLang);
- const link = det.querySelector('.gr-list-acc-link');
- if (link && url) {
- link.href = url;
- fillLangLink(link, url, hasLangs, resolveItemLang(item, currentLang));
- }
- const img = det.querySelector('.gr-list-acc-img');
- if (!item.bg && img) {
- const autoUrl = getAutoPreviewUrl(url);
- if (autoUrl) img.src = autoUrl;
- }
- });
- }
- const cycleBtn = document.createElement('button');
- cycleBtn.className = 'gr-list-cycle-btn';
- cycleBtn.type = 'button';
- cycleBtn.title = 'Cycle display mode';
- cycleBtn.textContent = 'โณ';
- el.appendChild(cycleBtn);
- function applyMode(mode) {
- LIST_MODES.forEach(m => el.classList.remove('gr-list-mode-' + m));
- el.classList.add('gr-list-mode-' + mode);
- cycleBtn.setAttribute('aria-label', 'Display mode: ' + LIST_MODE_LABELS[mode] + '. Click to cycle.');
- body.innerHTML = '';
- buildListBody(body, w, mode, currentLang, hasLangs);
- }
- cycleBtn.addEventListener('click', (e) => {
- e.preventDefault();
- e.stopPropagation();
- const current = getEffectiveListMode(w);
- const nextIdx = (LIST_MODES.indexOf(current) + 1) % LIST_MODES.length;
- const next = LIST_MODES[nextIdx];
- setListModeOverride(w.id, next);
- applyMode(next);
- });
- applyMode(getEffectiveListMode(w));
- return el;
- }
- function buildListBody(body, w, mode, lang, hasLangs) {
- switch (mode) {
- case 'horizontal':
- buildListStrip(body, w, lang, hasLangs, 'gr-list-strip', 'x');
- return;
- case 'vertical':
- buildListStrip(body, w, lang, hasLangs, 'gr-list-vstrip', 'y');
- return;
- case 'accordion':
- buildListAccordion(body, w, lang, hasLangs);
- return;
- case 'carousel':
- buildListCarousel(body, w, lang, hasLangs);
- return;
- }
- }
- function buildListStrip(body, w, lang, hasLangs, cls, axis) {
- const strip = document.createElement('div');
- strip.className = cls;
- (w.items || []).forEach((item, idx) => {
- strip.appendChild(buildListCard(item, lang, hasLangs, idx));
- });
- body.appendChild(strip);
- enableDragScroll(strip, axis);
- }
- function buildListAccordion(body, w, lang, hasLangs) {
- const list = document.createElement('div');
- list.className = 'gr-list-accordion';
- (w.items || []).forEach((item, idx) => {
- const det = document.createElement('details');
- det.className = 'gr-list-acc-item';
- det.dataset.itemIdx = idx;
- const sum = document.createElement('summary');
- sum.className = 'gr-list-acc-summary';
- sum.textContent = item.title || 'Untitled';
- det.appendChild(sum);
- const content = document.createElement('div');
- content.className = 'gr-list-acc-content';
- const accBgUrl = item.bg ? expandMediaUrl(item.bg) : getAutoPreviewUrl(resolveItemUrl(item, lang));
- if (accBgUrl) {
- const img = document.createElement('img');
- img.className = 'gr-list-acc-img';
- img.src = accBgUrl;
- img.alt = '';
- img.loading = 'lazy';
- img.onerror = () => img.remove();
- content.appendChild(img);
- }
- if (item.text) {
- const txt = document.createElement('div');
- txt.className = 'gr-list-acc-text';
- txt.innerHTML = linkifyText(item.text);
- content.appendChild(txt);
- }
- const resolvedUrl = resolveItemUrl(item, lang);
- if (resolvedUrl) {
- const link = document.createElement('a');
- link.className = 'gr-list-acc-link';
- setupExternalLink(link, resolvedUrl, { youtube: true });
- fillLangLink(link, resolvedUrl, hasLangs, resolveItemLang(item, lang));
- content.appendChild(link);
- }
- det.appendChild(content);
- list.appendChild(det);
- });
- body.appendChild(list);
- enableDragScroll(list, 'y');
- }
- function makeCarouselShell(count, renderAt) {
- const root = document.createElement('div');
- root.className = 'gr-list-carousel';
- root.tabIndex = 0;
- if (count <= 0) {
- const empty = document.createElement('div');
- empty.className = 'gr-list-car-empty';
- empty.textContent = '(no items)';
- root.appendChild(empty);
- return root;
- }
- let idx = 0;
- const prev = document.createElement('button');
- prev.className = 'gr-list-car-btn gr-list-car-prev';
- prev.type = 'button';
- prev.setAttribute('aria-label', 'Previous item');
- prev.textContent = 'โ';
- const slot = document.createElement('div');
- slot.className = 'gr-list-car-slot';
- const next = document.createElement('button');
- next.className = 'gr-list-car-btn gr-list-car-next';
- next.type = 'button';
- next.setAttribute('aria-label', 'Next item');
- next.textContent = 'โถ';
- const counter = document.createElement('div');
- counter.className = 'gr-list-car-counter';
- function render() {
- if (idx < 0) idx = count - 1;
- if (idx >= count) idx = 0;
- renderAt(slot, idx);
- counter.textContent = `${idx + 1} / ${count}`;
- }
- prev.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); idx--; render(); root.focus(); });
- next.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); idx++; render(); root.focus(); });
- root.addEventListener('keydown', (e) => {
- if (e.key === 'ArrowLeft') { e.preventDefault(); idx--; render(); }
- else if (e.key === 'ArrowRight') { e.preventDefault(); idx++; render(); }
- });
- let tStartX = 0, tStartY = 0, tActive = false;
- slot.addEventListener('touchstart', (e) => {
- if (e.touches.length !== 1) return;
- tActive = true;
- tStartX = e.touches[0].clientX;
- tStartY = e.touches[0].clientY;
- }, { passive: true });
- slot.addEventListener('touchend', (e) => {
- if (!tActive || e.changedTouches.length !== 1) return;
- tActive = false;
- const dx = e.changedTouches[0].clientX - tStartX;
- const dy = e.changedTouches[0].clientY - tStartY;
- if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) {
- e.preventDefault();
- if (dx < 0) idx++; else idx--;
- render();
- }
- }, { passive: false });
- root.appendChild(slot);
- if (count > 1) {
- root.appendChild(prev);
- root.appendChild(next);
- root.appendChild(counter);
- }
- render();
- return root;
- }
- function buildListCarousel(body, w, lang, hasLangs) {
- const items = w.items || [];
- const shell = makeCarouselShell(items.length, (slot, idx) => {
- slot.innerHTML = '';
- slot.appendChild(buildListCard(items[idx], lang, hasLangs, idx));
- });
- body.appendChild(shell);
- }
- function getAutoPreviewUrl(url) {
- if (!url) return null;
- const ytId = extractYouTubeId(url);
- if (ytId) return `https://i.ytimg.com/vi_webp/${ytId}/maxresdefault.webp`;
- return null;
- }
- function buildListCard(item, lang, hasLangs, itemIdx) {
- const resolvedUrl = resolveItemUrl(item, lang);
- const card = document.createElement(resolvedUrl ? 'a' : 'div');
- card.className = 'gr-list-card';
- card.dataset.itemIdx = itemIdx;
- if (resolvedUrl) {
- setupExternalLink(card, resolvedUrl, { youtube: true });
- }
- const bgUrl = item.bg ? expandMediaUrl(item.bg) : getAutoPreviewUrl(resolvedUrl);
- if (bgUrl) {
- card.classList.add('has-bg');
- lazyBgImage(card, bgUrl);
- }
- if (isYouTubeUrl(resolvedUrl)) {
- const play = document.createElement('div');
- play.className = 'gr-list-yt-play';
- play.innerHTML = YT_PLAY_SVG;
- card.appendChild(play);
- }
- // Language badge
- const itemLang = resolveItemLang(item, lang);
- if (hasLangs && itemLang) {
- card.appendChild(makeLangBadge(itemLang));
- }
- const t = document.createElement('div');
- t.className = 'gr-list-card-title';
- t.textContent = item.title || '';
- card.appendChild(t);
- return card;
- }
- function isYouTubeUrl(url) {
- if (!url) return false;
- return /(?:^|\.)youtube\.com|(?:^|\/)youtu\.be/i.test(url);
- }
- const HASH_PATTERN = /^[0-9a-f]{64}(?:\.[a-z0-9]+)?$/i;
- function expandMediaUrl(stored) {
- if (!stored) return stored;
- if (HASH_PATTERN.test(stored)) {
- return window.location.origin + '/.media/' + stored;
- }
- return stored;
- }
- function extractYouTubeId(url) {
- if (!url) return null;
- try {
- const u = new URL(url);
- const host = u.hostname.toLowerCase();
- // youtu.be/ID (short form)
- if (/(?:^|\.)youtu\.be$/.test(host)) {
- const id = u.pathname.replace(/^\/+/, '').split('/')[0];
- return id || null;
- }
- if (/(?:^|\.)youtube\.com$/.test(host)) {
- // /watch?v=ID
- const v = u.searchParams.get('v');
- if (v) return v;
- // /embed/ID, /shorts/ID, /v/ID, /live/ID
- const m = u.pathname.match(/^\/(?:embed|shorts|v|live)\/([^/?#]+)/);
- if (m) return m[1];
- }
- } catch (e) { /* bad URL */ }
- return null;
- }
- function extractYouTubeStart(url) {
- try {
- const u = new URL(url);
- const raw = u.searchParams.get('t') || u.searchParams.get('start') || '';
- if (!raw) return 0;
- if (/^\d+$/.test(raw)) return parseInt(raw, 10);
- let total = 0;
- const m = raw.match(/(\d+h)?(\d+m)?(\d+s)?/i);
- if (m) {
- if (m[1]) total += parseInt(m[1], 10) * 3600;
- if (m[2]) total += parseInt(m[2], 10) * 60;
- if (m[3]) total += parseInt(m[3], 10);
- }
- return total;
- } catch (e) { return 0; }
- }
- function openYouTubeLightbox(url) {
- const id = extractYouTubeId(url);
- if (!id) {
- try { window.open(url, '_blank', 'noopener'); } catch (e) { }
- return;
- }
- const start = extractYouTubeStart(url);
- const params = new URLSearchParams({
- autoplay: '1',
- rel: '0',
- modestbranding: '1',
- });
- if (start > 0) params.set('start', String(start));
- const embedUrl = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}?${params.toString()}`;
- openLightboxIframe(embedUrl);
- }
- function renderAttachments(w, opts) {
- opts = opts || {};
- const { el, body } = createWidgetShell(w, 'gr-att-body');
- if (opts.placeholder) {
- const count = opts.placeholderCount || 5;
- const shell = makeCarouselShell(count, (slot, idx) => {
- slot.innerHTML = '';
- const tile = document.createElement('div');
- tile.className = 'gr-att-tile gr-att-placeholder';
- tile.textContent = `Attachment ${idx + 1}`;
- slot.appendChild(tile);
- });
- body.appendChild(shell);
- return el;
- }
- const postEl = opts.postEl;
- const panel = postEl ? postEl.querySelector('.opUploadPanel') : null;
- const cellsNodeList = panel ? panel.querySelectorAll('.uploadCell') : [];
- const cells = Array.from(cellsNodeList);
- if (cells.length === 0) {
- const empty = document.createElement('div');
- empty.className = 'gr-att-empty';
- empty.textContent = 'no attachments';
- body.appendChild(empty);
- return el;
- }
- if (postEl) {
- let moved = state.movedAttachments.get(postEl);
- if (!moved) { moved = []; state.movedAttachments.set(postEl, moved); }
- cells.forEach(cell => moved.push({ cell, originalParent: cell.parentNode }));
- }
- cells.forEach(cell => {
- cell.querySelectorAll('details').forEach(d => { d.open = true; });
- cell.querySelectorAll('a.imgLink img').forEach(img => { img.style.display = ''; });
- cell.querySelectorAll('a.imgLink').forEach(a => { a.style.display = ''; });
- cell.querySelectorAll('.imgExpanded, .hideLink').forEach(n => n.remove());
- cell.querySelectorAll('video').forEach(v => {
- try { v.pause(); } catch (e) { }
- v.remove();
- });
- });
- const pool = document.createElement('div');
- pool.className = 'gr-att-pool';
- pool.style.display = 'none';
- cells.forEach(cell => pool.appendChild(cell));
- body.appendChild(pool);
- let currentCell = null;
- const shell = makeCarouselShell(cells.length, (slot, idx) => {
- if (currentCell) pool.appendChild(currentCell);
- currentCell = cells[idx];
- slot.appendChild(currentCell);
- });
- shell.addEventListener('click', onAttachmentStripClick, true);
- body.appendChild(shell);
- if (panel) {
- panel.style.display = 'none';
- panel.dataset.grHidden = '1';
- }
- return el;
- }
- function onAttachmentStripClick(e) {
- const a = e.target.closest && e.target.closest('a.imgLink');
- if (!a) return;
- const href = a.getAttribute('href');
- if (!href) return;
- const mime = a.getAttribute('data-filemime') || '';
- e.preventDefault();
- e.stopPropagation();
- e.stopImmediatePropagation();
- openLightbox(href, mime);
- }
- function renderThreadContent(w, opts) {
- opts = opts || {};
- const el = document.createElement('div');
- el.className = 'gr-thread-content';
- if (opts.placeholder) {
- el.classList.add('gr-widget');
- el.innerHTML = '<div class="gr-widget-body"><div class="gr-content-placeholder">Thread content will appear here</div></div>';
- return el;
- }
- if (opts.contentFragment) {
- el.appendChild(opts.contentFragment);
- }
- return el;
- }
- function openLightbox(url, mime) {
- let media;
- if (/^video\//i.test(mime)) {
- media = document.createElement('video');
- media.src = url;
- media.controls = true;
- media.autoplay = true;
- media.loop = true;
- } else {
- media = document.createElement('img');
- media.src = url;
- media.alt = '';
- }
- media.className = 'gr-lightbox-media';
- openLightboxWithContent(media);
- }
- function openLightboxIframe(embedUrl) {
- const iframe = document.createElement('iframe');
- iframe.className = 'gr-lightbox-media gr-lightbox-iframe';
- iframe.src = embedUrl;
- iframe.setAttribute('frameborder', '0');
- iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
- iframe.setAttribute('allowfullscreen', '');
- openLightboxWithContent(iframe);
- }
- function openLightboxWithContent(contentEl) {
- closeLightbox();
- const backdrop = document.createElement('div');
- backdrop.className = 'gr-lightbox-backdrop';
- const frame = document.createElement('div');
- frame.className = 'gr-lightbox-frame';
- frame.addEventListener('click', (e) => e.stopPropagation());
- frame.appendChild(contentEl);
- const close = document.createElement('button');
- close.type = 'button';
- close.className = 'gr-lightbox-close';
- close.setAttribute('aria-label', 'Close');
- close.textContent = 'โ';
- close.addEventListener('click', (e) => { e.stopPropagation(); closeLightbox(); });
- frame.appendChild(close);
- backdrop.appendChild(frame);
- backdrop.addEventListener('click', closeLightbox);
- state.lightboxKeyHandler = (ev) => { if (ev.key === 'Escape') closeLightbox(); };
- document.addEventListener('keydown', state.lightboxKeyHandler);
- document.body.appendChild(backdrop);
- state.lightboxEl = backdrop;
- }
- function closeLightbox() {
- if (state.lightboxEl) {
- state.lightboxEl.remove();
- state.lightboxEl = null;
- }
- if (state.lightboxKeyHandler) {
- document.removeEventListener('keydown', state.lightboxKeyHandler);
- state.lightboxKeyHandler = null;
- }
- }
- const YT_PLAY_SVG = `<svg viewBox="0 0 68 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
- <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"/>
- <path d="M45 24L27 14v20z" fill="#fff"/>
- </svg>`;
- function renderTimeline(w, opts) {
- opts = opts || {};
- const { el, body } = createWidgetShell(w);
- const events = (w.events || []).slice();
- if (events.length === 0) {
- body.textContent = '(no events)';
- return el;
- }
- let maxLane = 0;
- events.forEach(ev => { if ((ev.lane || 0) > maxLane) maxLane = ev.lane; });
- if (isMobile()) {
- const HEADER_RESERVE = 40;
- const PER_LANE = 28;
- const neededHeight = HEADER_RESERVE + (maxLane + 1) * PER_LANE + 8;
- body.style.minHeight = neededHeight + 'px';
- }
- setupCanvasTimeline(body, events, opts);
- return el;
- }
- const tzFmtCache = new Map();
- function tzFormatter(tz, opts) {
- const key = (tz || 'UTC') + ':' + JSON.stringify(opts);
- let f = tzFmtCache.get(key);
- if (!f) {
- f = new Intl.DateTimeFormat(undefined, Object.assign({ timeZone: tz || 'UTC' }, opts));
- tzFmtCache.set(key, f);
- }
- return f;
- }
- function tzParts(date, tz) {
- const parts = tzFormatter(tz, {
- year: 'numeric', month: '2-digit', day: '2-digit',
- hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
- }).formatToParts(date).reduce((o, p) => { o[p.type] = p.value; return o; }, {});
- const hh = parts.hour === '24' ? '00' : parts.hour;
- return { y: +parts.year, m: +parts.month, d: +parts.day, h: +hh, mi: +parts.minute, s: +parts.second };
- }
- function tzOffsetMs(date, tz) {
- if (!tz || tz === 'UTC') return 0;
- const p = tzParts(date, tz);
- const asIfUtc = Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s);
- return asIfUtc - date.getTime();
- }
- function forEachLocalMidnight(startMs, endMs, cb) {
- const start = new Date(startMs);
- start.setHours(0, 0, 0, 0); // local midnight
- let ms = start.getTime();
- while (ms > startMs) ms -= 86400000; // step back if we overshot
- const d = new Date(ms);
- while (d.getTime() <= endMs) {
- cb(d.getTime(), { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() });
- d.setDate(d.getDate() + 1);
- d.setHours(0, 0, 0, 0);
- }
- }
- const ENGLISH_MONTH_FMT = new Intl.DateTimeFormat('en', { month: 'short' });
- function wallTimeToUtcMs(wall, tz) {
- if (!wall) return NaN;
- const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(wall);
- if (!m) return NaN;
- const y = +m[1], mo = +m[2], d = +m[3], h = +m[4], mi = +m[5], s = +(m[6] || 0);
- const pretend = Date.UTC(y, mo - 1, d, h, mi, s);
- const offset = tzOffsetMs(new Date(pretend), tz || 'UTC');
- return pretend - offset;
- }
- function setupCanvasTimeline(container, events, opts) {
- opts = opts || {};
- const interactive = opts.interactive !== false;
- const tz = opts.tz || 'UTC';
- const serverName = opts.serverName || '';
- const wrap = document.createElement('div');
- wrap.className = 'gr-timeline-canvas-wrap';
- const canvas = document.createElement('canvas');
- canvas.className = 'gr-timeline-canvas';
- wrap.appendChild(canvas);
- container.appendChild(wrap);
- let ctx;
- try { ctx = canvas.getContext('2d'); }
- catch (e) { ctx = null; }
- if (!ctx) {
- wrap.removeChild(canvas);
- const notice = document.createElement('div');
- notice.className = 'gr-timeline-noctx';
- notice.innerHTML =
- '<strong>Timeline unavailable.</strong><br>' +
- 'Your browser is blocking canvas access (anti-fingerprinting / privacy mode). ' +
- 'Allow canvas for this site to see the timeline.';
- wrap.appendChild(notice);
- return;
- }
- const DPR = window.devicePixelRatio || 1;
- const now = Date.now();
- const DAY_MS = 86400000;
- const VIEW_SPAN_DAYS_DESKTOP = 40;
- const VIEW_SPAN_DAYS_MOBILE = 20;
- const view = {
- centerMs: now,
- spanMs: (isMobile() ? VIEW_SPAN_DAYS_MOBILE : VIEW_SPAN_DAYS_DESKTOP) * DAY_MS,
- };
- const MONTH_PAD_MS = 30 * DAY_MS;
- const boundMin = (function () {
- let min = Infinity;
- events.forEach(ev => {
- const s = wallTimeToUtcMs(ev.start, tz);
- if (Number.isFinite(s) && s < min) min = s;
- });
- return (min === Infinity ? now : min) - MONTH_PAD_MS;
- })();
- const boundMax = (function () {
- let max = -Infinity;
- events.forEach(ev => {
- const s = wallTimeToUtcMs(ev.start, tz);
- const e = ev.end ? wallTimeToUtcMs(ev.end, tz) : s;
- if (Number.isFinite(e) && e > max) max = e;
- });
- return (max === -Infinity ? now : max) + MONTH_PAD_MS;
- })();
- function clampCenter() {
- const halfSpan = view.spanMs / 2;
- if (boundMax - boundMin < view.spanMs) {
- view.centerMs = (boundMin + boundMax) / 2;
- return;
- }
- const minCenter = boundMin + halfSpan;
- const maxCenter = boundMax - halfSpan;
- if (view.centerMs < minCenter) view.centerMs = minCenter;
- else if (view.centerMs > maxCenter) view.centerMs = maxCenter;
- }
- clampCenter();
- const LANE_HEIGHT = 26;
- const LANE_PAD = 2;
- const HEADER_H = 32;
- const imgCache = new Map();
- let imgPreloadStarted = false;
- function preloadEventImages() {
- if (imgPreloadStarted) return;
- imgPreloadStarted = true;
- events.forEach(ev => {
- if (ev.bg && !imgCache.has(ev.bg)) {
- const img = new Image();
- img.crossOrigin = 'anonymous';
- img.onload = () => { imgCache.set(ev.bg, img); scheduleDraw(); };
- img.onerror = () => { imgCache.set(ev.bg, null); };
- img.src = expandMediaUrl(ev.bg);
- imgCache.set(ev.bg, 'loading');
- }
- });
- }
- function timeToX(t, w) {
- const startMs = view.centerMs - view.spanMs / 2;
- return ((t - startMs) / view.spanMs) * w;
- }
- function xToTime(x, w) {
- const startMs = view.centerMs - view.spanMs / 2;
- return startMs + (x / w) * view.spanMs;
- }
- let pendingDraw = false;
- let tlVisible = true;
- function scheduleDraw() {
- if (pendingDraw) return;
- if (!tlVisible) return; // skip while offscreen
- pendingDraw = true;
- requestAnimationFrame(() => { pendingDraw = false; draw(); });
- }
- let eventRects = [];
- function contentHeight() {
- let ml = 0;
- events.forEach(ev => { if ((ev.lane || 0) > ml) ml = ev.lane; });
- return HEADER_H + (ml + 1) * (LANE_HEIGHT + LANE_PAD) + 6;
- }
- function draw() {
- const rect = wrap.getBoundingClientRect();
- const W = Math.max(200, rect.width);
- const viewH = Math.max(100, rect.height);
- const H = Math.max(viewH, contentHeight());
- canvas.width = W * DPR;
- canvas.height = H * DPR;
- canvas.style.width = W + 'px';
- canvas.style.height = H + 'px';
- ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
- const bgColor = getCssVar('--menu-color', '#eef2ff');
- ctx.fillStyle = bgColor;
- ctx.fillRect(0, 0, W, H);
- const textColor = getCssVar('--text-color', '#000');
- const borderColor = getCssVar('--border-color', '#000');
- const sepColor = getCssVar('--border-color', '#b7c5d9');
- const startMs = view.centerMs - view.spanMs / 2;
- const endMs = view.centerMs + view.spanMs / 2;
- ctx.font = '9pt Arial, sans-serif';
- ctx.textBaseline = 'top';
- forEachLocalMidnight(startMs, endMs, (t, p) => {
- if (t < startMs - 86400000 || t > endMs + 86400000) return;
- const x = timeToX(t, W);
- const isMonthStart = p.d === 1;
- ctx.strokeStyle = isMonthStart ? borderColor : sepColor;
- ctx.lineWidth = isMonthStart ? 1.5 : 0.5;
- ctx.beginPath();
- ctx.moveTo(x + 0.5, 0);
- ctx.lineTo(x + 0.5, H);
- ctx.stroke();
- ctx.fillStyle = textColor;
- ctx.fillText(String(p.d), x + 2, 14);
- if (isMonthStart) {
- ctx.font = 'bold 10pt Arial, sans-serif';
- const monthStr = ENGLISH_MONTH_FMT.format(new Date(t));
- ctx.fillText(monthStr, x + 2, 0);
- ctx.font = '9pt Arial, sans-serif';
- }
- });
- let maxLane = 0;
- events.forEach(ev => { if ((ev.lane || 0) > maxLane) maxLane = ev.lane; });
- eventRects = [];
- events.forEach(ev => {
- const sMs = wallTimeToUtcMs(ev.start, tz);
- const eMs = ev.end ? wallTimeToUtcMs(ev.end, tz) : sMs + DAY_MS * 0.5;
- if (eMs < startMs || sMs > endMs) return;
- const x1 = timeToX(sMs, W);
- const x2 = timeToX(eMs, W);
- const lane = ev.lane || 0;
- const y = HEADER_H + lane * (LANE_HEIGHT + LANE_PAD);
- const barH = LANE_HEIGHT;
- const barW = Math.max(2, x2 - x1);
- ctx.fillStyle = ev.color || '#aaa';
- ctx.fillRect(x1, y, barW, barH);
- const img = ev.bg ? imgCache.get(ev.bg) : null;
- if (img && img !== 'loading' && img instanceof HTMLImageElement) {
- ctx.save();
- ctx.beginPath();
- ctx.rect(x1, y, barW, barH);
- ctx.clip();
- const imgH = barH;
- const imgW = (img.width / img.height) * imgH;
- ctx.globalAlpha = 0.9;
- ctx.drawImage(img, x1 + barW - imgW, y, imgW, imgH);
- ctx.globalAlpha = 1.0;
- ctx.restore();
- }
- ctx.strokeStyle = borderColor;
- ctx.lineWidth = 1;
- ctx.strokeRect(x1 + 0.5, y + 0.5, barW, barH);
- ctx.fillStyle = '#000';
- ctx.font = 'bold 9pt Arial, sans-serif';
- ctx.textBaseline = 'middle';
- ctx.save();
- ctx.beginPath();
- ctx.rect(x1 + 4, y, barW - 8, barH);
- ctx.clip();
- const padL = 6, padR = 4;
- const naturalLeft = x1 + padL;
- const viewLeft = padL;
- const textLeft = Math.max(naturalLeft, viewLeft);
- const textRight = x1 + barW - padR;
- const maxTextW = Math.max(0, textRight - textLeft);
- ctx.fillText(fitTextToWidth(ctx, ev.name, maxTextW), textLeft, y + barH / 2);
- ctx.restore();
- ctx.textBaseline = 'top';
- eventRects.push({ x: x1, y, w: barW, h: barH, event: ev });
- });
- const nowX = timeToX(Date.now(), W);
- if (nowX >= 0 && nowX <= W) {
- ctx.strokeStyle = getCssVar('--alert-color', 'red');
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(nowX + 0.5, 0);
- ctx.lineTo(nowX + 0.5, H);
- ctx.stroke();
- ctx.fillStyle = getCssVar('--alert-color', 'red');
- ctx.font = 'bold 9pt Arial, sans-serif';
- const label = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date());
- const lw = ctx.measureText(label).width + 8;
- ctx.fillRect(nowX - lw / 2, 0, lw, 14);
- ctx.fillStyle = '#fff';
- ctx.fillText(label, nowX - lw / 2 + 4, 1);
- }
- }
- const DRAG_THRESHOLD = 5;
- let pressed = false;
- let dragging = false;
- let pressStartX = 0, pressStartY = 0, pressStartCenter = 0, pressStartScrollTop = 0;
- let lastClientX = 0;
- function tryHitEvent(clientX, clientY) {
- const rect = canvas.getBoundingClientRect();
- const x = clientX - rect.left;
- const y = clientY - rect.top;
- for (let i = eventRects.length - 1; i >= 0; i--) {
- const r = eventRects[i];
- if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
- return r.event;
- }
- }
- return null;
- }
- function openPopupAt(clientX, clientY, ev) {
- const anchor = document.createElement('div');
- anchor.style.cssText = `position:absolute;left:${clientX + window.scrollX}px;top:${clientY + window.scrollY}px;width:1px;height:1px;`;
- document.body.appendChild(anchor);
- showEventPopup(anchor, ev, tz, serverName);
- setTimeout(() => anchor.remove(), 100);
- }
- canvas.addEventListener('mousedown', (e) => {
- if (!interactive) return;
- if (e.button !== 0) return;
- pressed = true;
- dragging = false;
- pressStartX = e.clientX;
- pressStartY = e.clientY;
- pressStartCenter = view.centerMs;
- pressStartScrollTop = wrap.scrollTop;
- lastClientX = e.clientX;
- });
- window.addEventListener('mousemove', (e) => {
- if (!pressed) return;
- const dx = e.clientX - pressStartX;
- const dy = e.clientY - pressStartY;
- if (!dragging && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
- dragging = true;
- canvas.classList.add('dragging');
- }
- if (dragging) {
- const W = canvas.clientWidth || 600;
- view.centerMs = pressStartCenter - (dx / W) * view.spanMs;
- clampCenter();
- wrap.scrollTop = pressStartScrollTop - dy;
- scheduleDraw();
- }
- lastClientX = e.clientX;
- });
- window.addEventListener('mouseup', (e) => {
- if (!pressed) return;
- const wasDragging = dragging;
- pressed = false;
- dragging = false;
- canvas.classList.remove('dragging');
- if (!wasDragging) {
- const ev = tryHitEvent(e.clientX, e.clientY);
- if (ev) openPopupAt(e.clientX, e.clientY, ev);
- }
- });
- let touchPressed = false, touchDragging = false, touchAxisDecided = false, touchAxisX = false;
- let touchStartX = 0, touchStartY = 0, touchStartCenter = 0, touchStartScrollTop = 0;
- canvas.addEventListener('touchstart', (e) => {
- if (!interactive) return;
- if (e.touches.length !== 1) return;
- touchPressed = true;
- touchDragging = false;
- touchAxisDecided = false;
- touchAxisX = false;
- touchStartX = e.touches[0].clientX;
- touchStartY = e.touches[0].clientY;
- touchStartCenter = view.centerMs;
- touchStartScrollTop = wrap.scrollTop;
- }, { passive: true });
- canvas.addEventListener('touchmove', (e) => {
- if (!touchPressed || e.touches.length !== 1) return;
- const dx = e.touches[0].clientX - touchStartX;
- const dy = e.touches[0].clientY - touchStartY;
- if (!touchAxisDecided && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
- touchAxisDecided = true;
- touchAxisX = Math.abs(dx) > Math.abs(dy);
- touchDragging = true;
- }
- if (touchDragging && touchAxisX) {
- const W = canvas.clientWidth || 600;
- view.centerMs = touchStartCenter - (dx / W) * view.spanMs;
- clampCenter();
- wrap.scrollTop = touchStartScrollTop - dy;
- scheduleDraw();
- e.preventDefault();
- }
- }, { passive: false });
- canvas.addEventListener('touchend', (e) => {
- if (!touchPressed) return;
- const wasDragging = touchDragging && touchAxisX;
- touchPressed = false;
- touchDragging = false;
- if (!wasDragging && !touchAxisDecided && e.changedTouches.length === 1) {
- const t = e.changedTouches[0];
- const ev = tryHitEvent(t.clientX, t.clientY);
- if (ev) openPopupAt(t.clientX, t.clientY, ev);
- }
- });
- canvas.addEventListener('touchcancel', () => {
- touchPressed = false; touchDragging = false;
- });
- const ro = new ResizeObserver(() => scheduleDraw());
- ro.observe(wrap);
- if (interactive) {
- function scheduleMinuteTick() {
- const msUntilNextMinute = 60000 - (Date.now() % 60000);
- setTimeout(() => {
- scheduleDraw();
- setInterval(() => scheduleDraw(), 60000);
- }, msUntilNextMinute);
- }
- scheduleMinuteTick();
- observeVisibility(wrap,
- () => { tlVisible = true; preloadEventImages(); scheduleDraw(); },
- () => { tlVisible = false; }
- );
- }
- requestAnimationFrame(() => scheduleDraw());
- }
- function getCssVar(name, fallback) {
- const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
- if (!v) return fallback;
- // Resolve light-dark(lightVal, darkVal)
- const ld = v.match(/^light-dark\(\s*(.+?)\s*,\s*(.+?)\s*\)$/i);
- if (ld) {
- const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
- || document.documentElement.classList.contains('dark')
- || document.documentElement.dataset.theme === 'dark'
- || document.documentElement.style.colorScheme === 'dark';
- return isDark ? ld[2] : ld[1];
- }
- return v;
- }
- const LAYOUT_OVERRIDE_PREFIX = 'grid-layout:';
- let _layoutOverrideCache = undefined;
- function getLayoutOverride() {
- if (_layoutOverrideCache !== undefined) return _layoutOverrideCache;
- try {
- if (typeof threadSubject === 'undefined') return (_layoutOverrideCache = null);
- const raw = localStorage.getItem(LAYOUT_OVERRIDE_PREFIX + threadSubject);
- return (_layoutOverrideCache = raw ? JSON.parse(raw) : null);
- } catch { return (_layoutOverrideCache = null); }
- }
- function setLayoutOverride(overrides) {
- _layoutOverrideCache = overrides;
- try {
- if (typeof threadSubject === 'undefined') return;
- if (overrides && Object.keys(overrides).length) {
- localStorage.setItem(LAYOUT_OVERRIDE_PREFIX + threadSubject, JSON.stringify(overrides));
- } else {
- localStorage.removeItem(LAYOUT_OVERRIDE_PREFIX + threadSubject);
- }
- } catch { /* ignore */ }
- }
- const GRID_SPAN_RE = /(\d+)\s*\/\s*span\s*(\d+)/;
- function parseGridSpan(value) {
- const m = GRID_SPAN_RE.exec(value || '');
- return m ? { start: +m[1] - 1, span: +m[2] } : null;
- }
- function getGridPos(el) {
- const c = parseGridSpan(el.style.gridColumn);
- const r = parseGridSpan(el.style.gridRow);
- if (!c || !r) return null;
- return { x: c.start, y: r.start, w: c.span, h: r.span };
- }
- function setGridPos(el, x, y, w, h) {
- el.style.gridColumn = `${x + 1} / span ${w}`;
- el.style.gridRow = `${y + 1} / span ${h}`;
- }
- function reprocessAllPosts() {
- document.querySelectorAll(OP_SELECTOR).forEach(op => {
- restorePost(op);
- processPost(op);
- });
- }
- function addResizeHandle(parent) {
- const handle = document.createElement('div');
- handle.className = 'gr-resize-handle';
- parent.appendChild(handle);
- return handle;
- }
- function makeHideBtn(title, onClick) {
- const btn = document.createElement('span');
- btn.className = 'gr-hide-btn';
- btn.textContent = '\u00d7';
- btn.title = title;
- btn.addEventListener('click', onClick);
- return btn;
- }
- function renderWidget(w, opts) {
- opts = opts || {};
- const overrides = getLayoutOverride();
- const ov = overrides && overrides[w.id];
- const ww = ov ? { ...w, x: ov.x, y: ov.y, w: ov.w, h: ov.h } : w;
- let el;
- switch (w.type) {
- case 'generic': el = renderGeneric(w, opts); break;
- case 'link': el = renderLink(w, opts); break;
- case 'list': el = renderList(w, opts); break;
- case 'timeline': el = renderTimeline(w, opts); break;
- case 'countdown': el = renderCountdown(w, opts); break;
- case 'attachments': el = renderAttachments(w, opts); break;
- case 'content': el = renderThreadContent(w, opts); break;
- default:
- el = document.createElement('div');
- el.className = 'gr-widget gr-error';
- el.textContent = `Unknown widget type: ${w.type}`;
- }
- el.classList.add('gr-widget-' + w.type);
- el.dataset.widgetId = w.id;
- const x = Math.max(0, Math.min(GRID_COLS - 1, ww.x | 0));
- const width = Math.max(1, Math.min(GRID_COLS - x, ww.w | 0));
- const y = Math.max(0, ww.y | 0);
- const height = Math.max(1, ww.h | 0);
- setGridPos(el, x, y, width, height);
- return el;
- }
- function renderGrid(data, opts) {
- opts = opts || {};
- const servers = (data.meta && Array.isArray(data.meta.servers)) ? data.meta.servers : [];
- const defaultId = data.meta && data.meta.defaultServerId;
- const xThreadLink = opts.crossThreadLink || null;
- if (servers.length === 0 && !xThreadLink) {
- return buildGridContent(data, opts, 'UTC', '');
- }
- const wrap = document.createElement('div');
- wrap.className = 'gr-wrap';
- if (opts.interactive === false) wrap.classList.add('gr-non-interactive');
- const bar = document.createElement('div');
- bar.className = 'gr-statusbar';
- if (xThreadLink) {
- const xtWrap = document.createElement('span');
- xtWrap.className = 'gr-statusbar-xthread';
- xtWrap.appendChild(xThreadLink);
- bar.appendChild(xtWrap);
- }
- if (servers.length > 0) {
- const serverGroup = document.createElement('span');
- serverGroup.className = 'gr-statusbar-server-group';
- const label = document.createElement('span');
- label.className = 'gr-statusbar-label';
- label.textContent = 'Server:';
- serverGroup.appendChild(label);
- const select = document.createElement('select');
- select.className = 'gr-statusbar-select';
- servers.forEach(s => {
- const opt = document.createElement('option');
- opt.value = s.id;
- opt.textContent = `${s.name} (${s.tz})`;
- select.appendChild(opt);
- });
- const initial = (
- getStoredServerChoice(servers)
- || (servers.find(s => s.id === defaultId) && defaultId)
- || servers[0].id
- );
- select.value = initial;
- serverGroup.appendChild(select);
- bar.appendChild(serverGroup);
- wrap.appendChild(bar);
- let currentGrid = buildGridContent(data, opts, getTzFor(servers, initial), getServerName(servers, initial));
- wrap.appendChild(currentGrid);
- select.addEventListener('change', () => {
- setStoredServerChoice(servers, select.value);
- const tz = getTzFor(servers, select.value);
- const serverName = getServerName(servers, select.value);
- const childOpts = Object.assign({}, opts, { tz: tz, serverName: serverName });
- data.widgets.forEach(w => {
- if (!TZ_AFFECTED_WIDGETS.has(w.type)) return;
- const escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(w.id) : String(w.id).replace(/"/g, '\\"');
- const existing = currentGrid.querySelector(`[data-widget-id="${escaped}"]`);
- if (!existing) return;
- try {
- const replacement = renderWidget(w, childOpts);
- existing.replaceWith(replacement);
- } catch (e) {
- console.error('[grid-renderer] TZ re-render failed:', w, e);
- }
- });
- });
- } else {
- wrap.appendChild(bar);
- wrap.appendChild(buildGridContent(data, opts, 'UTC', ''));
- }
- if (opts.interactive !== false) {
- const editBtn = document.createElement('span');
- editBtn.className = 'gr-layout-edit-btn gr-edit-toggle coloredIcon';
- editBtn.title = 'Edit layout';
- bar.appendChild(editBtn);
- editBtn.addEventListener('click', () => {
- const root = wrap.querySelector('.gr-root');
- if (!root) return;
- if (root.classList.contains('gr-edit-mode')) {
- exitEditMode(root, editBtn, bar, data.widgets);
- } else {
- enterEditMode(root, data.widgets, editBtn, bar);
- }
- });
- }
- return wrap;
- }
- function enterEditMode(root, widgets, editBtn, bar) {
- root.classList.add('gr-edit-mode');
- editBtn.classList.add('is-active');
- editBtn.title = 'Save layout';
- const resetBtn = document.createElement('span');
- resetBtn.className = 'gr-layout-edit-btn gr-layout-reset-btn coloredIcon';
- resetBtn.title = 'Reset layout';
- resetBtn.addEventListener('click', () => {
- if (!confirm('Reset layout to default? Your customizations will be lost.')) return;
- setLayoutOverride(null);
- _layoutOverrideCache = undefined;
- reprocessAllPosts();
- });
- bar.appendChild(resetBtn);
- const jsonBtn = document.createElement('span');
- jsonBtn.className = 'gr-layout-edit-btn gr-json-btn coloredIcon';
- jsonBtn.title = 'Edit layout JSON';
- bar.appendChild(jsonBtn);
- const exitBtn = document.createElement('span');
- exitBtn.className = 'gr-layout-edit-btn gr-exit-edit-btn coloredIcon';
- exitBtn.title = 'Exit edit mode';
- exitBtn.addEventListener('click', () => {
- if (root._editCleanup) { root._editCleanup(); delete root._editCleanup; }
- _layoutOverrideCache = undefined;
- reprocessAllPosts();
- });
- bar.appendChild(exitBtn);
- const jsonPanel = document.createElement('div');
- jsonPanel.className = 'gr-json-panel';
- jsonPanel.style.display = 'none';
- const jsonTextarea = document.createElement('textarea');
- jsonTextarea.className = 'gr-json-textarea';
- jsonTextarea.spellcheck = false;
- jsonPanel.appendChild(jsonTextarea);
- const jsonActions = document.createElement('div');
- jsonActions.style.cssText = 'display:flex;gap:4px;margin-top:4px;';
- const jsonApply = document.createElement('span');
- jsonApply.className = 'gr-json-apply';
- jsonApply.textContent = 'Apply';
- jsonApply.addEventListener('click', () => {
- const trimmed = jsonTextarea.value.trim();
- try {
- const parsed = trimmed ? JSON.parse(trimmed) : null;
- setLayoutOverride(parsed);
- _layoutOverrideCache = undefined;
- jsonPanel.style.display = 'none';
- reprocessAllPosts();
- } catch (e) {
- alert('Invalid JSON: ' + e.message);
- }
- });
- jsonActions.appendChild(jsonApply);
- const jsonCopy = document.createElement('span');
- jsonCopy.className = 'gr-json-apply';
- jsonCopy.textContent = 'Copy';
- jsonCopy.addEventListener('click', async () => {
- try {
- await navigator.clipboard.writeText(jsonTextarea.value);
- } catch {
- jsonTextarea.select();
- document.execCommand('copy');
- }
- });
- jsonActions.appendChild(jsonCopy);
- jsonPanel.appendChild(jsonActions);
- bar.parentNode.insertBefore(jsonPanel, bar.nextSibling);
- jsonBtn.addEventListener('click', () => {
- const isOpen = jsonPanel.style.display !== 'none';
- if (isOpen) {
- jsonPanel.style.display = 'none';
- } else {
- const ov = getLayoutOverride();
- jsonTextarea.value = ov ? JSON.stringify(ov, null, 2) : '';
- jsonPanel.style.display = '';
- }
- });
- const GAP = 4;
- const colW = () => (root.clientWidth + GAP) / GRID_COLS;
- const rowH = () => ROW_HEIGHT_PX + GAP;
- let activeEl = null;
- let mode = null; // 'drag' or 'resize'
- let startMX, startMY, startGX, startGY, startW, startH;
- const overrides = getLayoutOverride() || {};
- root.querySelectorAll('[data-widget-id]').forEach(el => {
- addResizeHandle(el);
- el.appendChild(makeHideBtn('Hide widget', (e) => {
- e.stopPropagation();
- const id = el.dataset.widgetId;
- el.remove();
- const w = widgets.find(w => w.id === id);
- if (w) root.appendChild(createHiddenPlaceholder(w, overrides));
- }));
- });
- widgets.forEach(w => {
- if (overrides[w.id] && overrides[w.id].hidden) {
- root.appendChild(createHiddenPlaceholder(w, overrides));
- }
- });
- function createHiddenPlaceholder(w, ov) {
- const ph = document.createElement('div');
- ph.className = 'gr-widget gr-widget-hidden';
- ph.dataset.widgetId = w.id;
- ph.dataset.isHidden = '1';
- const pos = ov[w.id] || {};
- const x = pos.x ?? w.x ?? 0;
- const y = pos.y ?? w.y ?? 0;
- const width = pos.w ?? w.w ?? 2;
- const height = pos.h ?? w.h ?? 1;
- setGridPos(ph, x, y, width, height);
- const label = document.createElement('span');
- label.className = 'gr-hidden-label';
- label.textContent = w.title || w.type || w.id;
- ph.appendChild(label);
- const restoreBtn = document.createElement('button');
- restoreBtn.className = 'gr-restore-btn';
- restoreBtn.textContent = 'โบ Show';
- restoreBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- delete ph.dataset.isHidden;
- ph.classList.remove('gr-widget-hidden');
- ph.classList.add('gr-widget-restored');
- label.textContent = 'โ ' + (w.title || w.type || w.id) + ' (will appear on save)';
- restoreBtn.remove();
- });
- ph.appendChild(restoreBtn);
- addResizeHandle(ph);
- return ph;
- }
- function onMouseDown(e) {
- if (e.button !== 0) return;
- if (e.target.closest('.gr-hide-btn, .gr-restore-btn, .gr-add-widget-btn')) return;
- const widget = e.target.closest('[data-widget-id]');
- if (!widget || !root.contains(widget)) return;
- e.preventDefault();
- activeEl = widget;
- const style = widget.style;
- const c = parseGridSpan(style.gridColumn);
- const r = parseGridSpan(style.gridRow);
- startGX = c ? c.start : 0;
- startGY = r ? r.start : 0;
- startW = c ? c.span : 1;
- startH = r ? r.span : 1;
- startMX = e.clientX;
- startMY = e.clientY;
- if (e.target.closest('.gr-resize-handle')) {
- mode = 'resize';
- widget.classList.add('gr-resizing');
- } else {
- mode = 'drag';
- widget.classList.add('gr-dragging');
- }
- }
- function onMouseMove(e) {
- if (!activeEl || !mode) return;
- const cw = colW(), rh = rowH();
- if (mode === 'drag') {
- const dx = Math.round((e.clientX - startMX) / cw);
- const dy = Math.round((e.clientY - startMY) / rh);
- const newX = Math.max(0, Math.min(GRID_COLS - startW, startGX + dx));
- const newY = Math.max(0, startGY + dy);
- setGridPos(activeEl, newX, newY, startW, startH);
- } else {
- const dCols = Math.round((e.clientX - startMX) / cw);
- const dRows = Math.round((e.clientY - startMY) / rh);
- const newW = Math.max(1, Math.min(GRID_COLS - startGX, startW + dCols));
- const newH = Math.max(1, startH + dRows);
- setGridPos(activeEl, startGX, startGY, newW, newH);
- }
- }
- function onMouseUp() {
- if (!activeEl) return;
- activeEl.classList.remove('gr-dragging', 'gr-resizing');
- activeEl = null;
- mode = null;
- }
- function onClickBlock(e) {
- const widget = e.target.closest('[data-widget-id]');
- if (widget && widget.tagName === 'A') {
- e.preventDefault();
- }
- }
- // Import widget button
- const importBtn = document.createElement('span');
- importBtn.className = 'gr-add-widget-btn';
- importBtn.textContent = '+ Import widget';
- importBtn.title = 'Import a widget from JSON';
- importBtn.addEventListener('click', () => {
- const json = prompt('Paste widget JSON:');
- if (!json) return;
- try {
- const w = JSON.parse(json);
- if (!w.type) throw new Error('Missing type');
- const id = '__custom_' + Math.random().toString(36).slice(2, 6);
- w.id = id;
- let maxRow = 0;
- root.querySelectorAll('[data-widget-id]').forEach(el => {
- const r = parseGridSpan(el.style.gridRow);
- if (r) maxRow = Math.max(maxRow, r.start + r.span);
- });
- const childOpts = { tz: 'UTC', serverName: '' };
- const rendered = renderWidget(w, childOpts);
- setGridPos(rendered, 0, maxRow, Math.min(w.w || 6, GRID_COLS), w.h || 2);
- rendered.dataset.widgetJson = JSON.stringify(w);
- rendered.dataset.isAdded = '1';
- addResizeHandle(rendered);
- rendered.appendChild(makeHideBtn('Remove widget', (ev) => {
- ev.stopPropagation();
- rendered.remove();
- }));
- root.appendChild(rendered);
- } catch (e) {
- alert('Invalid widget JSON: ' + e.message);
- }
- });
- root.appendChild(importBtn);
- root.addEventListener('click', onClickBlock, true);
- root.addEventListener('mousedown', onMouseDown);
- document.addEventListener('mousemove', onMouseMove);
- document.addEventListener('mouseup', onMouseUp);
- // Store cleanup ref
- root._editCleanup = () => {
- root.removeEventListener('click', onClickBlock, true);
- root.removeEventListener('mousedown', onMouseDown);
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
- };
- }
- function exitEditMode(root, editBtn, bar, templateWidgets) {
- const templatePos = {};
- (templateWidgets || []).forEach(w => {
- templatePos[w.id] = { x: w.x | 0, y: w.y | 0, w: w.w | 0, h: w.h | 0 };
- });
- const overrides = {};
- root.querySelectorAll('[data-widget-id]').forEach(el => {
- const id = el.dataset.widgetId;
- const pos = getGridPos(el);
- if (!pos) return;
- const isHidden = el.dataset.isHidden === '1';
- const isAdded = el.dataset.isAdded === '1';
- if (isAdded || isHidden) {
- const entry = { x: pos.x, y: pos.y, w: pos.w, h: pos.h };
- if (isHidden) entry.hidden = true;
- if (isAdded) entry.added = true;
- if (el.dataset.widgetJson) {
- try { entry.widget = JSON.parse(el.dataset.widgetJson); } catch {}
- }
- overrides[id] = entry;
- return;
- }
- const orig = templatePos[id];
- if (!orig) return;
- if (pos.x !== orig.x || pos.y !== orig.y || pos.w !== orig.w || pos.h !== orig.h) {
- overrides[id] = { x: pos.x, y: pos.y, w: pos.w, h: pos.h };
- }
- });
- setLayoutOverride(Object.keys(overrides).length ? overrides : null);
- if (root._editCleanup) { root._editCleanup(); delete root._editCleanup; }
- // Invalidate cache and re-render all posts with updated overrides
- _layoutOverrideCache = undefined;
- reprocessAllPosts();
- }
- const TZ_AFFECTED_WIDGETS = new Set([
- 'timeline',
- 'countdown',
- ]);
- function getTzFor(servers, id) {
- const s = servers.find(x => x.id === id);
- return (s && s.tz) || 'UTC';
- }
- function getServerName(servers, id) {
- const s = servers.find(x => x.id === id);
- return (s && s.name) || '';
- }
- function serverChoiceStorageKey(servers) {
- const ids = servers.map(s => s.id).slice().sort().join('|');
- return 'grid-server-choice:' + ids;
- }
- function getStoredServerChoice(servers) {
- try {
- const v = window.localStorage.getItem(serverChoiceStorageKey(servers));
- if (v && servers.some(s => s.id === v)) return v;
- } catch (e) { /* storage unavailable */ }
- return null;
- }
- function setStoredServerChoice(servers, id) {
- try { window.localStorage.setItem(serverChoiceStorageKey(servers), id); }
- catch (e) { /* ignore */ }
- }
- function buildGridContent(data, opts, tz, serverName) {
- const root = document.createElement('div');
- root.className = 'gr-root';
- if (opts.interactive === false) root.classList.add('gr-non-interactive');
- const orderOf = w => (w.order == null ? Infinity : w.order);
- const widgets = data.widgets.slice().sort((a, b) =>
- (orderOf(a) - orderOf(b)) || (a.y - b.y) || (a.x - b.x)
- );
- const childOpts = Object.assign({}, opts, { tz: tz, serverName: serverName });
- const groups = [
- { type: 'link', className: 'gr-link-group', collected: [], lastAnchor: null },
- { type: 'countdown', className: 'gr-countdown-group', collected: [], lastAnchor: null },
- ];
- function groupFor(type) { return groups.find(g => g.type === type) || null; }
- widgets.forEach(w => {
- const overrides = getLayoutOverride();
- if (overrides && overrides[w.id] && overrides[w.id].hidden) return;
- try {
- const rendered = renderWidget(w, childOpts);
- const g = groupFor(w.type);
- if (g) {
- g.collected.push(rendered);
- const marker = document.createComment('gr-group-anchor:' + w.type);
- root.appendChild(marker);
- g.lastAnchor = marker;
- } else {
- root.appendChild(rendered);
- }
- } catch (e) {
- console.error('[grid-renderer] widget render failed:', w, e);
- const err = document.createElement('div');
- err.className = 'gr-widget gr-error';
- err.textContent = `Widget error: ${e.message}`;
- root.appendChild(err);
- }
- });
- groups.forEach(g => {
- if (!g.lastAnchor) return;
- const container = document.createElement('div');
- container.className = g.className;
- g.collected.forEach(el => container.appendChild(el));
- g.lastAnchor.parentNode.replaceChild(container, g.lastAnchor);
- });
- Array.from(root.childNodes).forEach(n => {
- if (n.nodeType === 8 /* Comment */ && typeof n.data === 'string' && n.data.startsWith('gr-group-anchor:')) {
- n.remove();
- }
- });
- // Render user-imported custom widgets from the override
- const overrides = getLayoutOverride();
- if (overrides) {
- const templateIds = new Set(data.widgets.map(w => w.id));
- Object.entries(overrides).forEach(([id, ov]) => {
- if (!ov.added || !ov.widget || ov.hidden) return;
- if (templateIds.has(id)) return;
- try {
- const w = { ...ov.widget, id, x: ov.x, y: ov.y, w: ov.w, h: ov.h };
- const el = renderWidget(w, childOpts);
- el.dataset.isAdded = '1';
- el.dataset.widgetJson = JSON.stringify(ov.widget);
- root.appendChild(el);
- } catch (e) {
- console.error('[grid-renderer] imported widget failed:', id, e);
- }
- });
- }
- return root;
- }
- function findCrossThreadLink(msgEl) {
- const links = msgEl.querySelectorAll('a.quoteLink');
- for (const a of links) {
- if (a.querySelector('span.qmark-ct')) return a;
- }
- return links.length > 0 ? links[0] : null;
- }
- function processPost(postEl) {
- const msgEl = postEl.querySelector(MSG_SELECTOR);
- if (!msgEl) return;
- const extracted = extractGridData(postEl, msgEl);
- if (!extracted) {
- if (state.rendered.has(postEl)) return;
- const hasOverride = !!getLayoutOverride();
- const wrap = document.createElement('div');
- wrap.className = 'gr-wrap gr-wrap-empty';
- const bar = document.createElement('div');
- bar.className = 'gr-statusbar';
- wrap.appendChild(bar);
- const emptyData = { meta: {}, widgets: [] };
- if (hasOverride) {
- wrap.appendChild(buildGridContent(emptyData, {}, 'UTC', ''));
- }
- const editBtn = document.createElement('span');
- editBtn.className = 'gr-layout-edit-btn gr-edit-toggle coloredIcon';
- editBtn.title = 'Edit layout';
- bar.appendChild(editBtn);
- editBtn.addEventListener('click', () => {
- const root = wrap.querySelector('.gr-root');
- if (root && root.classList.contains('gr-edit-mode')) {
- exitEditMode(root, editBtn, bar, []);
- } else {
- let gridRoot = root;
- if (!gridRoot) {
- gridRoot = buildGridContent(emptyData, {}, 'UTC', '');
- wrap.appendChild(gridRoot);
- }
- enterEditMode(gridRoot, [], editBtn, bar);
- }
- });
- if (!state.originals.has(postEl)) {
- state.originals.set(postEl, msgEl.innerHTML);
- }
- msgEl.appendChild(wrap);
- state.rendered.add(postEl);
- return;
- }
- if (!state.originals.has(postEl)) {
- state.originals.set(postEl, msgEl.innerHTML);
- }
- let crossThreadLink = findCrossThreadLink(msgEl);
- if (crossThreadLink) {
- crossThreadLink.remove();
- }
- const codeBlock = msgEl.querySelector('span.blockCode');
- const contentFragment = document.createDocumentFragment();
- const children = Array.from(msgEl.childNodes);
- children.forEach(node => {
- if (node === codeBlock || node.contains?.(codeBlock) || codeBlock?.contains(node)) return;
- contentFragment.appendChild(node);
- });
- const grid = renderGrid(extracted.data, { postEl, crossThreadLink, contentFragment });
- msgEl.innerHTML = '';
- msgEl.appendChild(grid);
- state.rendered.add(postEl);
- }
- function restorePost(postEl) {
- const msgEl = postEl.querySelector(MSG_SELECTOR);
- if (!msgEl) return;
- const moved = state.movedAttachments.get(postEl);
- if (moved) {
- moved.forEach(({ cell, originalParent }) => {
- if (originalParent && originalParent.isConnected) {
- originalParent.appendChild(cell);
- }
- });
- state.movedAttachments.delete(postEl);
- }
- const orig = state.originals.get(postEl);
- if (orig !== undefined) {
- msgEl.innerHTML = orig;
- }
- postEl.querySelectorAll('.opUploadPanel[data-gr-hidden="1"]').forEach(p => {
- p.style.display = '';
- delete p.dataset.grHidden;
- });
- state.rendered.delete(postEl);
- }
- function scan() {
- const ops = document.querySelectorAll(OP_SELECTOR);
- ops.forEach(op => {
- if (state.enabled) {
- if (!state.rendered.has(op)) processPost(op);
- } else {
- if (state.rendered.has(op) || state.originals.has(op)) restorePost(op);
- }
- });
- }
- function getGridCSS() {
- return `
- :root {
- --grid-radius: 0px;
- --gr-text-outline: 1px 1px 2px black, -1px -1px 2px black, 1px -1px 2px black, -1px 1px 2px black;
- }
- .gr-root {
- display: grid;
- grid-template-columns: repeat(${GRID_COLS}, 1fr);
- grid-auto-rows: ${ROW_HEIGHT_PX}px;
- gap: 4px;
- width: 100%;
- box-sizing: border-box;
- container-type: inline-size;
- isolation: isolate;
- }
- .gr-root div > span.embedButton { display: none; } /* hide [Embed]s */
- @supports (container-type: inline-size) {
- .gr-root {
- grid-auto-rows: calc(100cqw * ${ROW_TO_COL_RATIO} / ${GRID_COLS});
- }
- }
- .gr-link-group,
- .gr-countdown-group { display: contents; }
- .gr-widget {
- background: var(--contrast-color, #eef2ff);
- border-radius: var(--grid-radius);
- padding: 6px;
- overflow: hidden;
- box-sizing: border-box;
- position: relative;
- z-index: 1;
- display: flex;
- flex-direction: column;
- }
- .gr-widget.gr-no-bg {
- background: transparent;
- padding: 0;
- }
- .gr-widget-title {
- font-weight: bold;
- color: var(--subject-color, #0f0c5d);
- font-size: clamp(12px, 0.8vw, 36px);
- margin-bottom: 4px;
- padding-bottom: 3px;
- border-bottom: 1px solid var(--horizon-sep-color, #b7c5d9);
- flex-shrink: 0;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .gr-widget-body { flex: 1; overflow: auto; min-height: 0; }
- .gr-generic { position: relative; }
- .gr-generic .gr-widget-body { position: relative; overflow: hidden; }
- .gr-generic-text-inner {
- position: relative;
- white-space: pre-wrap;
- word-wrap: break-word;
- max-height: 100%;
- overflow: auto;
- }
- .gr-generic-bg {
- position: absolute;
- inset: 0;
- z-index: 0;
- background-repeat: no-repeat;
- pointer-events: none;
- }
- .gr-bg-cover { background-size: cover; background-position: center; }
- .gr-bg-contain { background-size: contain; background-position: center; }
- .gr-bg-corner-tl,
- .gr-bg-corner-tr,
- .gr-bg-corner-bl,
- .gr-bg-corner-br { background-size: contain; }
- .gr-bg-corner-tl { background-position: left top; }
- .gr-bg-corner-tr { background-position: right top; }
- .gr-bg-corner-bl { background-position: left bottom; }
- .gr-bg-corner-br { background-position: right bottom; }
- .gr-generic-bg.gr-bg-corner-tl, .gr-generic-bg.gr-bg-corner-tr,
- .gr-generic-bg.gr-bg-corner-bl, .gr-generic-bg.gr-bg-corner-br {
- width: 50%;
- height: 80%;
- inset: auto;
- }
- .gr-generic-bg.gr-bg-corner-tl { top: 0; left: 0; }
- .gr-generic-bg.gr-bg-corner-tr { top: 0; right: 0; }
- .gr-generic-bg.gr-bg-corner-bl { bottom: 0; left: 0; }
- .gr-generic-bg.gr-bg-corner-br { bottom: 0; right: 0; }
- .gr-bg-tile { background-repeat: repeat; background-size: auto; }
- .gr-countdown { position: relative; }
- .gr-countdown-body {
- position: relative;
- overflow: hidden;
- display: flex;
- align-items: center;
- justify-content: center;
- flex: 1;
- min-height: 0;
- }
- .gr-countdown-timer {
- position: relative;
- font-weight: bold;
- font-variant-numeric: tabular-nums;
- font-size: clamp(14pt, 2.1vw, 40pt);
- color: var(--text-color, black);
- text-align: center;
- line-height: 1.1;
- white-space: nowrap;
- }
- .gr-countdown.has-bg .gr-countdown-timer {
- color: white;
- text-shadow: var(--gr-text-outline);
- }
- .gr-countdown-timer.gr-countdown-ended {
- font-size: clamp(12pt, 2vw, 24pt);
- font-style: italic;
- }
- .gr-countdown-label {
- padding: 0;
- }
- .gr-countdown-label .gr-countdown-body {
- flex-direction: column;
- gap: 0;
- }
- .gr-countdown-label-title {
- position: relative;
- font-weight: bold;
- font-size: clamp(10px, 1.2vw, 16px);
- color: var(--text-color, black);
- text-align: center;
- white-space: nowrap;
- line-height: 1.3;
- }
- .gr-countdown-label .gr-countdown-timer {
- font-size: clamp(10pt, 1.6vw, 24pt);
- }
- .gr-countdown-label.has-bg .gr-countdown-label-title {
- color: white;
- text-shadow: var(--gr-text-outline);
- }
- .gr-link {
- padding: 0;
- text-decoration: none;
- color: var(--link-color, #34345C);
- cursor: pointer;
- }
- .gr-link:hover { border-color: var(--border-highlight-color, #d00); }
- .gr-link-inner {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- text-align: center;
- padding: 6px;
- background-repeat: no-repeat;
- color: inherit;
- font-weight: bold;
- word-break: break-word;
- font-size: clamp(18px, 1.3vw, 54px);
- }
- .gr-link-inner.has-bg {
- color: white;
- text-shadow: var(--gr-text-outline);
- }
- .gr-list-strip {
- display: flex;
- align-items: stretch;
- gap: 4px;
- overflow-x: auto;
- overflow-y: hidden;
- height: 100%;
- scrollbar-width: none;
- }
- .gr-list-strip::-webkit-scrollbar { display: none; }
- .gr-list-vstrip {
- display: flex;
- flex-direction: column;
- gap: 4px;
- overflow-x: hidden;
- overflow-y: auto;
- height: 100%;
- scrollbar-width: none;
- }
- .gr-list-vstrip::-webkit-scrollbar { display: none; }
- .gr-list-vstrip .gr-list-card {
- width: auto;
- flex: 0 0 40%;
- min-height: 80px;
- }
- .gr-list-accordion {
- display: flex;
- flex-direction: column;
- gap: 2px;
- overflow-x: hidden;
- overflow-y: auto;
- height: 100%;
- scrollbar-width: none;
- }
- .gr-list-accordion::-webkit-scrollbar { display: none; }
- .gr-list-acc-item {
- flex: 0 0 auto;
- border-radius: var(--grid-radius);
- background: var(--menu-color, #eef2ff);
- overflow: hidden;
- }
- .gr-list-acc-summary {
- cursor: pointer;
- padding: 6px 8px 6px 6px;
- font-weight: bold;
- font-size: clamp(12px, 0.7vw, 36px);
- color: var(--text-color, #000);
- user-select: none;
- position: relative;
- }
- .gr-list-acc-summary::-webkit-details-marker { display: none; } /* Safari */
- /*.gr-list-acc-summary::before {
- content: 'โธ';
- position: absolute;
- left: 8px;
- top: 50%;
- transform: translateY(-50%);
- font-size: 10pt;
- opacity: 0.7;
- }*/
- .gr-list-acc-item[open] > .gr-list-acc-summary::before {
- transform: translateY(-50%) rotate(90deg);
- }
- .gr-list-acc-content {
- padding: 6px 10px 10px;
- border-top: 1px solid var(--horizon-sep-color, #b7c5d9);
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
- .gr-list-acc-img {
- max-width: 100%;
- max-height: 200px;
- width: auto;
- height: auto;
- border-radius: var(--grid-radius);
- align-self: flex-start;
- }
- .gr-list-acc-text {
- font-size: 10pt;
- color: var(--text-color, #000);
- white-space: pre-wrap;
- word-wrap: break-word;
- }
- .gr-list-acc-link {
- display: inline-block;
- align-self: flex-start;
- padding: 4px 10px;
- background: var(--menu-color, #eef2ff);
- border: 1px solid var(--border-color, black);
- border-radius: var(--grid-radius);
- color: var(--link-color, #34345C);
- text-decoration: none;
- font-size: 9pt;
- max-width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .gr-list-acc-link:hover {
- border-color: var(--border-highlight-color, #d00);
- }
- .gr-list-carousel {
- position: relative;
- height: 100%;
- outline: none;
- display: flex;
- flex-direction: column;
- }
- .gr-list-car-empty {
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--text-color, black);
- opacity: 0.5;
- font-style: italic;
- }
- .gr-list-car-btn {
- position: absolute;
- top: 50%;
- transform: translateY(-50%);
- width: 32px;
- height: 48px;
- border: none;
- border-radius: var(--grid-radius);
- background: rgba(0, 0, 0, 0.45);
- color: white;
- font-size: 18px;
- line-height: 1;
- padding: 0;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.15s ease;
- }
- .gr-widget:hover .gr-list-car-btn {
- opacity: 0.85;
- pointer-events: auto;
- }
- .gr-list-car-btn:hover {
- background: rgba(0, 0, 0, 0.65);
- opacity: 1 !important;
- }
- .gr-list-car-prev { left: 6px; }
- .gr-list-car-next { right: 6px; }
- .gr-list-car-slot {
- flex: 1;
- min-height: 0;
- display: flex;
- align-items: stretch;
- }
- .gr-list-carousel .gr-list-card {
- width: 100%;
- min-height: 0;
- }
- .gr-list-car-counter {
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
- bottom: 36px;
- font-size: 9pt;
- padding: 2px 8px;
- background: rgba(0, 0, 0, 0.6);
- color: white;
- border-radius: var(--grid-radius);
- pointer-events: none;
- opacity: 0;
- transition: opacity 0.15s ease;
- }
- .gr-widget:hover .gr-list-car-counter {
- opacity: 1;
- }
- @media (hover: none) {
- .gr-list-car-btn {
- opacity: 0.85;
- pointer-events: auto;
- }
- .gr-list-car-counter { opacity: 1; }
- }
- .gr-list-card {
- flex: 0 0 auto;
- width: 35%;
- border-radius: var(--grid-radius);
- background-size: cover;
- background-position: center;
- background-color: var(--contrast-color, #d6daf0);
- position: relative;
- overflow: hidden;
- text-decoration: none;
- color: inherit;
- display: block;
- min-height: 100px;
- }
- .gr-list-card img,
- .gr-list-card-title,
- .gr-list-lang-badge,
- .gr-list-acc-img {
- pointer-events: none;
- }
- .gr-list-card-title {
- position: absolute;
- bottom: 0; left: 0; right: 0;
- background: rgba(0,0,0,0.7);
- color: white;
- font-size: 9pt;
- padding: 4px 6px;
- text-align: center;
- }
- .gr-list-card:not(.has-bg) .gr-list-card-title {
- position: static;
- background: transparent;
- color: var(--text-color, black);
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .gr-list-yt-play {
- position: absolute;
- top: 50%; left: 50%;
- transform: translate(-50%, -50%);
- width: 48px; height: 34px;
- opacity: 0.85;
- pointer-events: none;
- transition: opacity 0.1s;
- }
- .gr-list-yt-play svg { width: 100%; height: 100%; display: block; }
- .gr-list-card:hover .gr-list-yt-play { opacity: 1; }
- .gr-list-cycle-btn {
- position: absolute;
- top: 2px;
- right: 4px;
- width: 20px;
- height: 20px;
- border: none;
- color: var(--text-color, #000);
- font-size: 14px;
- line-height: 1;
- cursor: pointer;
- align-items: center;
- justify-content: center;
- margin: 0;
- }
- .gr-widget-list .gr-widget-title { padding-right: 28px; }
- .gr-list-lang-btn {
- position: absolute;
- top: 2px;
- right: 26px;
- border: none;
- background: none;
- color: var(--text-color, #000);
- font-size: 10px;
- cursor: pointer;
- padding: 0 4px;
- opacity: 0.6;
- letter-spacing: 0.5px;
- }
- .gr-list-lang-btn:hover { opacity: 1; }
- .gr-list-lang-badge {
- position: absolute;
- top: 3px;
- left: 3px;
- font-size: 8px;
- font-weight: 700;
- letter-spacing: 0.5px;
- background: rgba(0,0,0,0.55);
- color: #fff;
- padding: 1px 4px;
- border-radius: 2px;
- line-height: 1.3;
- pointer-events: none;
- z-index: 1;
- }
- .gr-list-acc-link .gr-list-lang-badge {
- position: static;
- display: inline-block;
- vertical-align: middle;
- margin-right: 2px;
- }
- .gr-list-placeholder-notice {
- font-size: 9pt;
- color: var(--text-color, black);
- opacity: 0.6;
- font-style: italic;
- padding: 4px 6px;
- text-align: center;
- }
- .gr-att-body summary,
- .gr-att-body .imgExpanded,
- .gr-att-body video,
- .gr-att-body .hideLink { display: none !important; }
- .gr-att-body details { font-size: 0; }
- .gr-att-body details > * { font-size: 10pt; }
- .gr-att-body .gr-list-car-slot {
- height: 100%;
- min-width: 0;
- overflow: hidden;
- }
- .gr-att-body .gr-list-car-slot .uploadCell {
- margin: 0;
- width: 100%;
- height: 100%;
- min-width: 0;
- min-height: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- }
- .gr-att-body .gr-list-car-slot .uploadCell details {
- width: 100%;
- height: 100%;
- min-width: 0;
- min-height: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- }
- .gr-att-body .gr-list-car-slot .uploadCell a.imgLink {
- display: flex !important;
- align-items: center;
- justify-content: center;
- width: 100%;
- height: 100%;
- min-width: 0;
- min-height: 0;
- overflow: hidden;
- cursor: zoom-in;
- }
- .gr-att-body .gr-list-car-slot a.imgLink img {
- width: 100% !important;
- height: 100% !important;
- max-width: 100% !important;
- max-height: 100% !important;
- object-fit: contain;
- display: block !important;
- box-sizing: border-box;
- margin: 0 !important;
- }
- .gr-att-tile {
- flex: 0 0 auto;
- width: 180px;
- border: 1px solid var(--border-color, black);
- border-radius: var(--grid-radius);
- background: var(--contrast-color, #d6daf0);
- overflow: hidden;
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 100px;
- }
- .gr-att-body .gr-list-car-slot .gr-att-tile {
- width: 100%;
- height: 100%;
- }
- .gr-att-empty {
- font-size: 10pt;
- color: var(--text-color, black);
- opacity: 0.5;
- font-style: italic;
- padding: 16px;
- text-align: center;
- width: 100%;
- }
- .gr-att-placeholder {
- background: repeating-linear-gradient(
- 45deg,
- var(--contrast-color, #d6daf0),
- var(--contrast-color, #d6daf0) 10px,
- var(--menu-color, #eef2ff) 10px,
- var(--menu-color, #eef2ff) 20px
- );
- color: var(--text-color, black);
- opacity: 0.6;
- font-size: 10pt;
- font-style: italic;
- }
- .gr-thread-content {
- overflow-y: auto;
- overflow-x: hidden;
- scrollbar-width: none;
- word-wrap: break-word;
- padding: 4px;
- box-sizing: border-box;
- position: relative;
- z-index: 0;
- }
- .gr-thread-content::-webkit-scrollbar { display: none; }
- .gr-content-placeholder {
- color: var(--text-color, black);
- opacity: 0.5;
- font-style: italic;
- padding: 16px;
- text-align: center;
- }
- .gr-lightbox-backdrop {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.85);
- z-index: 10000;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 48px 24px 24px;
- box-sizing: border-box;
- cursor: zoom-out;
- }
- .gr-lightbox-frame {
- position: relative;
- display: inline-block;
- cursor: default;
- max-width: calc(100vw - 48px);
- max-height: calc(100vh - 72px);
- }
- .gr-lightbox-media {
- display: block;
- max-width: calc(100vw - 48px);
- max-height: calc(100vh - 72px);
- width: auto;
- height: auto;
- object-fit: contain;
- }
- .gr-lightbox-iframe {
- aspect-ratio: 16 / 9;
- width: min(calc(100vw - 48px), calc((100vh - 72px) * 16 / 9));
- height: auto;
- max-width: calc(100vw - 48px);
- max-height: calc(100vh - 72px);
- border: 0;
- }
- .gr-lightbox-close {
- position: absolute;
- top: -40px;
- right: 0px;
- width: 36px;
- height: 36px;
- border: none;
- background: rgba(255, 255, 255, 0.15);
- color: white;
- font-size: 18px;
- line-height: 1;
- padding: 0;
- margin: 0;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .gr-lightbox-close:hover {
- background: rgba(255, 255, 255, 0.3);
- }
- .gr-timeline-canvas-wrap {
- position: relative;
- width: 100%;
- height: 100%;
- overflow: hidden;
- overscroll-behavior: contain;
- }
- .gr-timeline-canvas {
- display: block;
- cursor: grab;
- touch-action: pan-y pinch-zoom;
- }
- .gr-timeline-canvas.dragging { cursor: grabbing; }
- .gr-popup {
- position: absolute;
- background: var(--menu-color, #eef2ff);
- border: 1px solid var(--border-color, black);
- border-radius: var(--grid-radius);
- padding: 8px;
- max-width: 320px;
- min-width: 200px;
- z-index: 9999;
- box-shadow: 2px 2px 4px rgba(0,0,0,0.3);
- }
- .gr-popup-close {
- position: absolute;
- top: 2px; right: 4px;
- cursor: pointer;
- color: var(--link-color, #34345C);
- font-weight: bold;
- }
- .gr-popup-title {
- font-weight: bold;
- color: var(--subject-color, #0f0c5d);
- margin-bottom: 4px;
- padding-right: 16px;
- }
- .gr-popup-time {
- font-size: 9pt;
- color: var(--text-color, black);
- margin-bottom: 2px;
- }
- .gr-popup-time-local {
- color: var(--text-color, black);
- opacity: 0.7;
- font-style: italic;
- margin-bottom: 6px;
- }
- .gr-popup-countdown {
- font-size: 9pt;
- font-weight: bold;
- margin-bottom: 6px;
- }
- .gr-popup-countdown.normal { color: var(--text-color, black); }
- .gr-popup-countdown.future { color: var(--text-color, black); font-weight: normal; font-style: italic; }
- .gr-popup-countdown.urgent { color: var(--alert-color, red); }
- .gr-popup-countdown.ended { color: var(--text-color, #666); font-weight: normal; opacity: 0.7; }
- .gr-popup-img {
- max-width: 100%;
- max-height: 160px;
- display: block;
- margin-bottom: 4px;
- }
- .gr-popup-text { font-size: 9pt; white-space: pre-wrap; }
- .gr-error {
- padding: 8px;
- background: var(--marked-color, #d6bad0);
- border: 1px solid var(--marked-border-color, #ba9dbf);
- }
- .gr-wrap { width: 100%; }
- .gr-statusbar {
- display: flex; align-items: center; gap: 8px;
- padding: 4px 6px; margin-bottom: 4px;
- font-size: 10pt; float: right;
- }
- .gr-statusbar-label { font-weight: bold; color: var(--subject-color, #0f0c5d); }
- .gr-statusbar-select {
- font: inherit; padding: 2px 4px;
- border: 1px solid var(--border-color, black);
- background: var(--contrast-color); color: var(--text-color, black);
- }
- .gr-statusbar-server-group {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- margin-left: auto;
- }
- .gr-statusbar-xthread {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- }
- .gr-statusbar-xthread::before {
- content: 'Previous:';
- font-weight: bold;
- }
- .gr-non-interactive, .gr-non-interactive * { pointer-events: none !important; }
- .gr-non-interactive .gr-timeline-canvas { cursor: default !important; }
- /* Layout editor */
- .gr-layout-edit-btn {
- background: none;
- border: none;
- color: var(--text-color, #000);
- cursor: pointer;
- font-size: 14px;
- padding: 0 4px;
- opacity: 0.4;
- line-height: 1;
- }
- .gr-layout-edit-btn:hover { opacity: 1; }
- @media (max-width: 812px) {
- .gr-layout-edit-btn { display: none; }
- }
- .gr-edit-toggle.coloredIcon::before { content: '\\e0A3'; }
- .gr-edit-toggle.coloredIcon.is-active::before { content: '\\e033'; }
- .gr-exit-edit-btn.coloredIcon::before { content: '\\e0DB'; }
- .gr-layout-reset-btn.coloredIcon::before { content: '\\e08C'; }
- .gr-json-btn.coloredIcon::before { content: '\\e040'; }
- .gr-edit-mode [data-widget-id] {
- cursor: grab;
- outline: 2px dashed rgba(128,128,128,0.4);
- outline-offset: -2px;
- user-select: none;
- }
- .gr-edit-mode [data-widget-id]:hover {
- outline-color: var(--ps-accent, #e94560);
- }
- .gr-edit-mode [data-widget-id] *:not(.gr-hide-btn):not(.gr-resize-handle):not(.gr-restore-btn) {
- pointer-events: none !important;
- }
- .gr-edit-mode .gr-resize-handle {
- pointer-events: auto !important;
- }
- .gr-edit-mode .gr-dragging,
- .gr-edit-mode .gr-resizing {
- opacity: 0.7;
- z-index: 100;
- }
- .gr-edit-mode .gr-dragging { cursor: grabbing; }
- .gr-resize-handle {
- position: absolute;
- bottom: 0;
- right: 0;
- width: 14px;
- height: 14px;
- cursor: nwse-resize;
- z-index: 10;
- background: linear-gradient(135deg, transparent 50%, var(--ps-accent, #e94560) 50%);
- opacity: 0.6;
- border-radius: 0 0 var(--grid-radius, 0) 0;
- }
- .gr-resize-handle:hover { opacity: 1; }
- .gr-hide-btn {
- position: absolute;
- top: 4px;
- right: 4px;
- width: 18px;
- height: 18px;
- border: none;
- background: rgba(0,0,0,0.4);
- color: #fff;
- font-size: 12px;
- line-height: 18px;
- text-align: center;
- cursor: pointer;
- border-radius: 50%;
- z-index: 11;
- padding: 0;
- pointer-events: auto !important;
- opacity: 0;
- transition: opacity 0.15s;
- }
- .gr-edit-mode [data-widget-id]:hover .gr-hide-btn { opacity: 1; }
- .gr-hide-btn:hover { background: var(--e-danger, #d33); }
- .gr-widget-hidden {
- background: repeating-linear-gradient(
- 45deg, transparent, transparent 8px,
- rgba(128,128,128,0.08) 8px, rgba(128,128,128,0.08) 16px
- );
- border: 2px dashed rgba(128,128,128,0.3);
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- opacity: 0.5;
- min-height: 40px;
- }
- .gr-hidden-label {
- font-size: 11px;
- color: var(--text-color, #666);
- pointer-events: none;
- }
- .gr-restore-btn {
- font-size: 11px;
- border: 1px solid rgba(128,128,128,0.3);
- background: var(--contrast-color, #eef2ff);
- color: var(--text-color, #333);
- padding: 2px 8px;
- border-radius: 3px;
- cursor: pointer;
- pointer-events: auto !important;
- }
- .gr-restore-btn:hover { border-color: var(--ps-accent, #e94560); }
- .gr-add-widget-btn {
- grid-column: 1 / -1;
- border: 2px dashed rgba(128,128,128,0.3);
- background: none;
- color: var(--text-color, #666);
- font-size: 16px;
- cursor: pointer;
- border-radius: var(--grid-radius, 0);
- opacity: 0.5;
- font-weight: bold;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- }
- .gr-add-widget-btn:hover {
- opacity: 1;
- border-color: var(--ps-accent, #e94560);
- }
- .gr-json-panel {
- background: var(--menu-color, #eef2ff);
- border: 1px solid var(--border-color, #ccc);
- border-top: none;
- padding: 6px;
- box-sizing: border-box;
- }
- .gr-json-textarea {
- width: 100%;
- min-height: 120px;
- max-height: 300px;
- font-family: "SF Mono", "Fira Code", monospace;
- font-size: 11px;
- background: var(--contrast-color, #fff);
- color: var(--text-color, #000);
- border: 1px solid var(--border-color, #ccc);
- padding: 6px;
- box-sizing: border-box;
- resize: vertical;
- tab-size: 2;
- }
- .gr-json-apply {
- font-size: 11px;
- padding: 3px 10px;
- border: 1px solid var(--border-color, #ccc);
- background: var(--contrast-color, #fff);
- color: var(--text-color, #000);
- cursor: pointer;
- border-radius: 2px;
- }
- .gr-json-apply:hover {
- border-color: var(--ps-accent, #e94560);
- }
- .gr-widget-restored {
- border: 2px dashed rgba(100,200,100,0.5);
- background: rgba(100,200,100,0.05);
- display: flex;
- align-items: center;
- justify-content: center;
- opacity: 0.6;
- }
- @media (max-width: ${MOBILE_BREAKPOINT}px) {
- .gr-root {
- display: flex;
- flex-direction: column;
- grid-auto-rows: auto;
- }
- .gr-widget {
- grid-column: unset !important;
- grid-row: unset !important;
- min-height: 120px;
- height: auto;
- }
- .gr-widget-attachments { order: -1; }
- .gr-thread-content {
- grid-column: unset !important;
- grid-row: unset !important;
- order: -2;
- }
- .gr-statusbar {
- flex-direction: column;
- align-items: stretch;
- gap: 4px;
- }
- .gr-statusbar-server-group {
- margin-left: 0;
- }
- .gr-widget-timeline .gr-widget-body { min-height: 200px; }
- .gr-list-carousel { min-height: 200px; }
- .gr-link-group {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 4px;
- }
- .gr-link-group .gr-widget-link {
- min-height: 60px !important;
- padding: 4px;
- }
- .gr-countdown-group {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 4px;
- }
- .gr-countdown-group .gr-widget-countdown {
- min-height: 100px !important;
- }
- }
- `;
- }
- function onStart(api) {
- api.addStyle(getGridCSS());
- const processLinkForEmbedOrig = embed.processLinkForEmbed;
- // patch to prevent native script from rendering [Embed] on youtube links inside OP
- embed.processLinkForEmbed = function (anchor) {
- if (anchor.classList.contains('gr-list-card'))
- return;
- processLinkForEmbedOrig(anchor);
- }
- state.enabled = true;
- scan();
- }
- function onStop(api) {
- state.enabled = false;
- scan();
- }
- function onSettingChange() { }
- return {
- id: 'interactive-op',
- name: 'Interactive OP',
- description: 'Makes thread OP interactive',
- settings: {},
- onStart,
- onStop,
- onSettingChange,
- renderGrid,
- getGridCSS,
- GRID_COLS,
- ROW_HEIGHT_PX,
- ROW_TO_COL_RATIO,
- }
- }
- window.InteractiveOP = InteractiveOP();
- document.head.appendChild(Object.assign(document.createElement('style'), { textContent: window.InteractiveOP.getGridCSS() }));
- // ---------------------------------------------------------------------------
- // ===== SERVERS ============================================================
- // Common IANA timezones relevant to gacha game regions. "Custom" allows free entry.
- const TZ_OPTIONS = [
- { v: 'UTC', label: 'UTC' },
- { v: 'America/Los_Angeles', label: 'America/Los_Angeles (NA West / PST/PDT)' },
- { v: 'America/New_York', label: 'America/New_York (NA East / EST/EDT)' },
- { v: 'Europe/London', label: 'Europe/London (GMT/BST)' },
- { v: 'Europe/Berlin', label: 'Europe/Berlin (CET/CEST)' },
- { v: 'Asia/Tokyo', label: 'Asia/Tokyo (JST +9)' },
- { v: 'Asia/Shanghai', label: 'Asia/Shanghai (CST +8)' },
- { v: 'Asia/Seoul', label: 'Asia/Seoul (KST +9)' },
- { v: 'Asia/Taipei', label: 'Asia/Taipei (+8)' },
- { v: 'Asia/Singapore', label: 'Asia/Singapore (+8)' },
- { v: 'Australia/Sydney', label: 'Australia/Sydney (AEST/AEDT)' },
- ];
- // All IANA zones the browser knows. Falls back to TZ_OPTIONS values if the
- // browser is too old to expose supportedValuesOf. Newer browsers return ~400
- // entries covering every region; older ones get the short curated list.
- const ALL_TZ_ZONES = (function() {
- try {
- if (typeof Intl.supportedValuesOf === 'function') {
- return Intl.supportedValuesOf('timeZone');
- }
- } catch (e) { /* fall through */ }
- return TZ_OPTIONS.map(o => o.v);
- })();
- // Compute current offset (in minutes, positive = ahead of UTC) for a given
- // IANA zone at a given moment. Uses the DateTimeFormat trick: format the same
- // instant in both the target zone and UTC, subtract.
- function offsetMinutesAt(tz, whenMs) {
- try {
- const d = new Date(whenMs);
- const fmt = new Intl.DateTimeFormat('en-US', {
- timeZone: tz, hour12: false,
- year: 'numeric', month: '2-digit', day: '2-digit',
- hour: '2-digit', minute: '2-digit', second: '2-digit',
- });
- const parts = fmt.formatToParts(d).reduce((a, p) => {
- if (p.type !== 'literal') a[p.type] = p.value;
- return a;
- }, {});
- const asUtcMs = Date.UTC(
- Number(parts.year), Number(parts.month) - 1, Number(parts.day),
- Number(parts.hour) % 24, Number(parts.minute), Number(parts.second)
- );
- return Math.round((asUtcMs - whenMs) / 60000);
- } catch (e) { return 0; }
- }
- // Format a signed offset in minutes as "UTC+2", "UTC-5:30", "UTC".
- function formatOffset(mins) {
- if (mins === 0) return 'UTC';
- const sign = mins > 0 ? '+' : '-';
- const abs = Math.abs(mins);
- const h = Math.floor(abs / 60);
- const m = abs % 60;
- return m === 0 ? `UTC${sign}${h}` : `UTC${sign}${h}:${String(m).padStart(2, '0')}`;
- }
- // Precompute offset metadata for all zones once per session. Cache keeps the
- // picker snappy โ 400 zones ร 2 offset calls = ~800 Intl calls, done once.
- const TZ_METADATA = (function() {
- const now = Date.now();
- const jan = Date.UTC(new Date().getUTCFullYear(), 0, 15);
- return ALL_TZ_ZONES.map(tz => {
- const nowOff = offsetMinutesAt(tz, now);
- const janOff = offsetMinutesAt(tz, jan);
- return {
- tz,
- offsetMins: nowOff,
- offsetLabel: formatOffset(nowOff),
- hasDst: nowOff !== janOff,
- // Lowercase searchable string: "europe/paris utc+2"
- search: (tz + ' ' + formatOffset(nowOff)).toLowerCase(),
- };
- }).sort((a, b) => a.offsetMins - b.offsetMins || a.tz.localeCompare(b.tz));
- })();
- function isValidTz(tz) {
- if (!tz) return false;
- try { new Intl.DateTimeFormat('en', { timeZone: tz }); return true; }
- catch (e) { return false; }
- }
- // Searchable timezone picker. Returns a wrapper element containing a text
- // input (for filter/manual entry) plus a dropdown list that appears when
- // focused. Each row shows "Zone/Name ยท UTC+N (DST)" so the author immediately
- // sees which offset a zone corresponds to without leaving the editor.
- function createTzPicker(initial, onChange) {
- const wrap = document.createElement('div');
- wrap.className = 'tz-picker';
- const input = document.createElement('input');
- input.type = 'text';
- input.className = 'tz-picker-input';
- input.placeholder = 'Search zones (e.g. Tokyo, Paris, UTC+9)';
- input.value = initial || 'UTC';
- input.autocomplete = 'off';
- wrap.appendChild(input);
- // Small label showing the current offset for the typed value. Updates as
- // user types; clearly shows when their input doesn't match a valid zone.
- const badge = document.createElement('span');
- badge.className = 'tz-picker-badge';
- wrap.appendChild(badge);
- const list = document.createElement('div');
- list.className = 'tz-picker-list';
- list.setAttribute('role', 'listbox');
- wrap.appendChild(list);
- // Current committed value โ changes only when user picks from list or
- // typed value is a real IANA zone when focus leaves.
- let currentValue = initial || 'UTC';
- function updateBadge(value) {
- if (!value) { badge.textContent = ''; return; }
- const meta = TZ_METADATA.find(m => m.tz === value);
- if (meta) {
- badge.textContent = meta.offsetLabel + (meta.hasDst ? ' ยท DST' : '');
- badge.classList.remove('tz-picker-badge-invalid');
- } else if (isValidTz(value)) {
- // Valid IANA but not in metadata (rare โ supportedValuesOf missing it)
- const off = offsetMinutesAt(value, Date.now());
- badge.textContent = formatOffset(off);
- badge.classList.remove('tz-picker-badge-invalid');
- } else {
- badge.textContent = 'invalid';
- badge.classList.add('tz-picker-badge-invalid');
- }
- }
- updateBadge(initial);
- function renderList(query) {
- list.innerHTML = '';
- const q = query.toLowerCase().trim();
- // Filter: if query is empty, show all; otherwise match substring in `search`
- const matches = q
- ? TZ_METADATA.filter(m => m.search.indexOf(q) !== -1)
- : TZ_METADATA;
- // Cap to avoid huge DOM โ first 200 matches is plenty for search
- const shown = matches.slice(0, 200);
- shown.forEach(m => {
- const row = document.createElement('div');
- row.className = 'tz-picker-row';
- if (m.tz === currentValue) row.classList.add('tz-picker-row-selected');
- row.setAttribute('role', 'option');
- row.dataset.tz = m.tz;
- const name = document.createElement('span');
- name.className = 'tz-picker-row-name';
- name.textContent = m.tz;
- row.appendChild(name);
- const off = document.createElement('span');
- off.className = 'tz-picker-row-off';
- off.textContent = m.offsetLabel + (m.hasDst ? ' ยท DST' : '');
- row.appendChild(off);
- row.addEventListener('mousedown', (e) => {
- // mousedown instead of click so it fires before blur hides the list
- e.preventDefault();
- commit(m.tz);
- list.classList.remove('tz-picker-list-open');
- });
- list.appendChild(row);
- });
- if (matches.length > shown.length) {
- const more = document.createElement('div');
- more.className = 'tz-picker-more';
- more.textContent = `+${matches.length - shown.length} more โ refine search`;
- list.appendChild(more);
- }
- if (matches.length === 0) {
- const none = document.createElement('div');
- none.className = 'tz-picker-more';
- none.textContent = 'No matches. Type any valid IANA zone to use it.';
- list.appendChild(none);
- }
- }
- function commit(newTz) {
- currentValue = newTz;
- input.value = newTz;
- updateBadge(newTz);
- onChange(newTz);
- }
- input.addEventListener('focus', () => {
- renderList(input.value);
- list.classList.add('tz-picker-list-open');
- });
- input.addEventListener('input', () => {
- updateBadge(input.value);
- renderList(input.value);
- list.classList.add('tz-picker-list-open');
- });
- input.addEventListener('blur', () => {
- // On blur, commit the typed value if it's a valid IANA zone; otherwise
- // revert the input text to the last committed value. Use a short delay
- // so a mousedown on a list row can fire first.
- setTimeout(() => {
- list.classList.remove('tz-picker-list-open');
- const typed = input.value.trim();
- if (typed && isValidTz(typed)) {
- if (typed !== currentValue) commit(typed);
- } else {
- input.value = currentValue;
- updateBadge(currentValue);
- }
- }, 150);
- });
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- input.value = currentValue;
- updateBadge(currentValue);
- list.classList.remove('tz-picker-list-open');
- input.blur();
- } else if (e.key === 'Enter') {
- // If there's exactly one match, commit it. Otherwise just accept
- // whatever's typed (blur handler will validate).
- const q = input.value.toLowerCase().trim();
- const matches = q ? TZ_METADATA.filter(m => m.search.indexOf(q) !== -1) : [];
- if (matches.length >= 1) {
- e.preventDefault();
- commit(matches[0].tz);
- list.classList.remove('tz-picker-list-open');
- }
- }
- });
- return wrap;
- }
- function updateServerButton() {
- const btn = document.getElementById('btn-servers');
- const n = (state.meta.servers || []).length;
- btn.textContent = n > 0 ? `Servers (${n})` : 'Servers';
- btn.classList.toggle('has-config', n > 0);
- }
- // Working copy of servers used while editing the dialog; applied on Save.
- let serversDraft = null;
- let serversDraftDefault = null;
- function openServersDialog() {
- // Clone current state so Cancel truly cancels
- serversDraft = (state.meta.servers || []).map(s => ({ ...s }));
- serversDraftDefault = state.meta.defaultServerId;
- renderServerList();
- document.getElementById('dlg-servers').showModal();
- }
- function renderServerList() {
- const list = document.getElementById('sv-list');
- list.innerHTML = '';
- if (serversDraft.length === 0) {
- const empty = document.createElement('div');
- empty.style.cssText = 'color:var(--e-muted);font-size:12px;padding:8px;text-align:center;';
- empty.textContent = 'No servers configured. Times will display in UTC.';
- list.appendChild(empty);
- return;
- }
- serversDraft.forEach((srv, idx) => {
- const row = document.createElement('div');
- row.className = 'sv-row';
- const defLabel = document.createElement('label');
- defLabel.className = 'sv-default';
- const defRadio = document.createElement('input');
- defRadio.type = 'radio';
- defRadio.name = 'sv-default-radio';
- defRadio.checked = srv.id === serversDraftDefault;
- defRadio.addEventListener('change', () => { serversDraftDefault = srv.id; });
- defLabel.appendChild(defRadio);
- defLabel.appendChild(document.createTextNode('default'));
- const nameEl = document.createElement('input');
- nameEl.type = 'text';
- nameEl.placeholder = 'Name (e.g. JP Server)';
- nameEl.value = srv.name || '';
- nameEl.addEventListener('input', () => { srv.name = nameEl.value; });
- const tzEl = createTzPicker(srv.tz || 'UTC', (newTz) => {
- srv.tz = newTz;
- });
- const del = document.createElement('button');
- del.className = 'mini-btn danger';
- del.textContent = 'ร';
- del.title = 'Remove';
- del.addEventListener('click', () => {
- serversDraft.splice(idx, 1);
- if (serversDraftDefault === srv.id) {
- serversDraftDefault = serversDraft[0] ? serversDraft[0].id : null;
- }
- renderServerList();
- });
- row.appendChild(defLabel);
- row.appendChild(nameEl);
- row.appendChild(tzEl);
- row.appendChild(del);
- list.appendChild(row);
- });
- }
- document.getElementById('btn-servers').addEventListener('click', openServersDialog);
- document.getElementById('sv-add').addEventListener('click', () => {
- const newSrv = { id: uid('s'), name: 'New Server', tz: 'UTC' };
- serversDraft.push(newSrv);
- if (!serversDraftDefault) serversDraftDefault = newSrv.id;
- renderServerList();
- });
- document.getElementById('sv-save').addEventListener('click', () => {
- // Sanity: ensure defaultServerId still refers to an existing server
- if (serversDraftDefault && !serversDraft.find(s => s.id === serversDraftDefault)) {
- serversDraftDefault = serversDraft[0] ? serversDraft[0].id : null;
- }
- state.meta.servers = serversDraft;
- state.meta.defaultServerId = serversDraftDefault;
- document.getElementById('dlg-servers').close();
- updateServerButton();
- // Timeline event list previews show TZ-dependent dates, so rerender props panel if open
- renderPropsPanel();
- // Rebuild widget content previews to reflect new TZ in date formatting
- state.widgets.forEach(w => refreshWidgetContent(w));
- onStateChanged();
- toast('Servers saved');
- });
- // Export JSON (just the schema, not the wrapped OP)
- document.getElementById('btn-export').addEventListener('click', () => {
- const payload = buildPayloadWithResolvedLanes();
- const text = JSON.stringify(payload, null, 2);
- const blob = new Blob([text], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = 'op-grid.json';
- a.click();
- setTimeout(() => URL.revokeObjectURL(url), 1000);
- });
- // Import
- document.getElementById('btn-import').addEventListener('click', () => {
- document.getElementById('import-text').value = '';
- document.getElementById('import-error').textContent = '';
- document.getElementById('dlg-import').showModal();
- });
- document.getElementById('import-confirm').addEventListener('click', () => {
- const text = document.getElementById('import-text').value;
- const errEl = document.getElementById('import-error');
- const data = tryExtractImport(text);
- if (!data) {
- errEl.textContent = 'Could not find valid grid data. Paste either a full OP (with [code]@@grid-v1@@ ...[/code]) or just the JSON.';
- return;
- }
- applyImportedData(data);
- document.getElementById('dlg-import').close();
- toast('Imported');
- });
- function tryExtractImport(text) {
- // 1) Full OP with [code] block
- const re = /\[code\]([\s\S]*?)\[\/code\]/gi;
- let m, last = null;
- while ((m = re.exec(text)) !== null) {
- if (m[1].includes(MAGIC)) last = m;
- }
- if (last) {
- const inner = last[1].trim();
- const idx = inner.indexOf(MAGIC);
- const json = inner.slice(idx + MAGIC.length).trim();
- try { const d = JSON.parse(json); if (d && Array.isArray(d.widgets)) return d; } catch {}
- }
- // 2) Maybe they pasted just the marker + JSON
- const idx2 = text.indexOf(MAGIC);
- if (idx2 !== -1) {
- try { const d = JSON.parse(text.slice(idx2 + MAGIC.length).trim()); if (d && Array.isArray(d.widgets)) return d; } catch {}
- }
- // 3) Raw JSON
- try {
- const d = JSON.parse(text.trim());
- if (d && Array.isArray(d.widgets)) return d;
- } catch {}
- return null;
- }
- function applyImportedData(data) {
- state.meta = data.meta || { title: '' };
- if (!Array.isArray(state.meta.servers)) state.meta.servers = [];
- if (!('defaultServerId' in state.meta)) state.meta.defaultServerId = null;
- // Drop defaultServerId if it references a nonexistent server
- if (state.meta.defaultServerId && !state.meta.servers.find(s => s.id === state.meta.defaultServerId)) {
- state.meta.defaultServerId = state.meta.servers[0] ? state.meta.servers[0].id : null;
- }
- state.widgets = (data.widgets || []).map(w => ({ ...w, id: w.id || uid('w') }));
- state.selectedId = null;
- updateServerButton();
- reloadGrid();
- renderPropsPanel();
- onStateChanged();
- }
- // ===== BOTTOM PANEL TOGGLE ================================================
- document.getElementById('bottom-header').addEventListener('click', () => {
- document.getElementById('bottom').classList.toggle('collapsed');
- });
- // ===== INIT ===============================================================
- let threadFetched = false;
- function init() {
- initGrid();
- const saved = load();
- if (saved) {
- applyImportedData(saved);
- } else {
- updateServerButton();
- updateOutput();
- // Auto-fetch current thread's template on first open
- if (!threadFetched && typeof threadId !== 'undefined' && threadId) {
- threadFetched = true;
- fetchThreadTemplate(threadId);
- }
- }
- }
- function fetchThreadTemplate(tid) {
- const boardPath = window.location.pathname.split('/res/')[0];
- fetch(`${boardPath}/res/${tid}.json`)
- .then(r => {
- if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
- return r.json();
- })
- .then(json => {
- const message = json.message;
- if (!message) return;
- const data = tryExtractImport(message);
- if (data) {
- applyImportedData(data);
- toast('Loaded thread template');
- }
- })
- .catch(e => {
- console.warn('[grid-editor] Could not fetch thread template:', e.message);
- });
- }
- init();
- })();
- }
- })();
Add Comment
Please, Sign In to add comment