/*!
* 曼谷追星共筆行事曆 — 內嵌小工具版 (widget.js)
* 用一個 <script> 標籤就能嵌入頁面,內部使用 Shadow DOM 隔離樣式,
* 不需要 iframe,也不會影響或被主網站的 CSS 干擾。
*
* 使用方式(貼在 narak.club 頁面你想顯示行事曆的地方):
* <script src="https://narak.club/bkk-calendar-widget.js"></script>
*/
(function(){
"use strict";
var CSS_TEXT = "\n :host{\n --bg:#12132a;\n --panel:#1c1e42;\n --card:#22244a;\n --card-border:rgba(231,178,75,.28);\n --gold:#e7b24b;\n --gold-soft:#f4d793;\n --gold-text:#1b1400;\n --magenta:#ff4f81;\n --teal:#57d6a0;\n --text:#f3f1e7;\n --text-muted:#9295be;\n --danger:#ff6b6b;\n --sky:#6fa8ff;\n --radial-op-1:.10;\n --radial-op-2:.12;\n --radius:16px;\n --font-display:'Space Grotesk','Noto Sans TC',sans-serif;\n --font-body:'Noto Sans TC',sans-serif;\n --font-mono:'IBM Plex Mono',monospace;\n }\n :host([data-theme=\"light\"]){\n --bg:#ffffff;\n --panel:#f7f5ef;\n --card:#ffffff;\n --card-border:rgba(201,143,46,.35);\n --gold:#c98f2e;\n --gold-soft:#e7b24b;\n --gold-text:#ffffff;\n --magenta:#e0407a;\n --teal:#1f9f74;\n --text:#201f2e;\n --text-muted:#65688a;\n --danger:#d84343;\n --sky:#3a6fd8;\n --radial-op-1:.05;\n --radial-op-2:.06;\n }\n *{box-sizing:border-box;}\n \n .app{\n max-width:1040px;\n margin:0 auto;\n padding:20px 16px 90px;\n background:\n radial-gradient(circle at 12% 0%, rgba(255,79,129,var(--radial-op-1)), transparent 40%),\n radial-gradient(circle at 90% 10%, rgba(231,178,75,var(--radial-op-2)), transparent 45%),\n var(--bg);\n min-height:100vh;\n transition:background-color .2s ease;\n }\n\n /* ---------- Header ---------- */\n .hero{\n position:relative;\n border-radius:20px;\n padding:24px 22px;\n background:var(--panel);\n border:1px solid var(--card-border);\n overflow:hidden;\n margin-bottom:16px;\n }\n .hero::after{\n content:\"\";\n position:absolute; right:-40px; top:-40px;\n width:160px;height:160px;border-radius:50%;\n background:radial-gradient(circle, rgba(231,178,75,.30), transparent 70%);\n }\n .hero-top{display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;}\n .hero-eyebrow{\n font-family:var(--font-mono);\n font-size:12px;\n letter-spacing:.14em;\n color:var(--gold);\n text-transform:uppercase;\n }\n .hero h1{\n font-family:var(--font-display);\n font-weight:700;\n font-size:clamp(22px,4vw,32px);\n margin:6px 0 6px;\n color:var(--text);\n }\n .hero p{\n margin:0;\n color:var(--text-muted);\n font-size:14px;\n line-height:1.6;\n max-width:560px;\n }\n .hero-note{\n margin-top:12px;\n display:inline-flex;\n align-items:center;\n gap:8px;\n font-size:12px;\n color:var(--teal);\n background:rgba(87,214,160,.10);\n border:1px solid rgba(87,214,160,.3);\n padding:6px 10px;\n border-radius:999px;\n }\n .theme-toggle{\n flex:0 0 auto;\n font-family:var(--font-mono);\n font-size:12px;\n background:var(--card);\n color:var(--text);\n border:1px solid var(--card-border);\n border-radius:999px;\n padding:8px 12px;\n cursor:pointer;\n white-space:nowrap;\n }\n .theme-toggle:hover{border-color:var(--gold);}\n\n /* ---------- Tabs ---------- */\n .tabbar{\n display:flex;\n gap:6px;\n overflow-x:auto;\n padding:6px 2px 12px;\n margin-bottom:14px;\n border-bottom:1px dashed var(--card-border);\n }\n .tabbar::-webkit-scrollbar{height:5px;}\n .tabbar::-webkit-scrollbar-thumb{background:var(--card-border);border-radius:10px;}\n .tab-btn{\n flex:0 0 auto;\n font-family:var(--font-mono);\n font-size:13px;\n color:var(--text-muted);\n background:var(--panel);\n border:1px solid transparent;\n border-radius:10px;\n padding:8px 14px;\n cursor:pointer;\n white-space:nowrap;\n transition:all .15s ease;\n }\n .tab-btn:hover{color:var(--text); border-color:var(--card-border);}\n .tab-btn.active{\n background:linear-gradient(135deg,var(--gold),var(--gold-soft));\n color:var(--gold-text);\n font-weight:700;\n border-color:transparent;\n }\n .tab-badge{\n display:inline-block;\n margin-left:6px;\n font-size:10px;\n background:var(--magenta);\n color:#fff;\n border-radius:999px;\n padding:1px 6px;\n font-family:var(--font-mono);\n }\n .tab-btn.active .tab-badge{background:rgba(0,0,0,.35);color:#fff;}\n\n /* ---------- Section titles ---------- */\n .section{margin-bottom:26px;}\n .section-head{\n display:flex;\n align-items:baseline;\n justify-content:space-between;\n gap:10px;\n margin-bottom:12px;\n flex-wrap:wrap;\n }\n .section-title{\n font-family:var(--font-display);\n font-size:18px;\n font-weight:700;\n display:flex;\n align-items:center;\n gap:8px;\n }\n .section-sub{font-size:12px;color:var(--text-muted);}\n\n /* ---------- Filters ---------- */\n .filters{\n display:flex;\n gap:8px;\n flex-wrap:wrap;\n margin-bottom:14px;\n }\n .filters select, .filters input[type=text]{\n background:var(--card);\n border:1px solid var(--card-border);\n color:var(--text);\n font-family:var(--font-body);\n font-size:13px;\n padding:8px 10px;\n border-radius:9px;\n outline:none;\n }\n .filters select:focus, .filters input:focus{border-color:var(--gold);}\n .filters input[type=text]{min-width:180px;flex:1;}\n\n /* ---------- Calendar grid ---------- */\n .cal-wrap{\n background:var(--panel);\n border:1px solid var(--card-border);\n border-radius:var(--radius);\n padding:16px;\n margin-bottom:20px;\n }\n .cal-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;}\n .cal-title{font-family:var(--font-display);font-weight:700;font-size:15px;color:var(--gold);}\n .cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;}\n .cal-dow{text-align:center;font-size:11px;color:var(--text-muted);font-family:var(--font-mono);padding-bottom:4px;}\n .cal-day{\n aspect-ratio:1/1;\n border-radius:8px;\n background:rgba(120,120,140,.06);\n display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;\n font-size:12px;color:var(--text-muted);cursor:default;position:relative;min-height:36px;\n }\n .cal-day.empty{background:transparent;}\n .cal-day.has-event{\n background:rgba(231,178,75,.12);\n color:var(--text);\n cursor:pointer;\n border:1px solid rgba(231,178,75,.35);\n }\n .cal-day.has-event:hover{background:rgba(231,178,75,.24);}\n .cal-day.today{box-shadow:0 0 0 2px var(--magenta) inset;}\n .cal-day.selected{background:var(--gold);color:var(--gold-text);font-weight:700;}\n .cal-dot{width:5px;height:5px;border-radius:50%;background:var(--magenta);}\n .cal-dots{display:flex;gap:2px;align-items:center;justify-content:center;}\n .cal-legend{display:flex;flex-wrap:wrap;gap:8px 14px;margin-bottom:10px;}\n .cal-legend-item{font-size:11px;color:var(--text-muted);display:inline-flex;align-items:center;gap:4px;}\n .cal-legend-dot{width:8px;height:8px;border-radius:50%;display:inline-block;}\n .cal-clear{font-size:11px;color:var(--magenta);background:none;border:none;cursor:pointer;font-family:var(--font-mono);text-decoration:underline;align-self:center;}\n\n /* ---------- Ticket cards ---------- */\n .ticket-list{display:flex;flex-direction:column;gap:12px;}\n .ticket{\n display:flex;\n background:var(--card);\n border-radius:var(--radius);\n overflow:hidden;\n border:1px solid var(--card-border);\n box-shadow:0 6px 18px rgba(0,0,0,.12);\n }\n .ticket-stub{\n flex:0 0 50px;\n background:linear-gradient(180deg,var(--gold),var(--gold-soft));\n display:flex;align-items:center;justify-content:center;position:relative;\n }\n .ticket-stub .v{writing-mode:vertical-rl;font-family:var(--font-mono);font-weight:600;letter-spacing:.08em;color:var(--gold-text);font-size:13px;}\n .ticket-stub::after{\n content:\"\";position:absolute;right:-7px;top:0;bottom:0;width:14px;\n background-image:radial-gradient(circle,var(--bg) 5px, transparent 5.5px);\n background-size:14px 16px;background-repeat:repeat-y;background-position:center;\n }\n .ticket-main{flex:1;padding:14px 16px 14px 20px;min-width:0;}\n .ticket-row-top{display:flex;align-items:center;gap:10px;margin-bottom:6px;flex-wrap:wrap;}\n .ticket-date{font-family:var(--font-mono);color:var(--gold);font-size:13px;font-weight:600;}\n .ticket-time{font-family:var(--font-mono);color:var(--text-muted);font-size:12px;}\n .tbd-chip{font-family:var(--font-mono);font-size:10px;color:var(--magenta);border:1px solid var(--magenta);padding:1px 6px;border-radius:999px;}\n .ticket-name{font-family:var(--font-display);font-size:16px;font-weight:700;margin:0 0 6px;color:var(--text);line-height:1.4;}\n .ticket-meta{font-size:13px;color:var(--text-muted);margin:2px 0;line-height:1.5;}\n .ticket-meta b{color:var(--text);font-weight:500;}\n .ticket-meta a{color:var(--teal);}\n .type-badge{font-family:var(--font-mono);font-size:11px;padding:2px 9px;border-radius:999px;font-weight:600;}\n .method-chip{display:inline-block;font-family:var(--font-mono);font-size:10px;color:var(--text-muted);border:1px solid var(--card-border);padding:1px 7px;border-radius:999px;margin-right:5px;}\n .ticket-actions{display:flex;align-items:center;gap:10px;margin-top:10px;flex-wrap:wrap;}\n .btn-join{\n font-family:var(--font-mono);font-size:12px;background:rgba(87,214,160,.12);color:var(--teal);\n border:1px solid rgba(87,214,160,.4);border-radius:999px;padding:6px 12px;cursor:pointer;transition:all .15s;\n }\n .btn-join.joined{background:var(--teal);color:#08251a;font-weight:700;}\n .btn-join:hover{filter:brightness(1.08);}\n .btn-del{font-family:var(--font-mono);font-size:11px;background:none;color:var(--text-muted);border:1px solid var(--card-border);border-radius:999px;padding:6px 10px;cursor:pointer;}\n .btn-del:hover{color:var(--danger);border-color:var(--danger);}\n .btn-del.confirming{color:#fff;background:var(--danger);border-color:var(--danger);}\n\n .empty-state{text-align:center;padding:36px 16px;color:var(--text-muted);border:1px dashed var(--card-border);border-radius:var(--radius);font-size:13px;}\n\n /* ---------- Floating add button ---------- */\n .fab{\n position:fixed;right:20px;bottom:20px;z-index:50;\n font-family:var(--font-display);font-weight:700;font-size:14px;color:var(--gold-text);\n background:linear-gradient(135deg,var(--gold-soft),var(--gold));\n border:none;border-radius:999px;padding:14px 20px;\n box-shadow:0 10px 24px rgba(231,178,75,.35);cursor:pointer;\n }\n .fab:hover{filter:brightness(1.05);}\n\n /* ---------- Modal ---------- */\n .modal-overlay{position:fixed;inset:0;background:rgba(8,9,20,.68);display:none;align-items:center;justify-content:center;z-index:100;padding:16px;}\n .modal-overlay.open{display:flex;}\n .modal{background:var(--panel);border:1px solid var(--card-border);border-radius:18px;padding:22px;width:100%;max-width:440px;max-height:90vh;overflow-y:auto;}\n .modal h2{font-family:var(--font-display);font-size:18px;margin:0 0 4px;}\n .modal .sub{font-size:12px;color:var(--text-muted);margin-bottom:16px;}\n .field{margin-bottom:12px;}\n .field label{display:block;font-size:12px;color:var(--gold);font-family:var(--font-mono);margin-bottom:5px;}\n .field input[type=text],.field input[type=date],.field input[type=time],.field input[type=url],.field select,.field textarea{\n width:100%;background:var(--card);border:1px solid var(--card-border);color:var(--text);\n font-family:var(--font-body);font-size:14px;padding:9px 10px;border-radius:9px;outline:none;\n }\n .field textarea{resize:vertical;min-height:70px;font-family:var(--font-body);}\n .field input:focus,.field select:focus,.field textarea:focus{border-color:var(--gold);}\n .field.checkbox{display:flex;align-items:center;gap:8px;}\n .field.checkbox label{margin:0;}\n .field-hint{font-size:11px;color:var(--text-muted);margin-top:4px;}\n .modal-actions{display:flex;gap:10px;margin-top:18px;}\n .btn-primary{flex:1;font-family:var(--font-display);font-weight:700;background:linear-gradient(135deg,var(--gold-soft),var(--gold));color:var(--gold-text);border:none;border-radius:10px;padding:11px;cursor:pointer;font-size:14px;}\n .btn-secondary{font-family:var(--font-body);background:none;color:var(--text-muted);border:1px solid var(--card-border);border-radius:10px;padding:11px 16px;cursor:pointer;font-size:13px;}\n .form-error{color:var(--danger);font-size:12px;margin-top:-4px;margin-bottom:10px;display:none;}\n .template-select{margin-bottom:16px;padding-bottom:14px;border-bottom:1px dashed var(--card-border);}\n .format-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:5px;}\n .format-btn{\n font-family:var(--font-mono);font-size:11px;background:none;color:var(--gold);\n border:1px solid var(--gold);border-radius:999px;padding:3px 10px;cursor:pointer;\n }\n .format-btn:hover{background:rgba(231,178,75,.1);}\n .format-preview{\n background:var(--card);border:1px dashed var(--card-border);border-radius:9px;\n padding:8px 10px;font-family:var(--font-mono);font-size:11px;color:var(--text-muted);\n white-space:pre-wrap;line-height:1.6;margin-bottom:10px;display:none;\n }\n .format-preview.show{display:block;}\n\n .toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--card);border:1px solid var(--gold);color:var(--text);padding:10px 18px;border-radius:999px;font-size:13px;z-index:200;opacity:0;pointer-events:none;transition:opacity .25s;}\n .toast.show{opacity:1;}\n\n .export-btn{font-family:var(--font-mono);font-size:12px;background:none;color:var(--gold);border:1px solid var(--gold);border-radius:999px;padding:6px 12px;cursor:pointer;}\n .export-btn:hover{background:rgba(231,178,75,.1);}\n\n .tbd-list{display:flex;flex-direction:column;gap:10px;}\n .tbd-item{background:var(--panel);border:1px dashed var(--card-border);border-radius:12px;padding:12px 14px;font-size:13px;color:var(--text-muted);}\n .tbd-item b{color:var(--text);}\n\n /* ---------- Board ---------- */\n .board-list{display:flex;flex-direction:column;gap:12px;}\n .board-disclaimer{\n background:rgba(255,79,129,.08);\n border:1px solid rgba(255,79,129,.35);\n border-radius:12px;\n padding:12px 14px;\n font-size:12.5px;\n color:var(--text-muted);\n line-height:1.7;\n margin-bottom:16px;\n }\n .board-disclaimer b{color:var(--magenta);}\n .board-card{background:var(--card);border:1px solid var(--card-border);border-radius:14px;padding:14px 16px;}\n .board-top{display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap;}\n .board-badge{font-family:var(--font-mono);font-size:10px;padding:2px 8px;border-radius:999px;}\n .board-badge.info{background:rgba(87,214,160,.14);color:var(--teal);border:1px solid rgba(87,214,160,.4);}\n .board-badge.trade{background:rgba(255,79,129,.14);color:var(--magenta);border:1px solid rgba(255,79,129,.4);}\n .board-badge.sell{background:rgba(231,178,75,.14);color:var(--gold);border:1px solid rgba(231,178,75,.4);}\n .board-badge.group{background:rgba(111,168,255,.14);color:var(--sky);border:1px solid rgba(111,168,255,.4);}\n .board-time{font-family:var(--font-mono);font-size:11px;color:var(--text-muted);}\n .board-title{font-family:var(--font-display);font-weight:700;font-size:15px;margin:0 0 6px;}\n .board-spec{\n display:grid;\n grid-template-columns:auto 1fr;\n gap:5px 12px;\n font-size:12.5px;\n background:rgba(120,120,140,.06);\n border:1px solid var(--card-border);\n border-radius:10px;\n padding:10px 12px;\n margin:0 0 10px;\n }\n .board-spec dt{font-family:var(--font-mono);color:var(--gold);white-space:nowrap;}\n .board-spec dd{margin:0;color:var(--text);}\n .board-content{font-size:13px;color:var(--text-muted);white-space:pre-wrap;line-height:1.6;margin:0 0 8px;}\n .board-contact{font-size:12px;color:var(--gold);}\n\n .venue-list-note{font-size:12px;color:var(--text-muted);margin:-4px 0 14px;}\n\n @media (max-width:520px){\n .ticket-stub{flex-basis:36px;}\n .ticket-stub .v{font-size:11px;}\n .ticket-main{padding:12px 12px 12px 16px;}\n }\n";
var BODY_HTML = "\n<div class=\"app\" id=\"app\">\n\n <div class=\"hero\">\n <div class=\"hero-top\">\n <div>\n <div class=\"hero-eyebrow\">BANGKOK IDOL EVENTS \u00b7 \u5171\u7b46\u4f01\u5283</div>\n <h1>\u66fc\u8c37\u8ffd\u661f\u5171\u7b46\u884c\u4e8b\u66c6 2026</h1>\n </div>\n <button class=\"theme-toggle\" id=\"themeToggle\">\ud83c\udf19 \u6df1\u8272 / \u2600\ufe0f \u6dfa\u8272</button>\n </div>\n <p>\u5927\u5bb6\u4e00\u8d77\u7dad\u8b77\u7684\u66fc\u8c37\u7dda\u4e0b\u6d3b\u52d5\u6642\u9593\u8868\u3002\u65b0\u589e\u4f60\u77e5\u9053\u7684\u5834\u6b21\uff0c\u7be9\u9078\u5834\u9928\uff0c\u6309\u4e0b\u300c\u6211\u8981\u53c3\u52a0\u300d\u63ea\u5718\u4e00\u8d77\u53bb\uff1b\u516c\u4f48\u6b04\u5247\u53ef\u4ee5\u8cbc\u6436\u7968\u60c5\u5831\u6216\u63db\u7968\u9700\u6c42\u3002</p>\n <div class=\"hero-note\">\ud83c\udf10 \u6240\u6709\u6d3b\u52d5\u8207\u516c\u4f48\u6b04\u5167\u5bb9\u70ba\u5171\u7528\u8cc7\u6599\uff0c\u6240\u6709\u4f7f\u7528\u8005\u90fd\u770b\u5f97\u5230</div>\n </div>\n\n <div class=\"tabbar\" id=\"tabbar\"></div>\n\n <div id=\"content\"></div>\n</div>\n\n<button class=\"fab\" id=\"fabAdd\">\uff0b \u65b0\u589e\u6d3b\u52d5</button>\n\n<!-- Event Modal -->\n<div class=\"modal-overlay\" id=\"modalOverlay\">\n <div class=\"modal\">\n <h2>\u65b0\u589e\u4e00\u7b46\u6d3b\u52d5</h2>\n <div class=\"sub\">\u9047\u5230\u97f3\u6a02\u7bc0\u6216\u5df2\u6709\u5834\u6b21\u7684\u5de1\u6f14\uff0c\u53ef\u4ee5\u5148\u9078\u7bc4\u672c\u5feb\u901f\u5e36\u5165\uff0c\u53ea\u8981\u88dc\u4e0a\u85dd\u4eba\u5373\u53ef\u3002</div>\n\n <div class=\"template-select\">\n <label style=\"display:block;font-size:12px;color:var(--gold);font-family:var(--font-mono);margin-bottom:5px;\">\u5f9e\u65e2\u6709\u6d3b\u52d5\u5feb\u901f\u5e36\u5165\uff08\u9078\u586b\uff09</label>\n <select id=\"f-template\">\n <option value=\"\">\u2014 \u4e0d\u4f7f\u7528\u7bc4\u672c\uff0c\u624b\u52d5\u586b\u5beb \u2014</option>\n </select>\n </div>\n\n <form id=\"eventForm\">\n <div class=\"field\">\n <label for=\"f-type\">\u6d3b\u52d5\u985e\u578b</label>\n <select id=\"f-type\"></select>\n </div>\n <div class=\"field\" id=\"dateField\">\n <label for=\"f-date\">\u65e5\u671f</label>\n <input type=\"date\" id=\"f-date\" min=\"2026-07-01\" max=\"2026-12-31\" required>\n </div>\n <div class=\"field\">\n <label for=\"f-time\">\u6642\u9593\uff08\u9078\u586b\uff09</label>\n <input type=\"time\" id=\"f-time\">\n </div>\n <div class=\"field\">\n <label for=\"f-venue-preset\">\u5834\u9928\u5feb\u9078\uff08\u9078\u586b\uff09</label>\n <select id=\"f-venue-preset\">\n <option value=\"\">\u2014 \u81ea\u884c\u8f38\u5165 / \u4e0d\u5728\u6e05\u55ae\u4e2d \u2014</option>\n </select>\n </div>\n <div class=\"field\">\n <label for=\"f-location\">\u5730\u9ede / \u5834\u9928</label>\n <input type=\"text\" id=\"f-location\" placeholder=\"\u4f8b\uff1aQSNCC \u8a69\u9e97\u5409\u7687\u540e\u570b\u5bb6\u6703\u8b70\u4e2d\u5fc3\">\n </div>\n <div class=\"field\">\n <label for=\"f-name\">\u6d3b\u52d5\u540d\u7a31</label>\n <input type=\"text\" id=\"f-name\" placeholder=\"\u4f8b\uff1aXXX ASIA TOUR in BANGKOK\">\n </div>\n <div class=\"field\">\n <label for=\"f-artist\">\u85dd\u4eba / \u5718\u9ad4</label>\n <input type=\"text\" id=\"f-artist\" placeholder=\"\u4f8b\uff1aXXX\">\n </div>\n <div class=\"field\">\n <label for=\"f-presale\">\u6436\u7968\u6642\u9593\uff08\u9078\u586b\uff09</label>\n <input type=\"text\" id=\"f-presale\" placeholder=\"\u4f8b\uff1a\u6703\u54e1\u9810\u552e 7/1 12:00\u3001\u4e00\u822c\u958b\u8ce3 7/3 10:00\">\n </div>\n <div class=\"field\">\n <label for=\"f-salelink\">\u8cfc\u7968\u9023\u7d50\uff08\u9078\u586b\uff09</label>\n <input type=\"url\" id=\"f-salelink\" placeholder=\"https://...\">\n </div>\n <div class=\"field\">\n <label>\u8cfc\u7968\u65b9\u5f0f\uff08\u9078\u586b\uff0c\u53ef\u8907\u9078\uff09</label>\n <div id=\"ticketMethodChecks\" style=\"display:flex;flex-direction:column;gap:6px;\"></div>\n </div>\n <div class=\"field\">\n <label for=\"f-notes\">\u5099\u8a3b\u8aaa\u660e\uff08\u9078\u586b\uff09</label>\n <textarea id=\"f-notes\" placeholder=\"\u4f8b\uff1a\u5165\u5834\u9700\u51fa\u793a\u8b77\u7167\u3001\u9650\u91cf\u5468\u908a\u8d08\u9001\u3001\u5ea7\u4f4d\u70ba\u7ad9\u5e2d\u7b49\"></textarea>\n </div>\n <div class=\"field\">\n <label for=\"f-creator\">\u586b\u5beb\u4eba\u66b1\u7a31\uff08\u9078\u586b\uff09</label>\n <input type=\"text\" id=\"f-creator\" placeholder=\"\u6703\u986f\u793a\u5728\u9019\u7b46\u6d3b\u52d5\u65c1\u908a\uff0c\u4f8b\uff1a\u5c0f\u7f8e\">\n </div>\n <div class=\"form-error\" id=\"formError\">\u8acb\u586b\u5beb\u5fc5\u8981\u6b04\u4f4d\u5594\uff01</div>\n <div class=\"modal-actions\">\n <button type=\"button\" class=\"btn-secondary\" id=\"cancelBtn\">\u53d6\u6d88</button>\n <button type=\"submit\" class=\"btn-primary\">\u9001\u51fa \u2713</button>\n </div>\n </form>\n </div>\n</div>\n\n<!-- Board Modal -->\n<div class=\"modal-overlay\" id=\"boardModalOverlay\">\n <div class=\"modal\">\n <h2>\u767c\u4f48\u516c\u544a / \u63db\u7968</h2>\n <div class=\"sub\">\u50c5\u9650\u7968\u5238\u4ea4\u63db\u8cc7\u8a0a\u3001\u96c6\u8cc7\u6216\u63ea\u5718\uff0c\u6b64\u70ba\u533f\u540d\u516c\u4f48\u6b04\u50c5\u4f9b\u8cc7\u8a0a\u5a92\u5408\u3002<b>\u4e0d\u63d0\u4f9b\u4ee3\u8cfc\u670d\u52d9</b>\uff08\u542b\u9031\u908a\u3001\u5c08\u8f2f\u7b49\u5546\u54c1\u4ee3\u8cfc\u8acb\u53e6\u5c0b\u7ba1\u9053\uff09\uff0c\u4ea4\u6613\u8acb\u81ea\u884c\u6838\u5be6\u5c0d\u65b9\u8eab\u4efd\u8207\u8cc7\u8a0a\uff0c\u98a8\u96aa\u81ea\u8ca0\u3002</div>\n <form id=\"boardForm\">\n <div class=\"field\">\n <label for=\"b-category\">\u5206\u985e</label>\n <select id=\"b-category\">\n <option value=\"info\">\ud83d\udccc \u8cc7\u8a0a\u516c\u544a</option>\n <option value=\"trade\">\ud83d\udd04 \u63db\u7968</option>\n <option value=\"sell\">\ud83d\udcb0 \u552e\u7968</option>\n <option value=\"group\">\ud83c\udf89 \u63ea\u5718</option>\n </select>\n <div class=\"field-hint\">\u9078\u597d\u5206\u985e\u5f8c\uff0c\u4e0b\u65b9\u6703\u51fa\u73fe\u5c0d\u61c9\u7684\u5b8c\u6574\u683c\u5f0f\u6b04\u4f4d\uff0c\u7167\u8457\u586b\u5c31\u597d\u3002</div>\n </div>\n\n <div id=\"relatedEventField\" style=\"display:none;\" class=\"field\">\n <label for=\"b-event\">\u76f8\u95dc\u6d3b\u52d5\uff08\u53ef\u76f4\u63a5\u9078\u65e2\u6709\u6d3b\u52d5\uff09</label>\n <input type=\"text\" id=\"b-event\" list=\"b-event-list\" placeholder=\"\u4f8b\uff1aBTS WORLD TOUR IN BANGKOK 2026 \u00b7 Day 2\">\n <datalist id=\"b-event-list\"></datalist>\n </div>\n\n <div id=\"ticketFields\" style=\"display:none;\">\n <div class=\"field\">\n <label for=\"b-tradetype\" id=\"b-tradetype-label\">\u4ea4\u6613\u985e\u578b</label>\n <select id=\"b-tradetype\"></select>\n </div>\n <div class=\"field\">\n <label for=\"b-zone\">\u7968\u7a2e / \u5ea7\u4f4d\u5340</label>\n <input type=\"text\" id=\"b-zone\" placeholder=\"\u4f8b\uff1a\u6416\u6efe\u5340 / B\u5340 / VIP\">\n </div>\n <div class=\"field\">\n <label for=\"b-qty\">\u6578\u91cf</label>\n <input type=\"text\" id=\"b-qty\" placeholder=\"\u4f8b\uff1a2 \u5f35\">\n </div>\n <div class=\"field\">\n <label for=\"b-price\" id=\"b-price-label\">\u50f9\u683c\uff08\u9078\u586b\uff09</label>\n <input type=\"text\" id=\"b-price\" placeholder=\"\u4f8b\uff1a\u4ee5\u539f\u50f9\u8f49\u8b93 / THB 3,500 \u4e00\u5f35\">\n </div>\n </div>\n\n <div id=\"groupFields\" style=\"display:none;\">\n <div class=\"field\">\n <label for=\"b-groupsize\">\u9700\u6c42\u4eba\u6578</label>\n <input type=\"text\" id=\"b-groupsize\" placeholder=\"\u4f8b\uff1a\u9084\u7f3a 2 \u4eba / 4\u4eba\u5c0f\u968a\">\n </div>\n <div class=\"field\">\n <label for=\"b-meetup\">\u96c6\u5408\u6642\u9593 / \u5730\u9ede\uff08\u9078\u586b\uff09</label>\n <input type=\"text\" id=\"b-meetup\" placeholder=\"\u4f8b\uff1a\u7576\u592917:00 \u5834\u9928\u5916\u96c6\u5408\">\n </div>\n </div>\n\n <div class=\"field\">\n <label for=\"b-title\">\u6a19\u984c</label>\n <input type=\"text\" id=\"b-title\" placeholder=\"\u4f8b\uff1aBTS \u66fc\u8c37\u5834 12/4 \u5169\u5f35\u5168\u5340\u5e2d\u4f4d\u6c42\u63db\">\n </div>\n <div class=\"field\">\n <label for=\"b-content\">\u88dc\u5145\u8aaa\u660e</label>\n <textarea id=\"b-content\" placeholder=\"\u5176\u4ed6\u7d30\u7bc0\uff1a\u4ea4\u7968\u65b9\u5f0f\u3001\u6642\u9593\u5730\u9ede\u3001\u6ce8\u610f\u4e8b\u9805\u7b49\"></textarea>\n </div>\n <div class=\"field\">\n <label for=\"b-contact\">\u806f\u7d61\u65b9\u5f0f\uff08\u9078\u586b\uff09</label>\n <input type=\"text\" id=\"b-contact\" placeholder=\"\u4f8b\uff1aIG @xxx / LINE ID\uff1axxx\">\n </div>\n <div class=\"field\">\n <label for=\"b-creator\">\u586b\u5beb\u4eba\u66b1\u7a31\uff08\u9078\u586b\uff09</label>\n <input type=\"text\" id=\"b-creator\" placeholder=\"\u6703\u986f\u793a\u5728\u9019\u5247\u516c\u544a\u65c1\u908a\uff0c\u4f8b\uff1a\u5c0f\u7f8e\">\n </div>\n <div class=\"form-error\" id=\"boardFormError\">\u8acb\u81f3\u5c11\u586b\u5beb\u6a19\u984c\u5594\uff01</div>\n <div class=\"modal-actions\">\n <button type=\"button\" class=\"btn-secondary\" id=\"boardCancelBtn\">\u53d6\u6d88</button>\n <button type=\"submit\" class=\"btn-primary\">\u767c\u4f48 \u2713</button>\n </div>\n </form>\n </div>\n</div>\n\n<div class=\"toast\" id=\"toast\"></div>\n<input type=\"text\" id=\"f-hp\" name=\"website\" autocomplete=\"off\" tabindex=\"-1\" style=\"position:absolute;left:-9999px;width:1px;height:1px;opacity:0;\" aria-hidden=\"true\">\n\n";
function ensureFonts(){
if(document.getElementById("bkk-calendar-fonts")) return;
var pre = document.createElement("link");
pre.rel = "preconnect";
pre.href = "https://fonts.googleapis.com";
var link = document.createElement("link");
link.id = "bkk-calendar-fonts";
link.rel = "stylesheet";
link.href = "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Noto+Sans+TC:wght@400;500;700;900&family=IBM+Plex+Mono:wght@400;500&display=swap";
document.head.appendChild(pre);
document.head.appendChild(link);
}
function runApp(root, hostEl){
"use strict";
var MONTH_NAMES = ["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"];
var DOW = ["日","一","二","三","四","五","六"];
var YEAR = 2026;
var STORAGE_KEY = "bkk-idol-calendar-events-v5";
var BOARD_KEY = "bkk-idol-calendar-board-v1";
var JOINED_KEY = "bkk-idol-calendar-joined-v1";
var THEME_KEY = "bkk-idol-calendar-theme-v1";
var CLIENT_ID_KEY = "bkk-idol-calendar-clientid-v1";
var CREATOR_KEY = "bkk-idol-calendar-creator-v1";
// 部署好 Google Apps Script Web App 之後,把網址貼在這裡(.../exec 結尾)。
// 留空時,整個工具仍會用瀏覽器內建的共用儲存空間正常運作;
// 一旦填上網址,新增/刪除/參加動作會「同時」寫入你的 Google Sheet 備份一份。
var SCRIPT_URL = "https://script.google.com/macros/s/AKfycbyp-8k-l6eQoGatboUfnktCqAWIIKV_F9cQRrzvC6lYsqysCf7O3dVBt5zgIKrckHJATw/exec";
var VENUE_GROUPS = [
{
label: "大型活動場地",
venues: [
"centralwOrld LIVE","Thunder Dome","MCC Hall, The Mall Bangkapi","Union Hall","UOB Live",
"MCC Hall, The Mall Bangkhae","MCC Hall, The Mall Ngamwongwan","Paragon Hall","BITEC LIVE",
"Impact Arena, Muang Thong Thani","Rajamangala National Stadium",
"QSNCC 詩麗吉皇后國家會議中心","Major Cineplex Ratchayothin"
]
},
{
label: "商場 / 廣場活動",
venues: [
"Siam Paragon(暹羅百麗宮)","Siam Center","Siam Square廣場","Siam Discovery","Emsphere","Lido Connect",
"Samyan Mitrtown","Central World前廣場","Central World 1樓","EmQuartier","Central Park"
]
}
];
var VENUES = VENUE_GROUPS.reduce(function(acc,g){ return acc.concat(g.venues); }, []);
var EVENT_TYPES = [
{key:"concert", label:"演唱會", icon:"🎤", color:"#ef476f"},
{key:"festival", label:"音樂節", icon:"🎪", color:"#ffb703"},
{key:"fanmeet", label:"見面會", icon:"🤝", color:"#06d6a0"},
{key:"brand", label:"品牌站台", icon:"🏬", color:"#3a86ff"},
{key:"screening",label:"SF Screening", icon:"🎬", color:"#9b5de5"},
{key:"drama", label:"戲劇宣傳", icon:"🎭", color:"#f4845f"},
{key:"bookfair", label:"書展", icon:"📚", color:"#83c5be"},
{key:"presscon", label:"發佈會", icon:"📢", color:"#ff8fab"},
{key:"other", label:"其他", icon:"✨", color:"#8d99ae"}
];
var TICKET_METHODS = [
{key:"form", label:"需填預約表單"},
{key:"queue", label:"現場排隊"},
{key:"lottery", label:"自動抽選"}
];
function getEventType(key){
for(var i=0;i<EVENT_TYPES.length;i++){ if(EVENT_TYPES[i].key===key) return EVENT_TYPES[i]; }
return EVENT_TYPES[EVENT_TYPES.length-1];
}
var state = {
events: [],
board: [],
joined: [],
tab: "home",
filters: {},
templates: [],
creatorName: ""
};
function pad2(n){ return n<10 ? "0"+n : ""+n; }
function seedEvents(){
return [
// ---- 韓流 / 日流海外巡演 ----
{id:"seed-team", date:"2026-07-18", time:"19:00", location:"場館待公布", name:"&TEAM《BLAZE THE WAY》CONCERT TOUR 曼谷站", artist:"&TEAM", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:""},
{id:"seed-irene", date:"2026-07-18", time:"18:00", location:"MCC Hall, The Mall Bangkapi", name:"IRENE ASIA TOUR [I-WILL] in BANGKOK", artist:"IRENE (Red Velvet)", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:""},
{id:"seed-xg", date:"2026-07-19", time:"", location:"Impact Arena, Muang Thong Thani", name:"XG WORLD TOUR : THE CORE IN BANGKOK", artist:"XG", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/xg-world-tour-the-core-in-bangkok.html"},
{id:"seed-natori", date:"2026-07-28", time:"", location:"UOB Live, The Mall Bangkapi", name:"natori ONE-MAN LIVE TOUR \"Koshin (March)\" in Bangkok", artist:"natori", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/natori-one-man-live-tour-koshin-march-in-bangkok.html"},
{id:"seed-byeon", date:"2026-08-16", time:"", location:"UOB Live, The Mall Bangkapi", name:"2026 ByeonWooSeok Asia Fanmeeting Tour <The Secret Library> in Bangkok", artist:"邊佑錫 Byeon Woo-seok", joinCount:0, type:"fanmeet", notes:"", ticketMethods:[], presale:"一般開賣 7/5 10:00", saleLink:"https://www.thaiticketmajor.com/performance/2026-byeonwooseok-asia-fanmeeting-tour-the-secret-library-in-bangkok.html"},
{id:"seed-sandara", date:"2026-08-15", time:"", location:"Samyan Mitrtown Hall", name:"2026 SANDARA PARK FAN-CON ASIA TOUR [REPRISM] IN BANGKOK", artist:"SANDARA PARK", joinCount:0, type:"fanmeet", notes:"", ticketMethods:[], presale:"", saleLink:"https://ticketmaster.co.th/activity/detail/26th_sandarapark"},
{id:"seed-babymonster", date:"2026-11-07", time:"", location:"Impact Arena, Muang Thong Thani", name:"2026'27 BABYMONSTER WORLD TOUR [CHOOM] IN BANGKOK · Day 1", artist:"BABYMONSTER", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/2026-27-babymonster-world-tour-choom-in-bangkok.html"},
{id:"seed-babymonster2", date:"2026-11-08", time:"", location:"Impact Arena, Muang Thong Thani", name:"2026'27 BABYMONSTER WORLD TOUR [CHOOM] IN BANGKOK · Day 2", artist:"BABYMONSTER", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/2026-27-babymonster-world-tour-choom-in-bangkok.html"},
{id:"seed-zaralarsson", date:"2026-11-01", time:"", location:"UOB Live, The Mall Bangkapi", name:"Zara Larsson - Midnight Sun Tour in Bangkok", artist:"Zara Larsson", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"會員預售 7/20–7/22(各梯次10:00或12:00);一般開賣 7/24 10:00", saleLink:"https://ticketmaster.co.th/activity/detail/26th_zaralarsson"},
{id:"seed-yamashita", date:"2026-12-19", time:"", location:"Union Hall", name:"DREAMING TOMOHISA YAMASHITA TOUR 2026 LIVE in BANGKOK", artist:"山下智久 Tomohisa Yamashita", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"一般開賣 7/18 10:00", saleLink:"https://www.thaiticketmajor.com/concert/dreaming-tomohisa-yamashita-tour-2026-live-in-bangkok.html"},
{id:"seed-fujiikaze", date:"2026-12-26", time:"", location:"สนามกีฬาแห่งชาติ ศุภชลาศัย (國家體育場)", name:"FUJII KAZE PREMA WORLD TOUR 曼谷站", artist:"藤井風 Fujii Kaze", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/Fujii-Kaze-Prema-World-Tour.html"},
// ---- 音樂節 / 聯合演出 ----
{id:"seed-monster1", date:"2026-07-25", time:"", location:"QSNCC 詩麗吉皇后國家會議中心", name:"Monster Music Festival 2026 · Day 1", artist:"PiXXiE、LYKN、YOUNGOHM 等", joinCount:0, type:"festival", notes:"", ticketMethods:[], presale:"", saleLink:""},
{id:"seed-monster2", date:"2026-07-26", time:"", location:"QSNCC 詩麗吉皇后國家會議中心", name:"Monster Music Festival 2026 · Day 2", artist:"JEFF SATUR、BIG ASS 等", joinCount:0, type:"festival", notes:"", ticketMethods:[], presale:"", saleLink:""},
{id:"seed-fandomfest", date:"2026-08-15", time:"", location:"One Bangkok Forum", name:"FANDOM FEST", artist:"多組泰國藝人聯合", joinCount:0, type:"festival", notes:"", ticketMethods:[], presale:"一般開賣 7/6 10:00", saleLink:"https://www.thaiticketmajor.com/concert/fandom-fest.html"},
// ---- GMMTV/泰國藝人見面會 ----
{id:"seed-juniormark1", date:"2026-08-07", time:"", location:"BITEC LIVE", name:"JUNIOR MARK SunnyMoon CONCERT · Day 1", artist:"JUNIOR(Panachai)& MARK(Jiruntanin)", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/junior-mark-sunnymoon-concert.html"},
{id:"seed-juniormark2", date:"2026-08-08", time:"", location:"BITEC LIVE", name:"JUNIOR MARK SunnyMoon CONCERT · Day 2", artist:"JUNIOR(Panachai)& MARK(Jiruntanin)", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/junior-mark-sunnymoon-concert.html"},
{id:"seed-juniormark3", date:"2026-08-09", time:"", location:"BITEC LIVE", name:"JUNIOR MARK SunnyMoon CONCERT · Day 3", artist:"JUNIOR(Panachai)& MARK(Jiruntanin)", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/junior-mark-sunnymoon-concert.html"},
{id:"seed-pondphuwin1", date:"2026-08-21", time:"", location:"Impact Arena, Muang Thong Thani", name:"POND PHUWIN Space Soul-dyssey CONCERT · Day 1", artist:"POND PHUWIN", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/pond-phuwin-space-soul-dyssey-concert.html"},
{id:"seed-pondphuwin2", date:"2026-08-22", time:"", location:"Impact Arena, Muang Thong Thani", name:"POND PHUWIN Space Soul-dyssey CONCERT · Day 2", artist:"POND PHUWIN", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/pond-phuwin-space-soul-dyssey-concert.html"},
{id:"seed-pondphuwin3", date:"2026-08-23", time:"", location:"Impact Arena, Muang Thong Thani", name:"POND PHUWIN Space Soul-dyssey CONCERT · Day 3", artist:"POND PHUWIN", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/pond-phuwin-space-soul-dyssey-concert.html"},
{id:"seed-perthsanta1", date:"2026-07-18", time:"", location:"Paragon Hall", name:"PERTH SANTA DEVIL'S KISS CONCERT · Day 1", artist:"Perth Tanapon & Santa Ausanan", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/perth-santa-devils-kiss-concert.html"},
{id:"seed-perthsanta2", date:"2026-07-19", time:"", location:"Paragon Hall", name:"PERTH SANTA DEVIL'S KISS CONCERT · Day 2", artist:"Perth Tanapon & Santa Ausanan", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/perth-santa-devils-kiss-concert.html"},
{id:"seed-feelfan1", date:"2026-07-25", time:"", location:"Union Hall", name:"FEEL FAN FUN PARTY CONCERT · Day 1", artist:"未公布(詳見官網陣容)", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/feel-fan-fun-party-concert.html"},
{id:"seed-feelfan2", date:"2026-07-26", time:"", location:"Union Hall", name:"FEEL FAN FUN PARTY CONCERT · Day 2", artist:"未公布(詳見官網陣容)", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/feel-fan-fun-party-concert.html"},
{id:"seed-jossgawin", date:"2026-07-28", time:"", location:"Union Hall", name:"JOSS GAWIN HEAT & BEAT CONCERT", artist:"Joss Wayar & Gawin Caskey", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/joss-gawin-heat-and-beat-concert.html"},
{id:"seed-pipatchara", date:"2026-07-22", time:"", location:"ศูนย์วัฒนธรรมแห่งประเทศไทย(泰國文化中心)", name:"PIPATCHARA The Main Stage", artist:"PIPATCHARA", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"一般開賣 7/7 12:00", saleLink:"https://www.thaiticketmajor.com/performance/pipatchara-the-main-stage.html"},
{id:"seed-fforever1", date:"2026-08-01", time:"", location:"Impact Arena, Muang Thong Thani", name:"F✦FOREVER 1st World Tour in Bangkok · Day 1", artist:"F✦FOREVER", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/f-forever-1st-world-tour-in-bangkok.html"},
{id:"seed-fforever2", date:"2026-08-02", time:"", location:"Impact Arena, Muang Thong Thani", name:"F✦FOREVER 1st World Tour in Bangkok · Day 2", artist:"F✦FOREVER", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/f-forever-1st-world-tour-in-bangkok.html"},
{id:"seed-sotus1", date:"2026-08-29", time:"", location:"Union Hall", name:"SOTUS TENIVERSARY CONCERT · Day 1", artist:"Krist Perawat & Singto Prachaya", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"一般開賣 7/5 10:00", saleLink:"https://www.thaiticketmajor.com/concert/sotus-teniversary-concert.html"},
{id:"seed-sotus2", date:"2026-08-30", time:"", location:"Union Hall", name:"SOTUS TENIVERSARY CONCERT · Day 2", artist:"Krist Perawat & Singto Prachaya", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"一般開賣 7/5 10:00", saleLink:"https://www.thaiticketmajor.com/concert/sotus-teniversary-concert.html"},
{id:"seed-bday", date:"2026-08-29", time:"", location:"Paragon Hall", name:"B.DAY UN·CUT LIVE CONCERT", artist:"未公布(詳見官網陣容)", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/b-day-sbz-uncut-live-reel-01.html"},
{id:"seed-gemini1", date:"2026-09-05", time:"", location:"BITEC LIVE", name:"GEMINI ART-VENTURE CONCERT · Day 1", artist:"GEMINI", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"一般開賣 7/11 10:00", saleLink:"https://www.thaiticketmajor.com/concert/gemini-art-venture-concert.html"},
{id:"seed-gemini2", date:"2026-09-06", time:"", location:"BITEC LIVE", name:"GEMINI ART-VENTURE CONCERT · Day 2", artist:"GEMINI", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"一般開賣 7/11 10:00", saleLink:"https://www.thaiticketmajor.com/concert/gemini-art-venture-concert.html"},
{id:"seed-rookiedivos", date:"2026-09-26", time:"", location:"BITEC LIVE", name:"ROOKIE DIVOS CONCERT", artist:"未公布(詳見官網陣容)", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:"https://www.thaiticketmajor.com/concert/rookie-divos-concert.html"},
{id:"seed-remaster", date:"2026-10-10", time:"", location:"Paragon Hall", name:"REMASTER CONCERT", artist:"未公布(詳見官網陣容)", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"一般開賣 7/24 10:00", saleLink:"https://www.thaiticketmajor.com/concert/remaster-concert.html"},
// ---- 世界巡演 ----
{id:"seed-bts1", date:"2026-12-03", time:"", location:"場館待公布", name:"BTS WORLD TOUR IN BANGKOK 2026 · Day 1", artist:"BTS", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:""},
{id:"seed-bts2", date:"2026-12-04", time:"", location:"場館待公布", name:"BTS WORLD TOUR IN BANGKOK 2026 · Day 2", artist:"BTS", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:""},
{id:"seed-bts3", date:"2026-12-05", time:"", location:"場館待公布", name:"BTS WORLD TOUR IN BANGKOK 2026 · Day 3", artist:"BTS", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:""},
{id:"seed-bts4", date:"2026-12-06", time:"", location:"場館待公布", name:"BTS WORLD TOUR IN BANGKOK 2026 · Day 4", artist:"BTS", joinCount:0, type:"concert", notes:"", ticketMethods:[], presale:"", saleLink:""},
];
}
// ---------- storage helpers ----------
function mapEventRow_(r){
return {
id: r.id, date: r.date || null, time: r.time || "", location: r.location || "",
type: r.type || "concert", name: r.name || "", artist: r.artist || "",
presale: r.presale || "", saleLink: r.saleLink || "", notes: r.notes || "",
ticketMethods: r.ticketMethods ? String(r.ticketMethods).split(",").filter(Boolean) : [],
joinCount: Number(r.joinCount || 0), creator: r.creator || ""
};
}
function mapBoardRow_(r){
var extra = {};
try{ extra = JSON.parse(r.extraJSON || "{}"); }catch(e){}
return Object.assign({
id: r.id, category: r.category || "info", title: r.title || "", content: r.content || "",
contact: r.contact || "", creator: r.creator || "", createdAt: r.createdAt || new Date().toISOString()
}, extra);
}
async function loadEvents(){
if(SCRIPT_URL){
var rows = await apiGet("events");
if(rows !== null){
if(rows.length === 0){
// Google Sheet 是空的(第一次使用):把預設場次資料寫進去,之後大家看到的都是同一份
var seeded = seedEvents();
for(var i=0;i<seeded.length;i++){ await apiPost("events","create", seeded[i]); }
var rows2 = await apiGet("events");
return (rows2 || []).map(mapEventRow_);
}
return rows.map(mapEventRow_);
}
// 讀取失敗(網路問題等)才會往下退回本機備援,僅供 Claude 預覽環境使用
}
try{
var res = await window.storage.get(STORAGE_KEY, true);
if(res && res.value) return JSON.parse(res.value);
}catch(e){}
var seeded2 = seedEvents();
try{ await window.storage.set(STORAGE_KEY, JSON.stringify(seeded2), true); }catch(e){}
return seeded2;
}
async function saveEvents(events){
try{ await window.storage.set(STORAGE_KEY, JSON.stringify(events), true); }catch(e){}
}
async function loadBoard(){
if(SCRIPT_URL){
var rows = await apiGet("board");
if(rows !== null) return rows.map(mapBoardRow_);
}
try{
var res = await window.storage.get(BOARD_KEY, true);
if(res && res.value) return JSON.parse(res.value);
}catch(e){}
return [];
}
async function saveBoard(list){
try{ await window.storage.set(BOARD_KEY, JSON.stringify(list), true); }catch(e){}
}
async function loadJoined(){
try{
var res = await window.storage.get(JOINED_KEY, false);
if(res && res.value) return JSON.parse(res.value);
}catch(e){}
return [];
}
async function saveJoined(list){
try{ await window.storage.set(JOINED_KEY, JSON.stringify(list), false); }catch(e){}
}
async function loadTheme(){
try{
var res = await window.storage.get(THEME_KEY, false);
if(res && res.value) return res.value;
}catch(e){}
return null;
}
async function saveTheme(t){
try{ await window.storage.set(THEME_KEY, t, false); }catch(e){}
}
// ---------- 建立者暱稱 + 裝置代碼(給 Google Sheet 後端用,非個資,僅本機記住)----------
async function getClientId(){
try{
var res = await window.storage.get(CLIENT_ID_KEY, false);
if(res && res.value) return res.value;
}catch(e){}
var id = "c-"+Date.now()+"-"+Math.floor(Math.random()*100000);
try{ await window.storage.set(CLIENT_ID_KEY, id, false); }catch(e){}
return id;
}
async function loadCreatorName(){
try{
var res = await window.storage.get(CREATOR_KEY, false);
if(res && res.value) return res.value;
}catch(e){}
return "";
}
async function saveCreatorName(name){
try{ await window.storage.set(CREATOR_KEY, name, false); }catch(e){}
}
// ---------- Google Sheet 後端(best-effort:沒設定 SCRIPT_URL 或連線失敗都不影響現有功能)----------
async function apiPost(sheet, action, payload){
if(!SCRIPT_URL) return null;
try{
var clientId = await getClientId();
var res = await fetch(SCRIPT_URL, {
method: "POST",
headers: {"Content-Type":"text/plain;charset=utf-8"},
body: JSON.stringify({
sheet: sheet, action: action, payload: payload,
clientId: clientId, creator: state.creatorName || "",
honeypot: (root.getElementById("f-hp") ? root.getElementById("f-hp").value : "")
})
});
return await res.json();
}catch(e){ return null; }
}
async function apiGet(sheet){
if(!SCRIPT_URL) return null;
try{
var res = await fetch(SCRIPT_URL + "?sheet=" + sheet);
var json = await res.json();
return (json && json.ok) ? json.data : null;
}catch(e){ return null; }
}
// ---------- utils ----------
function todayStr(){
var d = new Date();
return d.getFullYear()+"-"+pad2(d.getMonth()+1)+"-"+pad2(d.getDate());
}
function todayObj(){ return new Date(); }
function formatDateShort(dateStr){
if(!dateStr) return "";
var p = dateStr.split("-");
var d = new Date(parseInt(p[0]),parseInt(p[1])-1,parseInt(p[2]));
return (parseInt(p[1]))+"/"+(parseInt(p[2]))+"("+DOW[d.getDay()]+")";
}
function formatDateVert(dateStr){
if(!dateStr) return "TBD";
var p = dateStr.split("-");
return p[1]+"."+p[2];
}
function formatDateTime(iso){
var d = new Date(iso);
return (d.getMonth()+1)+"/"+d.getDate()+" "+pad2(d.getHours())+":"+pad2(d.getMinutes());
}
function escapeHtml(s){
if(s===undefined || s===null) return "";
return String(s).replace(/[&<>"']/g, function(c){
return {"&":"&","<":"<",">":">",'"':""","'":"'"}[c];
});
}
function isKnownVenue(name){
if(!name) return false;
return VENUES.some(function(v){ return v.toLowerCase() === String(name).toLowerCase(); });
}
function getFilterState(tabKey){
if(!state.filters[tabKey]) state.filters[tabKey] = {venue:"", q:"", day:null, type:""};
return state.filters[tabKey];
}
function distinctVenues(list){
var set = {};
list.forEach(function(e){ if(e.location) set[e.location]=true; });
return Object.keys(set).sort();
}
function applyFilters(list, f){
return list.filter(function(e){
if(f.venue && e.location !== f.venue) return false;
if(f.type && e.type !== f.type) return false;
if(f.day && e.date !== f.day) return false;
if(f.q){
var q = f.q.toLowerCase();
var hay = (e.name+" "+e.artist+" "+e.location).toLowerCase();
if(hay.indexOf(q) === -1) return false;
}
return true;
});
}
function isUpcoming(e){
if(!e.date) return false;
return e.date >= todayStr();
}
// ---------- tabs ----------
function renderTabs(){
var tabbar = root.getElementById("tabbar");
tabbar.innerHTML = "";
var frag = document.createDocumentFragment();
frag.appendChild(makeTabBtn("home","首頁", state.events.filter(isUpcoming).length));
frag.appendChild(makeTabBtn("board","📌 公佈欄", state.board.length));
for(var mm=7; mm<=12; mm++){
var cnt = state.events.filter(function(e){ return e.date && parseInt(e.date.split("-")[1],10)===mm; }).length;
frag.appendChild(makeTabBtn("m"+mm, MONTH_NAMES[mm-1], cnt));
}
tabbar.appendChild(frag);
}
function makeTabBtn(key,label,count){
var btn = document.createElement("button");
btn.className = "tab-btn"+(state.tab===key?" active":"");
btn.innerHTML = label + (count>0 ? '<span class="tab-badge">'+count+'</span>' : "");
btn.addEventListener("click", function(){
state.tab = key;
renderTabs();
renderContent();
updateFabLabel();
});
return btn;
}
function updateFabLabel(){
var fab = root.getElementById("fabAdd");
fab.textContent = state.tab === "board" ? "+ 發佈公告" : "+ 新增活動";
}
// ---------- content router ----------
function renderContent(){
var content = root.getElementById("content");
content.innerHTML = "";
if(state.tab === "home"){
content.appendChild(renderHome());
}else if(state.tab === "board"){
content.appendChild(renderBoard());
}else{
var month = parseInt(state.tab.slice(1),10);
content.appendChild(renderMonth(month));
}
}
function renderHome(){
var wrap = document.createElement("div");
var upcoming = state.events.filter(isUpcoming).sort(function(a,b){ return a.date.localeCompare(b.date); });
var f = getFilterState("home");
var venues = distinctVenues(state.events);
// 1) Calendar for current month first
var now = todayObj();
var calYear = YEAR, calMonth = now.getMonth()+1;
if(now.getFullYear() !== YEAR){ calMonth = upcoming.length ? parseInt(upcoming[0].date.split("-")[1],10) : 7; }
var sec2 = document.createElement("div");
sec2.className = "section";
sec2.innerHTML = '<div class="section-head"><div class="section-title">📅 '+calYear+'年 '+MONTH_NAMES[calMonth-1]+' 行事曆</div><div class="section-sub">點日期可篩選當日活動</div></div>';
sec2.appendChild(buildCalendar(calYear, calMonth, "home"));
wrap.appendChild(sec2);
// 2) Today's events (or the day picked on the calendar above)
var f2 = getFilterState("home");
var today = todayStr();
var targetDate = f2.day || today;
var isToday = targetDate === today;
var dayEvents = state.events.filter(function(e){ return e.date === targetDate; })
.sort(function(a,b){ return (a.time||"").localeCompare(b.time||""); });
var sec1 = document.createElement("div");
sec1.className = "section";
sec1.innerHTML = '<div class="section-head"><div class="section-title">🔥 '+(isToday ? "今日活動" : formatDateShort(targetDate)+" 的活動")+'</div><div class="section-sub">'+formatDateShort(targetDate)+',共 '+dayEvents.length+' 筆</div></div>';
sec1.appendChild(buildFilterBar("home", venues, f));
var dayFiltered = applyFilters(dayEvents, {venue:f2.venue, q:f2.q, day:null});
sec1.appendChild(buildTicketList(dayFiltered, isToday ? "今天沒有活動,切換上方月份分頁看看其他場次,或直接新增一筆!" : "這天沒有活動。"));
wrap.appendChild(sec1);
return wrap;
}
function renderMonth(month){
var wrap = document.createElement("div");
var f = getFilterState("m"+month);
var monthEvents = state.events.filter(function(e){ return e.date && parseInt(e.date.split("-")[1],10)===month; })
.sort(function(a,b){ return a.date.localeCompare(b.date) || (a.time||"").localeCompare(b.time||""); });
var venues = distinctVenues(monthEvents);
var sec = document.createElement("div");
sec.className = "section";
var headWrap = document.createElement("div");
headWrap.className = "section-head";
headWrap.innerHTML = '<div class="section-title">🎫 '+YEAR+'年 '+MONTH_NAMES[month-1]+'</div><div class="section-sub">共 '+monthEvents.length+' 筆活動</div>';
var exportBtn = document.createElement("button");
exportBtn.className = "export-btn";
exportBtn.textContent = "⬇ 匯出本月清單 (CSV)";
exportBtn.addEventListener("click", function(){ exportCSV(month, monthEvents); });
headWrap.appendChild(exportBtn);
sec.appendChild(headWrap);
sec.appendChild(buildCalendar(YEAR, month, "m"+month));
sec.appendChild(buildFilterBar("m"+month, venues, f));
sec.appendChild(buildTicketList(applyFilters(monthEvents, f), "這個月還沒有活動,用右下角「+ 新增活動」補上第一筆吧!"));
wrap.appendChild(sec);
return wrap;
}
function renderBoard(){
var wrap = document.createElement("div");
var sec = document.createElement("div");
sec.className = "section";
sec.innerHTML = '<div class="section-head"><div class="section-title">📌 公佈欄</div><div class="section-sub">搶票情報、集資、換票交流,共 '+state.board.length+' 則</div></div>';
var disclaimer = document.createElement("div");
disclaimer.className = "board-disclaimer";
disclaimer.innerHTML =
'⚠️ <b>使用須知:</b>公佈欄僅提供「票券交換資訊」的媒合,本站不參與、不介入任何交易,也無法驗證發文者身份。' +
'<b>不提供代購服務</b>,商品代購(含週邊、專輯等)不在此討論範圍,如有代購需求請另尋管道處理。' +
'這裡是<b>匿名</b>公佈欄,請自行甄別所有資訊的真偽;正式交易前,務必先互相確認個人資訊與社群帳號,並自行承擔交易風險。';
sec.appendChild(disclaimer);
var posts = state.board.slice().sort(function(a,b){ return b.createdAt.localeCompare(a.createdAt); });
var catFilter = getFilterState("board-cat");
var filterBar = document.createElement("div");
filterBar.className = "filters";
var catSel = document.createElement("select");
catSel.innerHTML =
'<option value="">全部分類</option>'+
'<option value="info"'+(catFilter.venue==="info"?" selected":"")+'>📌 資訊公告</option>'+
'<option value="trade"'+(catFilter.venue==="trade"?" selected":"")+'>🔄 換票</option>'+
'<option value="sell"'+(catFilter.venue==="sell"?" selected":"")+'>💰 售票</option>'+
'<option value="group"'+(catFilter.venue==="group"?" selected":"")+'>🎉 揪團</option>';
catSel.addEventListener("change", function(){ catFilter.venue = catSel.value; renderContent(); });
filterBar.appendChild(catSel);
sec.appendChild(filterBar);
if(catFilter.venue){ posts = posts.filter(function(p){ return p.category === catFilter.venue; }); }
if(!posts.length){
var empty = document.createElement("div");
empty.className = "empty-state";
empty.textContent = "目前沒有符合條件的公告,點右下角「+ 發佈公告」貼第一則情報吧!";
sec.appendChild(empty);
}else{
var list = document.createElement("div");
list.className = "board-list";
posts.forEach(function(p){ list.appendChild(buildBoardCard(p)); });
sec.appendChild(list);
}
wrap.appendChild(sec);
return wrap;
}
var CATEGORY_META = {
info: {cls:"info", label:"📌 資訊公告"},
trade: {cls:"trade", label:"🔄 換票"},
sell: {cls:"sell", label:"💰 售票"},
group: {cls:"group", label:"🎉 揪團"}
};
function buildBoardCard(p){
var card = document.createElement("div");
card.className = "board-card";
var meta = CATEGORY_META[p.category] || CATEGORY_META.info;
var top = document.createElement("div");
top.className = "board-top";
top.innerHTML =
'<span class="board-badge '+meta.cls+'">'+meta.label+'</span>'+
'<span class="board-time">'+formatDateTime(p.createdAt)+(p.creator ? ' · 🖊️ '+escapeHtml(p.creator) : '')+'</span>';
card.appendChild(top);
var title = document.createElement("h3");
title.className = "board-title";
title.textContent = p.title;
card.appendChild(title);
if((p.category === "trade" || p.category === "sell") && (p.relatedEvent || p.tradeType || p.zone || p.qty || p.price)){
var spec = document.createElement("dl");
spec.className = "board-spec";
var rows = [
[p.category === "sell" ? "售票方式" : "交易類型", p.tradeType],
["相關活動", p.relatedEvent],
["票種 / 座位", p.zone],
["數量", p.qty],
[p.category === "sell" ? "售價" : "價格", p.price]
];
rows.forEach(function(r){
if(!r[1]) return;
var dt = document.createElement("dt");
dt.textContent = r[0];
var dd = document.createElement("dd");
dd.textContent = r[1];
spec.appendChild(dt);
spec.appendChild(dd);
});
card.appendChild(spec);
}
if(p.category === "group" && (p.relatedEvent || p.groupSize || p.meetup)){
var gspec = document.createElement("dl");
gspec.className = "board-spec";
var grows = [
["相關活動", p.relatedEvent],
["需求人數", p.groupSize],
["集合時間 / 地點", p.meetup]
];
grows.forEach(function(r){
if(!r[1]) return;
var dt = document.createElement("dt");
dt.textContent = r[0];
var dd = document.createElement("dd");
dd.textContent = r[1];
gspec.appendChild(dt);
gspec.appendChild(dd);
});
card.appendChild(gspec);
}
if(p.content){
var content = document.createElement("p");
content.className = "board-content";
content.textContent = p.content;
card.appendChild(content);
}
if(p.contact){
var contact = document.createElement("div");
contact.className = "board-contact";
contact.textContent = "✉️ "+p.contact;
card.appendChild(contact);
}
var delBtn = document.createElement("button");
delBtn.className = "btn-del";
delBtn.style.marginTop = "10px";
delBtn.textContent = "刪除";
var confirming = false;
delBtn.addEventListener("click", function(){
if(!confirming){
confirming = true;
delBtn.textContent = "確定刪除?";
delBtn.classList.add("confirming");
setTimeout(function(){ confirming=false; delBtn.textContent="刪除"; delBtn.classList.remove("confirming"); }, 3000);
}else{
deleteBoardPost(p.id);
}
});
card.appendChild(delBtn);
return card;
}
function buildFilterBar(tabKey, venues, f){
var bar = document.createElement("div");
bar.className = "filters";
var typeSel = document.createElement("select");
typeSel.innerHTML = '<option value="">全部類型</option>' + EVENT_TYPES.map(function(t){
return '<option value="'+t.key+'"'+(f.type===t.key?" selected":"")+'>'+t.icon+' '+t.label+'</option>';
}).join("");
typeSel.addEventListener("change", function(){ f.type = typeSel.value; renderContent(); });
bar.appendChild(typeSel);
var sel = document.createElement("select");
sel.innerHTML = '<option value="">全部場域</option>' + venues.map(function(v){
return '<option value="'+escapeHtml(v)+'"'+(f.venue===v?" selected":"")+'>'+escapeHtml(v)+'</option>';
}).join("");
sel.addEventListener("change", function(){ f.venue = sel.value; renderContent(); });
bar.appendChild(sel);
var input = document.createElement("input");
input.type = "text";
input.placeholder = "搜尋藝人 / 活動名稱 / 經紀公司";
input.value = f.q || "";
input.addEventListener("input", function(){ f.q = input.value; renderContent(); });
bar.appendChild(input);
if(f.day){
var clearBtn = document.createElement("button");
clearBtn.className = "cal-clear";
clearBtn.textContent = "✕ 清除日期篩選("+formatDateShort(f.day)+")";
clearBtn.addEventListener("click", function(){ f.day = null; renderContent(); });
bar.appendChild(clearBtn);
}
return bar;
}
function buildCalendar(year, month, tabKey){
var f = getFilterState(tabKey);
var wrap = document.createElement("div");
wrap.className = "cal-wrap";
var first = new Date(year, month-1, 1);
var daysInMonth = new Date(year, month, 0).getDate();
var startDow = first.getDay();
var today = todayStr();
var typesByDay = {};
state.events.forEach(function(e){
if(!e.date) return;
var p = e.date.split("-");
if(parseInt(p[0],10)===year && parseInt(p[1],10)===month){
if(!typesByDay[e.date]) typesByDay[e.date] = {};
typesByDay[e.date][e.type || "other"] = true;
}
});
var head = document.createElement("div");
head.className = "cal-head";
head.innerHTML = '<div class="cal-title">'+year+' / '+pad2(month)+'</div>';
wrap.appendChild(head);
var legend = document.createElement("div");
legend.className = "cal-legend";
var usedTypes = {};
Object.keys(typesByDay).forEach(function(ds){ Object.keys(typesByDay[ds]).forEach(function(t){ usedTypes[t]=true; }); });
EVENT_TYPES.forEach(function(t){
if(!usedTypes[t.key]) return;
var tag = document.createElement("span");
tag.className = "cal-legend-item";
tag.innerHTML = '<span class="cal-legend-dot" style="background:'+t.color+'"></span>'+t.icon+' '+t.label;
legend.appendChild(tag);
});
if(Object.keys(usedTypes).length) wrap.appendChild(legend);
var grid = document.createElement("div");
grid.className = "cal-grid";
DOW.forEach(function(d){
var el = document.createElement("div");
el.className = "cal-dow";
el.textContent = d;
grid.appendChild(el);
});
for(var i=0;i<startDow;i++){
var empty = document.createElement("div");
empty.className = "cal-day empty";
grid.appendChild(empty);
}
for(var d=1; d<=daysInMonth; d++){
var dateStr = year+"-"+pad2(month)+"-"+pad2(d);
var cell = document.createElement("div");
cell.className = "cal-day";
var dayTypes = typesByDay[dateStr] ? Object.keys(typesByDay[dateStr]) : [];
if(dayTypes.length) cell.className += " has-event";
if(dateStr === today) cell.className += " today";
if(f.day === dateStr) cell.className += " selected";
var num = document.createElement("div");
num.textContent = d;
cell.appendChild(num);
if(dayTypes.length){
var dotsRow = document.createElement("div");
dotsRow.className = "cal-dots";
dayTypes.slice(0,4).forEach(function(tk){
var dot = document.createElement("div");
dot.className = "cal-dot";
dot.style.background = getEventType(tk).color;
dotsRow.appendChild(dot);
});
cell.appendChild(dotsRow);
cell.addEventListener("click", function(ds){
return function(){ f.day = (f.day === ds) ? null : ds; renderContent(); };
}(dateStr));
}
grid.appendChild(cell);
}
wrap.appendChild(grid);
return wrap;
}
function buildTicketList(list, emptyMsg){
if(!list.length){
var empty = document.createElement("div");
empty.className = "empty-state";
empty.textContent = emptyMsg;
return empty;
}
var wrap = document.createElement("div");
wrap.className = "ticket-list";
list.forEach(function(e){ wrap.appendChild(buildTicketCard(e)); });
return wrap;
}
function buildTicketCard(e){
var card = document.createElement("div");
card.className = "ticket";
var stub = document.createElement("div");
stub.className = "ticket-stub";
stub.innerHTML = '<div class="v">'+formatDateVert(e.date)+'</div>';
card.appendChild(stub);
var main = document.createElement("div");
main.className = "ticket-main";
var top = document.createElement("div");
top.className = "ticket-row-top";
var typeMeta = getEventType(e.type);
top.innerHTML =
'<span class="type-badge" style="background:'+typeMeta.color+'22;color:'+typeMeta.color+';border:1px solid '+typeMeta.color+'55;">'+typeMeta.icon+' '+typeMeta.label+'</span>'+
(e.date ? '<span class="ticket-date">'+formatDateShort(e.date)+'</span>' : '<span class="tbd-chip">日期未定</span>') +
(e.time ? '<span class="ticket-time">🕒 '+escapeHtml(e.time)+'</span>' : "");
main.appendChild(top);
var name = document.createElement("h3");
name.className = "ticket-name";
name.textContent = e.name;
main.appendChild(name);
var artist = document.createElement("p");
artist.className = "ticket-meta";
artist.innerHTML = '🎤 <b>'+escapeHtml(e.artist)+'</b>';
main.appendChild(artist);
var loc = document.createElement("p");
loc.className = "ticket-meta";
loc.innerHTML = '📍 '+escapeHtml(e.location||"場館待公布");
main.appendChild(loc);
if(e.presale){
var presale = document.createElement("p");
presale.className = "ticket-meta";
presale.innerHTML = '🎟️ 搶票:'+escapeHtml(e.presale);
main.appendChild(presale);
}
if(e.saleLink){
var link = document.createElement("p");
link.className = "ticket-meta";
link.innerHTML = '🔗 <a href="'+escapeHtml(e.saleLink)+'" target="_blank" rel="noopener noreferrer">前往購票 →</a>';
main.appendChild(link);
}
if(e.ticketMethods && e.ticketMethods.length){
var tm = document.createElement("p");
tm.className = "ticket-meta";
tm.innerHTML = e.ticketMethods.map(function(k){
var m = TICKET_METHODS.find ? TICKET_METHODS.find(function(x){return x.key===k;}) : null;
return '<span class="method-chip">'+(m?m.label:k)+'</span>';
}).join(" ");
main.appendChild(tm);
}
if(e.notes){
var notes = document.createElement("p");
notes.className = "ticket-meta";
notes.innerHTML = '📝 '+escapeHtml(e.notes);
main.appendChild(notes);
}
if(e.creator){
var creatorLine = document.createElement("p");
creatorLine.className = "ticket-meta";
creatorLine.innerHTML = '🖊️ 提供者:'+escapeHtml(e.creator);
main.appendChild(creatorLine);
}
var actions = document.createElement("div");
actions.className = "ticket-actions";
var joined = state.joined.indexOf(e.id) !== -1;
var joinBtn = document.createElement("button");
joinBtn.className = "btn-join"+(joined?" joined":"");
joinBtn.textContent = (joined ? "✅ 已參加 (" : "🙋 我要參加 (") + (e.joinCount||0) + ")";
joinBtn.addEventListener("click", function(){ toggleJoin(e.id); });
actions.appendChild(joinBtn);
var delBtn = document.createElement("button");
delBtn.className = "btn-del";
delBtn.textContent = "刪除";
var confirming = false;
delBtn.addEventListener("click", function(){
if(!confirming){
confirming = true;
delBtn.textContent = "確定刪除?";
delBtn.classList.add("confirming");
setTimeout(function(){ confirming=false; delBtn.textContent="刪除"; delBtn.classList.remove("confirming"); }, 3000);
}else{
deleteEvent(e.id);
}
});
actions.appendChild(delBtn);
main.appendChild(actions);
card.appendChild(main);
return card;
}
// ---------- actions ----------
async function toggleJoin(id){
var idx = state.joined.indexOf(id);
var ev = state.events.find(function(e){ return e.id===id; });
if(!ev) return;
var delta;
if(idx === -1){
state.joined.push(id);
ev.joinCount = (ev.joinCount||0)+1;
delta = 1;
showToast("已加入參加名單,出發曼谷追星去!");
}else{
state.joined.splice(idx,1);
ev.joinCount = Math.max(0,(ev.joinCount||0)-1);
delta = -1;
showToast("已取消參加");
}
renderTabs(); renderContent();
await saveJoined(state.joined);
await saveEvents(state.events);
apiPost("events", "joinToggle", {id:id, delta:delta});
}
async function deleteEvent(id){
state.events = state.events.filter(function(e){ return e.id !== id; });
renderTabs(); renderContent();
await saveEvents(state.events);
apiPost("events", "delete", {id:id});
showToast("已刪除該筆活動");
}
async function deleteBoardPost(id){
state.board = state.board.filter(function(p){ return p.id !== id; });
renderTabs(); renderContent();
await saveBoard(state.board);
apiPost("board", "delete", {id:id});
showToast("已刪除該則公告");
}
function exportCSV(month, list){
var header = ["日期","時間","地點","活動類型","活動名稱","藝人","搶票時間","購票連結","備註"];
var rows = list.map(function(e){
return [e.date||"未定", e.time||"", e.location||"", getEventType(e.type).label, e.name||"", e.artist||"", e.presale||"", e.saleLink||"", e.notes||""]
.map(function(v){ return '"'+String(v).replace(/"/g,'""')+'"'; }).join(",");
});
var csv = "\uFEFF" + header.join(",") + "\n" + rows.join("\n");
var blob = new Blob([csv], {type:"text/csv;charset=utf-8;"});
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "曼谷追星行事曆_"+YEAR+"年"+month+"月.csv";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
var toastTimer = null;
function showToast(msg){
var t = root.getElementById("toast");
t.textContent = msg;
t.classList.add("show");
if(toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function(){ t.classList.remove("show"); }, 2400);
}
// ---------- venue preset + template select population ----------
function populateVenuePreset(){
var sel = root.getElementById("f-venue-preset");
var html = '<option value="">— 自行輸入 / 不在清單中 —</option>';
VENUE_GROUPS.forEach(function(g){
html += '<optgroup label="'+escapeHtml(g.label)+'">' +
g.venues.map(function(v){ return '<option value="'+escapeHtml(v)+'">'+escapeHtml(v)+'</option>'; }).join("") +
'</optgroup>';
});
sel.innerHTML = html;
sel.addEventListener("change", function(){
if(sel.value) root.getElementById("f-location").value = sel.value;
});
}
function populateEventTypeSelect(){
var sel = root.getElementById("f-type");
sel.innerHTML = EVENT_TYPES.map(function(t){ return '<option value="'+t.key+'">'+t.icon+' '+t.label+'</option>'; }).join("");
}
function populateTicketMethodChecks(){
var wrap = root.getElementById("ticketMethodChecks");
wrap.innerHTML = TICKET_METHODS.map(function(m){
return '<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text);font-family:var(--font-body);font-weight:400;">'+
'<input type="checkbox" class="tm-check" value="'+m.key+'"> '+m.label+'</label>';
}).join("");
}
function getCheckedTicketMethods(){
return Array.prototype.slice.call(root.querySelectorAll(".tm-check:checked")).map(function(c){ return c.value; });
}
function setCheckedTicketMethods(list){
list = list || [];
root.querySelectorAll(".tm-check").forEach(function(c){ c.checked = list.indexOf(c.value) !== -1; });
}
function populateTemplateSelect(){
var map = {};
state.events.forEach(function(e){ map[e.name] = e; }); // last one wins
var names = Object.keys(map).sort();
state.templates = names.map(function(n){ return map[n]; });
var sel = root.getElementById("f-template");
sel.innerHTML = '<option value="">— 不使用範本,手動填寫 —</option>' +
names.map(function(n,i){ return '<option value="'+i+'">'+escapeHtml(n)+'</option>'; }).join("");
sel.onchange = function(){
if(sel.value === "") return;
var tmpl = state.templates[parseInt(sel.value,10)];
if(!tmpl) return;
root.getElementById("f-type").value = tmpl.type || "concert";
root.getElementById("f-date").value = tmpl.date || "";
root.getElementById("f-time").value = tmpl.time || "";
root.getElementById("f-location").value = tmpl.location || "";
root.getElementById("f-name").value = tmpl.name || "";
root.getElementById("f-presale").value = tmpl.presale || "";
root.getElementById("f-salelink").value = tmpl.saleLink || "";
root.getElementById("f-notes").value = tmpl.notes || "";
setCheckedTicketMethods(tmpl.ticketMethods || []);
root.getElementById("f-artist").value = "";
root.getElementById("f-artist").focus();
showToast("已帶入範本,補上藝人名稱即可送出");
};
}
// ---------- modal / form: event ----------
var modalOverlay = root.getElementById("modalOverlay");
var eventForm = root.getElementById("eventForm");
var dateField = root.getElementById("dateField");
var dateInput = root.getElementById("f-date");
var formError = root.getElementById("formError");
root.getElementById("fabAdd").addEventListener("click", function(){
if(state.tab === "board"){
openBoardModal();
return;
}
eventForm.reset();
root.getElementById("f-template").value = "";
root.getElementById("f-creator").value = state.creatorName || "";
dateField.style.display = "block";
formError.style.display = "none";
populateEventTypeSelect();
populateTicketMethodChecks();
populateTemplateSelect();
modalOverlay.classList.add("open");
});
root.getElementById("cancelBtn").addEventListener("click", function(){ modalOverlay.classList.remove("open"); });
modalOverlay.addEventListener("click", function(e){ if(e.target === modalOverlay) modalOverlay.classList.remove("open"); });
eventForm.addEventListener("submit", async function(ev){
ev.preventDefault();
var date = dateInput.value;
var time = root.getElementById("f-time").value;
var location = root.getElementById("f-location").value.trim();
var name = root.getElementById("f-name").value.trim();
var artist = root.getElementById("f-artist").value.trim();
var type = root.getElementById("f-type").value || "concert";
var presale = root.getElementById("f-presale").value.trim();
var saleLink = root.getElementById("f-salelink").value.trim();
var notes = root.getElementById("f-notes").value.trim();
var creatorName = root.getElementById("f-creator").value.trim();
var ticketMethods = getCheckedTicketMethods();
if(!date || !location || !name || !artist){
formError.style.display = "block";
return;
}
formError.style.display = "none";
state.creatorName = creatorName;
await saveCreatorName(creatorName);
var newEvent = {
id: "u-"+Date.now()+"-"+Math.floor(Math.random()*1000),
date: date, time: time || "", location: location, name: name,
artist: artist, type: type, joinCount: 0,
presale: presale || "", saleLink: saleLink || "", notes: notes || "", ticketMethods: ticketMethods,
creator: creatorName || ""
};
state.events.push(newEvent);
modalOverlay.classList.remove("open");
state.tab = "m"+parseInt(date.split("-")[1],10);
renderTabs(); renderContent(); updateFabLabel();
await saveEvents(state.events);
apiPost("events", "create", newEvent); // best-effort 備份到 Google Sheet,失敗不影響上面已完成的儲存
showToast("新增成功!行事曆已同步更新 🎉");
});
// ---------- modal / form: board ----------
var boardModalOverlay = root.getElementById("boardModalOverlay");
var boardForm = root.getElementById("boardForm");
var boardFormError = root.getElementById("boardFormError");
var TRADE_TYPE_OPTIONS = {
trade: [["交換","交換(我想換位置/場次)"],["徵票","徵票(我在找票)"]],
sell: [["出售","出售(我要賣票)"],["轉讓","轉讓(佛心價/免費)"]]
};
function openBoardModal(){
boardForm.reset();
boardFormError.style.display = "none";
root.getElementById("b-creator").value = state.creatorName || "";
root.getElementById("relatedEventField").style.display = "none";
root.getElementById("ticketFields").style.display = "none";
root.getElementById("groupFields").style.display = "none";
populateBoardEventList();
boardModalOverlay.classList.add("open");
}
function updateBoardFieldsForCategory(cat){
var relatedEventField = root.getElementById("relatedEventField");
var ticketFields = root.getElementById("ticketFields");
var groupFields = root.getElementById("groupFields");
relatedEventField.style.display = (cat === "info") ? "none" : "block";
ticketFields.style.display = (cat === "trade" || cat === "sell") ? "block" : "none";
groupFields.style.display = (cat === "group") ? "block" : "none";
if(cat === "trade" || cat === "sell"){
var sel = root.getElementById("b-tradetype");
var opts = TRADE_TYPE_OPTIONS[cat];
sel.innerHTML = opts.map(function(o){ return '<option value="'+o[0]+'">'+o[1]+'</option>'; }).join("");
root.getElementById("b-tradetype-label").textContent = (cat === "sell") ? "售票方式" : "交易類型";
root.getElementById("b-price-label").textContent = (cat === "sell") ? "售價" : "價格 / 希望的交換內容(選填)";
}
}
root.getElementById("b-category").addEventListener("change", function(){
updateBoardFieldsForCategory(this.value);
});
function populateBoardEventList(){
var dl = root.getElementById("b-event-list");
var names = {};
state.events.forEach(function(e){ names[e.name] = true; });
dl.innerHTML = Object.keys(names).sort().map(function(n){ return '<option value="'+escapeHtml(n)+'"></option>'; }).join("");
}
root.getElementById("boardCancelBtn").addEventListener("click", function(){ boardModalOverlay.classList.remove("open"); });
boardModalOverlay.addEventListener("click", function(e){ if(e.target === boardModalOverlay) boardModalOverlay.classList.remove("open"); });
boardForm.addEventListener("submit", async function(ev){
ev.preventDefault();
var category = root.getElementById("b-category").value;
var title = root.getElementById("b-title").value.trim();
var content = root.getElementById("b-content").value.trim();
var contact = root.getElementById("b-contact").value.trim();
var creatorName = root.getElementById("b-creator").value.trim();
if(!title){
boardFormError.style.display = "block";
return;
}
boardFormError.style.display = "none";
state.creatorName = creatorName;
await saveCreatorName(creatorName);
var post = {
id: "b-"+Date.now()+"-"+Math.floor(Math.random()*1000),
category: category, title: title, content: content, contact: contact,
creator: creatorName || "",
createdAt: new Date().toISOString()
};
if(category !== "info"){
post.relatedEvent = root.getElementById("b-event").value.trim();
}
if(category === "trade" || category === "sell"){
post.tradeType = root.getElementById("b-tradetype").value;
post.zone = root.getElementById("b-zone").value.trim();
post.qty = root.getElementById("b-qty").value.trim();
post.price = root.getElementById("b-price").value.trim();
}
if(category === "group"){
post.groupSize = root.getElementById("b-groupsize").value.trim();
post.meetup = root.getElementById("b-meetup").value.trim();
}
state.board.push(post);
boardModalOverlay.classList.remove("open");
renderTabs(); renderContent();
await saveBoard(state.board);
apiPost("board", "create", {
category: post.category, title: post.title, content: post.content, contact: post.contact,
creator: post.creator,
extra: {relatedEvent:post.relatedEvent, tradeType:post.tradeType, zone:post.zone, qty:post.qty, price:post.price, groupSize:post.groupSize, meetup:post.meetup}
});
showToast("已發佈到公佈欄!");
});
// ---------- theme ----------
function applyTheme(t){
hostEl.setAttribute("data-theme", t);
}
async function initTheme(){
var params = new URLSearchParams(window.location.search);
var urlTheme = params.get("theme");
if(urlTheme === "light" || urlTheme === "dark"){
applyTheme(urlTheme);
return;
}
var saved = await loadTheme();
applyTheme(saved === "light" ? "light" : "dark");
}
root.getElementById("themeToggle").addEventListener("click", async function(){
var current = hostEl.getAttribute("data-theme") || "dark";
var next = current === "dark" ? "light" : "dark";
applyTheme(next);
await saveTheme(next);
});
// ---------- init ----------
async function init(){
await initTheme();
populateVenuePreset();
state.creatorName = await loadCreatorName();
root.getElementById("content").innerHTML = '<div class="empty-state">載入共筆行事曆中...</div>';
state.events = await loadEvents();
state.board = await loadBoard();
state.joined = await loadJoined();
renderTabs();
renderContent();
updateFabLabel();
}
init();
}
function mount(){
var currentScript = document.currentScript || (function(){
var scripts = document.getElementsByTagName("script");
return scripts[scripts.length - 1];
})();
var hostEl = document.createElement("div");
hostEl.className = "bkk-calendar-widget-host";
currentScript.parentNode.insertBefore(hostEl, currentScript.nextSibling);
var root = hostEl.attachShadow({mode:"open"});
var styleEl = document.createElement("style");
styleEl.textContent = CSS_TEXT;
root.appendChild(styleEl);
var wrapper = document.createElement("div");
wrapper.innerHTML = BODY_HTML;
while(wrapper.firstChild){ root.appendChild(wrapper.firstChild); }
ensureFonts();
runApp(root, hostEl);
}
if(document.readyState === "loading"){
document.addEventListener("DOMContentLoaded", mount);
}else{
mount();
}
})();Bangkok-calendar
HomeBangkok-calendar