index.html 75 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
  6. <title>Calendar</title>
  7. <script src="../script/jquery.min.js"></script>
  8. <script src="../script/ao_module.js"></script>
  9. <script src="../script/applocale.js"></script>
  10. <style>
  11. *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
  12. :root{
  13. --bg:#fff;--bg2:#f5f5f7;--border:#d1d1d6;--text:#1c1c1e;--text2:#6c6c70;--text3:#aeaeb2;
  14. --accent:#007aff;--accent2:#0051d5;--hover:rgba(0,0,0,.04);--hover2:rgba(0,0,0,.09);
  15. --shadow:0 8px 32px rgba(0,0,0,.14);--r:12px;--r2:8px;--r3:6px;
  16. --ev-blue-bg:#dbeafe;--ev-blue-bd:#3b82f6;--ev-blue-tx:#1d4ed8;
  17. --ev-red-bg:#fee2e2;--ev-red-bd:#ef4444;--ev-red-tx:#991b1b;
  18. --ev-orange-bg:#ffedd5;--ev-orange-bd:#f97316;--ev-orange-tx:#9a3412;
  19. --ev-green-bg:#dcfce7;--ev-green-bd:#22c55e;--ev-green-tx:#15803d;
  20. --ev-purple-bg:#ede9fe;--ev-purple-bd:#a855f7;--ev-purple-tx:#6b21a8;
  21. --ev-teal-bg:#ccfbf1;--ev-teal-bd:#14b8a6;--ev-teal-tx:#0f766e;
  22. }
  23. body.dark{
  24. --bg:#1c1c1e;--bg2:#2c2c2e;--border:#3a3a3c;--text:#fff;--text2:#8e8e93;--text3:#636366;
  25. --accent:#0a84ff;--accent2:#409cff;--hover:rgba(255,255,255,.05);--hover2:rgba(255,255,255,.11);
  26. --shadow:0 8px 32px rgba(0,0,0,.5);
  27. --ev-blue-bg:rgba(59,130,246,.18);--ev-blue-bd:#60a5fa;--ev-blue-tx:#93c5fd;
  28. --ev-red-bg:rgba(239,68,68,.18);--ev-red-bd:#f87171;--ev-red-tx:#fca5a5;
  29. --ev-orange-bg:rgba(249,115,22,.18);--ev-orange-bd:#fb923c;--ev-orange-tx:#fdba74;
  30. --ev-green-bg:rgba(34,197,94,.18);--ev-green-bd:#4ade80;--ev-green-tx:#86efac;
  31. --ev-purple-bg:rgba(168,85,247,.18);--ev-purple-bd:#c084fc;--ev-purple-tx:#d8b4fe;
  32. --ev-teal-bg:rgba(20,184,166,.18);--ev-teal-bd:#2dd4bf;--ev-teal-tx:#5eead4;
  33. }
  34. body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',sans-serif;background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:grid;grid-template-rows:52px 1fr}
  35. /* HAMBURGER — only shown on narrow screens (see media query) */
  36. .menu-btn{display:none;background:none;border:none;cursor:pointer;padding:5px 6px;border-radius:var(--r3);color:var(--accent);line-height:1;align-items:center}
  37. .menu-btn:hover{background:var(--hover2)}
  38. /* TOOLBAR */
  39. #toolbar{display:flex;align-items:center;gap:6px;padding:0 14px;border-bottom:1px solid var(--border);background:var(--bg2);user-select:none;flex-shrink:0}
  40. .app-title{font-size:17px;font-weight:700;margin-right:4px}
  41. .tb-btn{background:none;border:none;cursor:pointer;padding:5px 8px;border-radius:var(--r3);color:var(--accent);font-size:19px;line-height:1;display:flex;align-items:center;transition:background .12s}
  42. .tb-btn:hover{background:var(--hover2)}
  43. .period-label{font-size:15px;font-weight:600;min-width:170px;text-align:center;color:var(--text)}
  44. .today-btn{background:none;border:1px solid var(--border);cursor:pointer;padding:4px 12px;border-radius:var(--r2);color:var(--text);font-size:13px;font-weight:500;transition:background .12s;white-space:nowrap}
  45. .today-btn:hover{background:var(--hover)}
  46. .view-switcher{display:flex;background:var(--hover);border-radius:var(--r2);padding:2px;gap:1px}
  47. .view-btn{background:none;border:none;cursor:pointer;padding:4px 10px;border-radius:6px;font-size:13px;font-weight:500;color:var(--text2);transition:all .15s;white-space:nowrap}
  48. .view-btn.active{background:var(--bg);color:var(--text);box-shadow:0 1px 3px rgba(0,0,0,.14)}
  49. .tb-spacer{flex:1}
  50. .dark-toggle{background:none;border:1px solid var(--border);border-radius:var(--r2);cursor:pointer;padding:4px 9px;font-size:15px;color:var(--text);transition:background .12s;line-height:1}
  51. .dark-toggle:hover{background:var(--hover)}
  52. .add-btn{background:var(--accent);border:none;cursor:pointer;width:32px;height:32px;border-radius:50%;color:#fff;font-size:22px;display:flex;align-items:center;justify-content:center;transition:background .12s,transform .12s;flex-shrink:0}
  53. .add-btn:hover{background:var(--accent2);transform:scale(1.07)}
  54. /* MAIN */
  55. #main{display:grid;grid-template-columns:220px 1fr;overflow:hidden}
  56. /* SIDEBAR */
  57. #sidebar{border-right:1px solid var(--border);background:var(--bg2);display:flex;flex-direction:column;overflow-y:auto;overflow-x:hidden}
  58. .mini-cal{padding:12px 10px 6px}
  59. .mc-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
  60. .mc-title{font-size:13px;font-weight:600}
  61. .mc-nav{background:none;border:none;cursor:pointer;color:var(--accent);font-size:16px;padding:2px 6px;border-radius:4px;transition:background .12s}
  62. .mc-nav:hover{background:var(--hover2)}
  63. .mc-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:1px}
  64. .mc-dow{font-size:10px;text-align:center;color:var(--text3);font-weight:600;padding:2px 0}
  65. .mc-day{font-size:11px;text-align:center;padding:3px 0;border-radius:50%;cursor:pointer;aspect-ratio:1;display:flex;align-items:center;justify-content:center;transition:background .1s}
  66. .mc-day:hover{background:var(--hover2)}
  67. .mc-day.other{color:var(--text3)}
  68. .mc-day.today{background:var(--accent);color:#fff;font-weight:700}
  69. .mc-day.in-view{font-weight:600;color:var(--accent)}
  70. .mc-day.today.in-view{background:var(--accent);color:#fff}
  71. .sb-section{padding:8px 12px 12px;border-top:1px solid var(--border);margin-top:auto}
  72. .sb-section-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text3);margin-bottom:6px}
  73. .sb-btn{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:var(--r2);cursor:pointer;font-size:13px;color:var(--text);background:none;border:none;width:100%;text-align:left;transition:background .12s}
  74. .sb-btn:hover{background:var(--hover2)}
  75. /* VIEW AREA */
  76. #viewArea{overflow:hidden;display:flex;flex-direction:column;background:var(--bg)}
  77. /* MONTH VIEW */
  78. .month-view{display:flex;flex-direction:column;height:100%;overflow:hidden}
  79. .month-dow-hdr{display:grid;grid-template-columns:repeat(7,1fr);border-bottom:1px solid var(--border);flex-shrink:0}
  80. .month-dow{text-align:center;font-size:11px;font-weight:600;color:var(--text2);padding:8px 0;text-transform:uppercase;letter-spacing:.04em}
  81. .month-grid{flex:1;display:grid;grid-template-columns:repeat(7,1fr);grid-auto-rows:1fr;overflow:hidden}
  82. .month-cell{border-right:1px solid var(--border);border-bottom:1px solid var(--border);padding:4px;min-height:80px;cursor:pointer;overflow:hidden;display:flex;flex-direction:column;transition:background .1s}
  83. .month-cell:last-child,.month-cell:nth-child(7n){border-right:none}
  84. .month-cell:hover{background:var(--hover)}
  85. .month-cell.other-month .m-date{color:var(--text3)}
  86. .m-date{font-size:12px;font-weight:500;margin-bottom:2px;display:flex;justify-content:center}
  87. .m-date-inner{width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:50%}
  88. .month-cell.today .m-date-inner{background:var(--accent);color:#fff;font-weight:700}
  89. .month-pill{font-size:11px;padding:1px 6px;border-radius:4px;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
  90. .month-pill:hover{filter:brightness(.92)}
  91. .month-more{font-size:11px;color:var(--text2);padding:1px 4px;cursor:pointer}
  92. .month-more:hover{text-decoration:underline}
  93. /* WEEK/DAY VIEW */
  94. .week-view{display:flex;flex-direction:column;height:100%;overflow:hidden}
  95. .wk-hdr{display:flex;border-bottom:1px solid var(--border);flex-shrink:0}
  96. .wk-time-gutter{width:52px;min-width:52px;flex-shrink:0}
  97. .wk-day-hdrs{flex:1;display:grid}
  98. .wk-day-hdr{text-align:center;padding:8px 4px 6px;border-left:1px solid var(--border);cursor:pointer}
  99. .wk-dn{font-size:11px;font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:.04em}
  100. .wk-dd{font-size:22px;font-weight:300;line-height:1.15;color:var(--text)}
  101. .wk-dd.is-today{width:34px;height:34px;background:var(--accent);color:#fff;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto;font-weight:600;font-size:18px}
  102. .allday-row{display:flex;border-bottom:2px solid var(--border);flex-shrink:0;min-height:28px}
  103. .allday-lbl{width:52px;min-width:52px;font-size:10px;color:var(--text3);text-align:right;padding:6px 8px 0 0;flex-shrink:0}
  104. .allday-cells{flex:1;display:grid}
  105. .allday-cell{border-left:1px solid var(--border);padding:2px 4px;min-height:28px}
  106. .allday-pill{font-size:11px;padding:2px 6px;border-radius:4px;margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}
  107. .allday-pill:hover{filter:brightness(.92)}
  108. .grid-scroll{flex:1;overflow-y:auto;overflow-x:hidden;position:relative}
  109. .grid-content{display:flex;position:relative}
  110. .time-labels{width:52px;min-width:52px;flex-shrink:0;position:relative}
  111. .t-lbl{position:absolute;right:8px;font-size:10px;color:var(--text3);transform:translateY(-50%);pointer-events:none;white-space:nowrap}
  112. .day-columns{flex:1;display:grid;position:relative}
  113. .day-col{position:relative;border-left:1px solid var(--border);cursor:crosshair}
  114. .hr-line{position:absolute;left:0;right:0;border-top:1px solid var(--border);pointer-events:none}
  115. .hh-line{position:absolute;left:0;right:0;border-top:1px dashed var(--border);opacity:.45;pointer-events:none}
  116. .now-bar{position:absolute;left:-1px;right:0;pointer-events:none;z-index:10}
  117. .now-bar::before{content:'';position:absolute;left:0;top:-4px;width:8px;height:8px;background:#ff3b30;border-radius:50%}
  118. .now-bar::after{content:'';position:absolute;left:7px;right:0;top:-1px;height:2px;background:#ff3b30}
  119. .cal-event{position:absolute;border-radius:5px;padding:3px 6px;font-size:11px;overflow:hidden;cursor:pointer;border-left:3px solid;user-select:none;z-index:2;transition:filter .1s,box-shadow .1s}
  120. .cal-event:hover{box-shadow:0 2px 8px rgba(0,0,0,.2)}
  121. .cal-event.dragging{opacity:.4;pointer-events:none}
  122. .ev-title{font-weight:600;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  123. .ev-time{font-size:10px;opacity:.75;white-space:nowrap}
  124. /* Drag-to-create selection */
  125. .create-sel{position:absolute;left:2px;right:2px;z-index:5;border-radius:5px;pointer-events:none;opacity:.7;background:var(--ev-blue-bg);border-left:3px solid var(--ev-blue-bd);color:var(--ev-blue-tx);font-size:11px;font-weight:600;padding:3px 6px}
  126. /* Drop ghost for event move */
  127. .drop-ghost{position:absolute;border-radius:5px;padding:3px 6px;border-left:3px solid;pointer-events:none;opacity:.65;z-index:20;display:none;font-size:11px;font-weight:600}
  128. /* MODAL */
  129. .modal-back{position:fixed;inset:0;background:rgba(0,0,0,.38);display:flex;align-items:center;justify-content:center;z-index:1000;backdrop-filter:blur(4px)}
  130. .modal-back.hidden{display:none}
  131. .modal{background:var(--bg);border-radius:16px;box-shadow:var(--shadow);width:420px;max-width:calc(100vw - 32px);max-height:calc(100vh - 60px);overflow-y:auto;overflow-x:hidden}
  132. .modal-hdr{padding:18px 20px 6px;display:flex;align-items:center;gap:8px}
  133. .ev-color-dot{width:12px;height:12px;border-radius:50%;flex-shrink:0}
  134. .modal-title-inp{font-size:20px;font-weight:600;border:none;background:transparent;color:var(--text);width:100%;outline:none;font-family:inherit}
  135. .modal-title-inp::placeholder{color:var(--text3)}
  136. .modal-body{padding:4px 20px 4px}
  137. .f-row{display:flex;align-items:flex-start;gap:10px;padding:9px 0;border-bottom:1px solid var(--border)}
  138. .f-row:last-child{border-bottom:none}
  139. .f-icon{width:20px;flex-shrink:0;color:var(--text3);margin-top:2px;display:flex;align-items:center;justify-content:center}
  140. .f-body{flex:1;display:flex;flex-direction:column;gap:5px}
  141. .f-row-h{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--border)}
  142. /* Toggle */
  143. .toggle-wrap{display:flex;align-items:center;justify-content:space-between;width:100%}
  144. .toggle-lbl{font-size:13px;color:var(--text)}
  145. .toggle{position:relative;width:38px;height:22px;flex-shrink:0}
  146. .toggle input{display:none}
  147. .toggle-track{display:block;width:38px;height:22px;border-radius:11px;background:var(--border);cursor:pointer;transition:background .2s;position:relative}
  148. .toggle-track::after{content:'';position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#fff;transition:transform .2s;box-shadow:0 1px 3px rgba(0,0,0,.25)}
  149. .toggle input:checked+.toggle-track{background:var(--accent)}
  150. .toggle input:checked+.toggle-track::after{transform:translateX(16px)}
  151. /* From/To date+time rows */
  152. .ft-row{display:flex;align-items:center;gap:6px;margin-bottom:5px}
  153. .ft-row:last-child{margin-bottom:0}
  154. .ft-lbl{font-size:11px;font-weight:600;color:var(--text3);width:28px;flex-shrink:0;text-align:right}
  155. .date-fld,.time-fld{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r2);padding:5px 9px;font-size:12px;color:var(--text);cursor:pointer;transition:border-color .12s;white-space:nowrap;user-select:none;display:inline-block}
  156. .date-fld:hover,.time-fld:hover,.date-fld.open,.time-fld.open{border-color:var(--accent)}
  157. .dt-sep{font-size:13px;color:var(--text3)}
  158. /* Text inputs */
  159. .text-inp,.ta-inp{background:transparent;border:none;padding:0;font-size:13px;color:var(--text);width:100%;outline:none;font-family:inherit}
  160. .ta-inp{resize:none;min-height:54px;line-height:1.55}
  161. .text-inp::placeholder,.ta-inp::placeholder{color:var(--text3)}
  162. .sel-inp{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r2);padding:6px 10px;font-size:13px;color:var(--text);font-family:inherit;outline:none;cursor:pointer;width:100%}
  163. .color-swatches{display:flex;gap:10px;flex-wrap:wrap;padding:2px 0}
  164. .cswatch{width:22px;height:22px;border-radius:50%;cursor:pointer;transition:transform .1s;border:2px solid transparent}
  165. .cswatch:hover{transform:scale(1.18)}
  166. .cswatch.sel{border-color:var(--text);transform:scale(1.1)}
  167. /* Time validation error */
  168. .time-err{font-size:11px;color:#ff3b30;display:none;margin-top:2px}
  169. /* Footer */
  170. .modal-foot{padding:10px 20px 16px;display:flex;align-items:center;gap:8px}
  171. .del-btn{background:none;border:1px solid #ff3b30;border-radius:var(--r2);color:#ff3b30;padding:7px 14px;font-size:13px;cursor:pointer;transition:background .12s}
  172. .del-btn:hover{background:rgba(255,59,48,.1)}
  173. .mf-spacer{flex:1}
  174. .cancel-btn{background:none;border:1px solid var(--border);border-radius:var(--r2);color:var(--text);padding:7px 14px;font-size:13px;cursor:pointer;transition:background .12s}
  175. .cancel-btn:hover{background:var(--hover)}
  176. .save-btn{background:var(--accent);border:none;border-radius:var(--r2);color:#fff;padding:7px 18px;font-size:13px;font-weight:600;cursor:pointer;transition:background .12s}
  177. .save-btn:hover{background:var(--accent2)}
  178. /* TIME PICKER POPUP */
  179. #timePicker{position:fixed;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow);z-index:2100;width:148px;max-height:210px;overflow-y:auto;display:none}
  180. .tp-opt{padding:7px 14px;font-size:13px;cursor:pointer;transition:background .08s;color:var(--text)}
  181. .tp-opt:hover{background:var(--hover2)}
  182. .tp-opt.sel{background:var(--accent);color:#fff;border-radius:6px}
  183. /* DATE PICKER POPUP */
  184. #datePicker{position:fixed;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow);z-index:2100;padding:10px;width:228px;display:none}
  185. .dp-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
  186. .dp-title{font-size:13px;font-weight:600}
  187. .dp-nav{background:none;border:none;cursor:pointer;color:var(--accent);font-size:16px;padding:2px 6px;border-radius:4px}
  188. .dp-nav:hover{background:var(--hover2)}
  189. .dp-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:1px}
  190. .dp-dow{font-size:10px;text-align:center;color:var(--text3);font-weight:600;padding:2px 0}
  191. .dp-day{font-size:12px;text-align:center;padding:4px 0;border-radius:50%;cursor:pointer;aspect-ratio:1;display:flex;align-items:center;justify-content:center;transition:background .08s}
  192. .dp-day:hover{background:var(--hover2)}
  193. .dp-day.other{color:var(--text3)}
  194. .dp-day.today{background:var(--accent);color:#fff;font-weight:700}
  195. .dp-day.picked{background:var(--hover2);font-weight:600}
  196. .dp-day.today.picked{background:var(--accent);color:#fff}
  197. /* SCROLLBARS */
  198. ::-webkit-scrollbar{width:4px;height:4px}
  199. ::-webkit-scrollbar-track{background:transparent}
  200. ::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
  201. /* SNACKBAR */
  202. #snackbar{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(12px);padding:8px 18px;border-radius:10px;font-size:13px;opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;z-index:9999;background:var(--text);color:var(--bg);white-space:nowrap}
  203. #snackbar.show{opacity:1;transform:translateX(-50%) translateY(0)}
  204. /* SIDEBAR BACKDROP (mobile off-canvas) */
  205. #sidebarScrim{display:none}
  206. /* ── MOBILE / RESPONSIVE ─────────────────────────────────────────────── */
  207. @media (max-width:768px){
  208. /* Toolbar: drop the title, let it wrap so the view switcher gets its own row */
  209. body{grid-template-rows:auto 1fr}
  210. #toolbar{flex-wrap:wrap;padding:6px 10px;gap:5px 6px}
  211. .app-title{display:none}
  212. .menu-btn{display:inline-flex}
  213. .period-label{flex:1;min-width:0;font-size:14px;text-align:left}
  214. .tb-spacer{display:none}
  215. /* The view switcher wraps to a full-width second row */
  216. .view-switcher{order:1;flex:1 1 100%}
  217. .view-btn{flex:1;text-align:center}
  218. .tb-btn{font-size:21px;padding:5px 6px}
  219. /* Main area becomes single-column; sidebar slides in over the content */
  220. #main{grid-template-columns:1fr}
  221. #sidebar{position:fixed;top:0;left:0;z-index:40;height:100vh;height:100dvh;width:80vw;max-width:280px;transform:translateX(-100%);transition:transform .22s ease;box-shadow:var(--shadow)}
  222. #sidebar.open{transform:translateX(0)}
  223. #sidebarScrim{display:block;position:fixed;inset:0;z-index:39;background:rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:opacity .2s}
  224. #sidebarScrim.open{opacity:1;pointer-events:auto}
  225. /* Roomier cells / tap targets */
  226. .month-cell{min-height:0;padding:3px}
  227. .wk-dd{font-size:18px}
  228. .sb-btn{padding:9px 8px}
  229. .modal{width:100%;max-width:calc(100vw - 24px)}
  230. }
  231. </style>
  232. </head>
  233. <body>
  234. <header id="toolbar">
  235. <button class="menu-btn" title="Menu" onclick="toggleSidebar()">
  236. <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
  237. </button>
  238. <span class="app-title" locale="calendar/app/title">Calendar</span>
  239. <button class="today-btn" onclick="goToday()" locale="calendar/btn/today">Today</button>
  240. <button class="tb-btn" onclick="navPrev()">&#8249;</button>
  241. <span class="period-label" id="periodLabel"></span>
  242. <button class="tb-btn" onclick="navNext()">&#8250;</button>
  243. <div class="view-switcher">
  244. <button class="view-btn" data-view="day" onclick="switchView('day')" locale="calendar/view/day">Day</button>
  245. <button class="view-btn" data-view="workweek" onclick="switchView('workweek')" locale="calendar/view/workweek">Work Week</button>
  246. <button class="view-btn" data-view="week" onclick="switchView('week')" locale="calendar/view/week">Week</button>
  247. <button class="view-btn" data-view="month" onclick="switchView('month')" locale="calendar/view/month">Month</button>
  248. </div>
  249. <div class="tb-spacer"></div>
  250. <button class="dark-toggle" id="darkToggle" onclick="toggleDark()" title="Toggle dark mode">
  251. <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
  252. </button>
  253. <button class="add-btn" title="New Event" onclick="openModal(null,null)">+</button>
  254. </header>
  255. <div id="main">
  256. <aside id="sidebar">
  257. <div class="mini-cal" id="miniCal"></div>
  258. <div class="sb-section">
  259. <div class="sb-section-title" locale="calendar/sidebar/section-title">Import / Export</div>
  260. <button class="sb-btn" onclick="importFromComputer()" title="Import from Computer">
  261. <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
  262. <span locale="calendar/sidebar/import-computer">Import from Computer</span>
  263. </button>
  264. <button class="sb-btn" onclick="importFromFiles()" title="Import from Files">
  265. <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
  266. <span locale="calendar/sidebar/import-files">Import from Files</span>
  267. </button>
  268. <button class="sb-btn" onclick="exportICS()" title="Export as .ics">
  269. <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
  270. <span locale="calendar/sidebar/export-ics">Export as .ics</span>
  271. </button>
  272. </div>
  273. </aside>
  274. <div id="viewArea"></div>
  275. </div>
  276. <!-- Backdrop for the mobile off-canvas sidebar -->
  277. <div id="sidebarScrim" onclick="closeSidebar()"></div>
  278. <!-- Event Modal -->
  279. <div class="modal-back hidden" id="eventModal">
  280. <div class="modal">
  281. <div class="modal-hdr">
  282. <span class="ev-color-dot" id="modalColorDot"></span>
  283. <input class="modal-title-inp" id="evTitle" placeholder="New Event" autocomplete="off">
  284. </div>
  285. <div class="modal-body">
  286. <!-- All-day toggle -->
  287. <div class="f-row-h" style="border-bottom:1px solid var(--border);padding:9px 0">
  288. <span class="f-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 15"/></svg></span>
  289. <div class="toggle-wrap">
  290. <span class="toggle-lbl" locale="calendar/modal/allday">All-day</span>
  291. <label class="toggle"><input type="checkbox" id="evAllDay" onchange="onAllDayChange()"><span class="toggle-track"></span></label>
  292. </div>
  293. </div>
  294. <!-- Date / time — timed -->
  295. <div class="f-row" id="dtRowTimed">
  296. <span class="f-icon" style="margin-top:6px"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>
  297. <div class="f-body">
  298. <div class="ft-row">
  299. <span class="ft-lbl" locale="calendar/modal/from">From</span>
  300. <span class="date-fld" id="startDateFld" onclick="openDatePicker('start',this)"></span>
  301. <span class="time-fld" id="startTimeFld" onclick="openTimePicker('start',this)"></span>
  302. </div>
  303. <div class="ft-row">
  304. <span class="ft-lbl" locale="calendar/modal/to">To</span>
  305. <span class="date-fld" id="endDateFld" onclick="openDatePicker('end',this)"></span>
  306. <span class="time-fld" id="endTimeFld" onclick="openTimePicker('end',this)"></span>
  307. </div>
  308. <div class="time-err" id="timeErr" locale="calendar/modal/time-err">End time must be after start time</div>
  309. </div>
  310. </div>
  311. <!-- Date — all-day -->
  312. <div class="f-row" id="dtRowAllDay" style="display:none">
  313. <span class="f-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>
  314. <div class="f-body">
  315. <div class="ft-row">
  316. <span class="ft-lbl" locale="calendar/modal/date">Date</span>
  317. <span class="date-fld" id="adStartFld" onclick="openDatePicker('start',this)"></span>
  318. </div>
  319. </div>
  320. </div>
  321. <!-- Address -->
  322. <div class="f-row">
  323. <span class="f-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span>
  324. <div class="f-body"><input class="text-inp" id="evAddress" placeholder="Add location" autocomplete="off"></div>
  325. </div>
  326. <!-- Notes -->
  327. <div class="f-row">
  328. <span class="f-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
  329. <div class="f-body"><textarea class="ta-inp" id="evNotes" placeholder="Add notes" rows="3"></textarea></div>
  330. </div>
  331. <!-- Reminder -->
  332. <div class="f-row">
  333. <span class="f-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg></span>
  334. <div class="f-body">
  335. <select class="sel-inp" id="evReminder">
  336. <option value="" locale="calendar/reminder/none">None</option>
  337. <option value="5:mins" locale="calendar/reminder/5min">5 minutes before</option>
  338. <option value="10:mins" locale="calendar/reminder/10min">10 minutes before</option>
  339. <option value="15:mins" locale="calendar/reminder/15min">15 minutes before</option>
  340. <option value="30:mins" locale="calendar/reminder/30min">30 minutes before</option>
  341. <option value="1:hours" locale="calendar/reminder/1hour">1 hour before</option>
  342. <option value="2:hours" locale="calendar/reminder/2hours">2 hours before</option>
  343. <option value="1:days" locale="calendar/reminder/1day">1 day before</option>
  344. <option value="2:days" locale="calendar/reminder/2days">2 days before</option>
  345. <option value="7:days" locale="calendar/reminder/1week">1 week before</option>
  346. </select>
  347. </div>
  348. </div>
  349. <!-- Color -->
  350. <div class="f-row" style="border-bottom:none">
  351. <span class="f-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/></svg></span>
  352. <div class="f-body"><div class="color-swatches" id="colorSwatches"></div></div>
  353. </div>
  354. </div>
  355. <div class="modal-foot">
  356. <button class="del-btn hidden" id="deleteEvBtn" onclick="deleteCurrentEvent()" locale="calendar/modal/delete">Delete</button>
  357. <div class="mf-spacer"></div>
  358. <button class="cancel-btn" onclick="closeModal()" locale="calendar/modal/cancel">Cancel</button>
  359. <button class="save-btn" onclick="saveEvent()" locale="calendar/modal/save">Save</button>
  360. </div>
  361. </div>
  362. </div>
  363. <div id="timePicker"></div>
  364. <div id="datePicker"></div>
  365. <div id="snackbar"></div>
  366. <script>
  367. // ── Config ────────────────────────────────────────────────────────────
  368. var SNAP_MINS = 15;
  369. var MIN_EV_H = 20;
  370. var COLORS = ['blue','red','orange','green','purple','teal'];
  371. var COLOR_HEX = {blue:'#3b82f6',red:'#ef4444',orange:'#f97316',green:'#22c55e',purple:'#a855f7',teal:'#14b8a6'};
  372. // These arrays are re-populated from locale data after applocale loads.
  373. var DAYS_SHORT = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
  374. var MONTHS_LONG = ['January','February','March','April','May','June','July','August','September','October','November','December'];
  375. var MONTHS_SHORT= ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  376. var LOC_AM = 'AM', LOC_PM = 'PM'; // overridden by locale
  377. // ── Locale helper ─────────────────────────────────────────────────────
  378. // Short-hand: returns translated string, or 'fallback' when locale unavailable.
  379. function L(key, fallback){
  380. return (typeof applocale !== 'undefined' && applocale) ? applocale.getString(key, fallback) : fallback;
  381. }
  382. // Rebuild day/month name arrays and AM/PM strings from the loaded locale data.
  383. function applyLocaleData(){
  384. LOC_AM = L('calendar/time/am', 'AM');
  385. LOC_PM = L('calendar/time/pm', 'PM');
  386. DAYS_SHORT = [
  387. L('calendar/day/sun','Sun'), L('calendar/day/mon','Mon'), L('calendar/day/tue','Tue'),
  388. L('calendar/day/wed','Wed'), L('calendar/day/thu','Thu'), L('calendar/day/fri','Fri'),
  389. L('calendar/day/sat','Sat')
  390. ];
  391. MONTHS_LONG = [
  392. L('calendar/month/january','January'), L('calendar/month/february','February'),
  393. L('calendar/month/march','March'), L('calendar/month/april','April'),
  394. L('calendar/month/may','May'), L('calendar/month/june','June'),
  395. L('calendar/month/july','July'), L('calendar/month/august','August'),
  396. L('calendar/month/september','September'),L('calendar/month/october','October'),
  397. L('calendar/month/november','November'), L('calendar/month/december','December')
  398. ];
  399. MONTHS_SHORT = [
  400. L('calendar/month/jan','Jan'), L('calendar/month/feb','Feb'), L('calendar/month/mar','Mar'),
  401. L('calendar/month/apr','Apr'), L('calendar/month/may-short','May'), L('calendar/month/jun','Jun'),
  402. L('calendar/month/jul','Jul'), L('calendar/month/aug','Aug'), L('calendar/month/sep','Sep'),
  403. L('calendar/month/oct','Oct'), L('calendar/month/nov','Nov'), L('calendar/month/dec','Dec')
  404. ];
  405. }
  406. // ── State ─────────────────────────────────────────────────────────────
  407. var S = {
  408. view:'week', anchor:new Date(), events:[],
  409. editId:null, modalStart:null, modalEnd:null,
  410. pickerTarget:null, dpTarget:null,
  411. dragId:null, dragOffsetPx:0,
  412. gridScrollTop:null, // persists scroll between renders
  413. miniCalAnchor:new Date(), dark:false, nowTimer:null
  414. };
  415. // Drag-to-create state
  416. var CD = { active:false, col:null, dayMs:0, startY:0, currentY:0, selEl:null };
  417. // ── Date utils ────────────────────────────────────────────────────────
  418. function midnight(d){ var r=new Date(d); r.setHours(0,0,0,0); return r; }
  419. function addDays(d,n){ var r=new Date(d); r.setDate(r.getDate()+n); return r; }
  420. function addMinutes(d,n){ return new Date(d.getTime()+n*60000); }
  421. function sameDay(a,b){ return midnight(a).getTime()===midnight(b).getTime(); }
  422. function totalMinutes(d){ return d.getHours()*60+d.getMinutes(); }
  423. function startOfWeek(d){ var r=new Date(d); r.setDate(r.getDate()-r.getDay()); r.setHours(0,0,0,0); return r; }
  424. function startOfMonth(d){ return new Date(d.getFullYear(),d.getMonth(),1); }
  425. function endOfMonth(d){ return new Date(d.getFullYear(),d.getMonth()+1,0); }
  426. function fmt12(d){
  427. var h=d.getHours(),m=d.getMinutes(),ap=h<12?LOC_AM:LOC_PM;
  428. return (h%12||12)+':'+(m<10?'0':'')+m+' '+ap;
  429. }
  430. function fmtDateShort(d){
  431. return ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()]+', '+MONTHS_SHORT[d.getMonth()]+' '+d.getDate();
  432. }
  433. function fmtDateLong(d){
  434. return ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][d.getDay()]+
  435. ', '+MONTHS_LONG[d.getMonth()]+' '+d.getDate()+', '+d.getFullYear();
  436. }
  437. // ── Event colours ─────────────────────────────────────────────────────
  438. function evStyleVars(c){ c=c||'blue'; return {bg:'var(--ev-'+c+'-bg)',bd:'var(--ev-'+c+'-bd)',tx:'var(--ev-'+c+'-tx)'}; }
  439. function applyEvStyle(el,c){ var s=evStyleVars(c); el.style.background=s.bg; el.style.borderLeftColor=s.bd; el.style.color=s.tx; }
  440. // ── API ───────────────────────────────────────────────────────────────
  441. function apiLoadEvents(cb){
  442. ao_module_agirun('Calendar/backend/init.agi',{},function(d){
  443. try{ S.events=(typeof d==='string'?JSON.parse(d):d).events||[]; }catch(e){ S.events=[]; } cb();
  444. },function(){ S.events=[]; cb(); });
  445. }
  446. function apiSaveEvent(ev,cb){
  447. ao_module_agirun('Calendar/backend/saveEvent.agi',{eventData:JSON.stringify(ev)},function(){ cb&&cb(); },function(){ cb&&cb(); });
  448. }
  449. function apiSaveEvents(arr,cb){
  450. ao_module_agirun('Calendar/backend/saveEvents.agi',{eventsData:JSON.stringify(arr)},function(d){ cb&&cb(typeof d==='string'?JSON.parse(d):d); },function(){ cb&&cb(null); });
  451. }
  452. function apiDeleteEvent(id,cb){
  453. ao_module_agirun('Calendar/backend/deleteEvent.agi',{eventId:id},function(){ cb&&cb(); },function(){ cb&&cb(); });
  454. }
  455. function apiImportIcs(path,cb){
  456. ao_module_agirun('Calendar/backend/importIcs.agi',{filePath:path},function(d){ cb(typeof d==='string'?JSON.parse(d):d); },function(){ cb({error:'Request failed'}); });
  457. }
  458. // ── Snackbar ──────────────────────────────────────────────────────────
  459. var _sbT;
  460. function snack(msg){ var el=document.getElementById('snackbar'); el.textContent=msg; el.classList.add('show'); clearTimeout(_sbT); _sbT=setTimeout(function(){ el.classList.remove('show'); },2400); }
  461. // ── SVG icon strings (used by JS-generated content) ───────────────────
  462. var SVG_MOON='<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
  463. var SVG_SUN='<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
  464. // Small left-right arrows shown on events that span multiple days
  465. var SVG_MULTIDAY='<svg style="vertical-align:middle;margin-right:2px" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 8 22 12 17 16"/><polyline points="7 16 2 12 7 8"/><line x1="2" y1="12" x2="22" y2="12"/></svg>';
  466. // ── Dark mode ─────────────────────────────────────────────────────────
  467. function applyDark(dark){
  468. S.dark=dark;
  469. document.body.classList.toggle('dark',dark);
  470. // Use innerHTML so we can swap between moon/sun SVGs
  471. document.getElementById('darkToggle').innerHTML=dark?SVG_SUN:SVG_MOON;
  472. //if(typeof ao_module_setWindowTheme==='function') ao_module_setWindowTheme(dark?'dark':'white');
  473. }
  474. function toggleDark(){ applyDark(!S.dark); }
  475. // ── Mobile off-canvas sidebar (no-op on desktop where it's a static column) ──
  476. function toggleSidebar(){ document.getElementById('sidebar').classList.toggle('open'); document.getElementById('sidebarScrim').classList.toggle('open'); }
  477. function closeSidebar(){ document.getElementById('sidebar').classList.remove('open'); document.getElementById('sidebarScrim').classList.remove('open'); }
  478. // ── Mini calendar ─────────────────────────────────────────────────────
  479. function renderMiniCal(){
  480. var a=S.miniCalAnchor, y=a.getFullYear(), mo=a.getMonth();
  481. var dow=new Date(y,mo,1).getDay();
  482. var today=midnight(new Date()), viewSet={};
  483. getViewDays().forEach(function(d){ viewSet[midnight(d).getTime()]=1; });
  484. var html='<div class="mc-header"><button class="mc-nav" onclick="mcPrev()">&#8249;</button>'
  485. +'<span class="mc-title">'+MONTHS_SHORT[mo]+' '+y+'</span>'
  486. +'<button class="mc-nav" onclick="mcNext()">&#8250;</button></div><div class="mc-grid">';
  487. ['S','M','T','W','T','F','S'].forEach(function(d){ html+='<div class="mc-dow">'+d+'</div>'; });
  488. for(var i=0;i<dow;i++) html+='<div class="mc-day other"></div>';
  489. var dim=new Date(y,mo+1,0).getDate();
  490. for(var day=1;day<=dim;day++){
  491. var d=new Date(y,mo,day), cls='mc-day';
  492. if(sameDay(d,today)) cls+=' today';
  493. if(viewSet[midnight(d).getTime()]) cls+=' in-view';
  494. html+='<div class="'+cls+'" onclick="mcDayClick('+y+','+mo+','+day+')">'+day+'</div>';
  495. }
  496. html+='</div>';
  497. document.getElementById('miniCal').innerHTML=html;
  498. }
  499. function getViewDays(){
  500. var days=[], se=viewStartEnd();
  501. if(S.view==='month'){ var f=startOfMonth(S.anchor),l=endOfMonth(S.anchor); for(var d=new Date(f);d<=l;d=addDays(d,1)) days.push(new Date(d)); }
  502. else { for(var d=new Date(se.start);d<=se.end;d=addDays(d,1)) days.push(new Date(d)); }
  503. return days;
  504. }
  505. function mcPrev(){ S.miniCalAnchor=new Date(S.miniCalAnchor.getFullYear(),S.miniCalAnchor.getMonth()-1,1); renderMiniCal(); }
  506. function mcNext(){ S.miniCalAnchor=new Date(S.miniCalAnchor.getFullYear(),S.miniCalAnchor.getMonth()+1,1); renderMiniCal(); }
  507. function mcDayClick(y,mo,day){ S.anchor=new Date(y,mo,day); S.miniCalAnchor=new Date(y,mo,1); render(); closeSidebar(); }
  508. // ── Navigation ────────────────────────────────────────────────────────
  509. function goToday(){ S.anchor=new Date(); S.miniCalAnchor=new Date(S.anchor.getFullYear(),S.anchor.getMonth(),1); render(); }
  510. function navPrev(){
  511. if(S.view==='day') S.anchor=addDays(S.anchor,-1);
  512. else if(S.view==='workweek'||S.view==='week') S.anchor=addDays(S.anchor,-7);
  513. else S.anchor=new Date(S.anchor.getFullYear(),S.anchor.getMonth()-1,1);
  514. render();
  515. }
  516. function navNext(){
  517. if(S.view==='day') S.anchor=addDays(S.anchor,1);
  518. else if(S.view==='workweek'||S.view==='week') S.anchor=addDays(S.anchor,7);
  519. else S.anchor=new Date(S.anchor.getFullYear(),S.anchor.getMonth()+1,1);
  520. render();
  521. }
  522. function switchView(v){
  523. S.view=v;
  524. S.gridScrollTop=null; // fresh view always starts at 7 am
  525. document.querySelectorAll('.view-btn').forEach(function(b){ b.classList.toggle('active',b.dataset.view===v); });
  526. render();
  527. }
  528. function viewStartEnd(){
  529. if(S.view==='day') return {start:midnight(S.anchor),end:midnight(S.anchor)};
  530. if(S.view==='workweek'){
  531. var day=S.anchor.getDay(), mon=addDays(midnight(S.anchor),day===0?1:day===6?2:-(day-1));
  532. return {start:mon,end:addDays(mon,4)};
  533. }
  534. if(S.view==='week'){ var sun=startOfWeek(S.anchor); return {start:sun,end:addDays(sun,6)}; }
  535. return {start:startOfMonth(S.anchor),end:endOfMonth(S.anchor)};
  536. }
  537. function updatePeriodLabel(){
  538. var se=viewStartEnd(), lbl='';
  539. if(S.view==='day') lbl=fmtDateLong(S.anchor);
  540. else if(S.view==='month') lbl=MONTHS_LONG[S.anchor.getMonth()]+' '+S.anchor.getFullYear();
  541. else {
  542. var s=se.start,e=se.end;
  543. if(s.getMonth()===e.getMonth()) lbl=MONTHS_LONG[s.getMonth()]+' '+s.getFullYear();
  544. else if(s.getFullYear()===e.getFullYear()) lbl=MONTHS_SHORT[s.getMonth()]+' – '+MONTHS_SHORT[e.getMonth()]+' '+s.getFullYear();
  545. else lbl=MONTHS_SHORT[s.getMonth()]+' '+s.getFullYear()+' – '+MONTHS_SHORT[e.getMonth()]+' '+e.getFullYear();
  546. }
  547. document.getElementById('periodLabel').textContent=lbl;
  548. }
  549. // ── Main render ───────────────────────────────────────────────────────
  550. function render(){ updatePeriodLabel(); renderMiniCal(); if(S.view==='month') renderMonth(); else renderWeekView(); }
  551. // ── Month view ────────────────────────────────────────────────────────
  552. function renderMonth(){
  553. var y=S.anchor.getFullYear(), mo=S.anchor.getMonth();
  554. var startDow=new Date(y,mo,1).getDay(), daysInMo=new Date(y,mo+1,0).getDate();
  555. var today=midnight(new Date());
  556. var html='<div class="month-view"><div class="month-dow-hdr">';
  557. ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(function(d){ html+='<div class="month-dow">'+d+'</div>'; });
  558. html+='</div><div class="month-grid" id="monthGrid">';
  559. for(var i=0;i<startDow;i++){
  560. var pd=new Date(y,mo,1-startDow+i);
  561. html+='<div class="month-cell other-month" data-date="'+pd.toISOString()+'" ondragover="monthDragOver(event,this)" ondrop="monthDrop(event,this)" onclick="monthCellClick(event,this)">'
  562. +'<div class="m-date"><div class="m-date-inner">'+pd.getDate()+'</div></div></div>';
  563. }
  564. for(var day=1;day<=daysInMo;day++){
  565. var d=new Date(y,mo,day), isTod=sameDay(d,today);
  566. html+='<div class="month-cell'+(isTod?' today':'')
  567. +'" data-date="'+d.toISOString()+'" ondragover="monthDragOver(event,this)" ondrop="monthDrop(event,this)" onclick="monthCellClick(event,this)">'
  568. +'<div class="m-date"><div class="m-date-inner">'+day+'</div></div></div>';
  569. }
  570. var trailing=Math.ceil((startDow+daysInMo)/7)*7-(startDow+daysInMo);
  571. for(var i=1;i<=trailing;i++){
  572. var nd=new Date(y,mo+1,i);
  573. html+='<div class="month-cell other-month" data-date="'+nd.toISOString()+'" ondragover="monthDragOver(event,this)" ondrop="monthDrop(event,this)" onclick="monthCellClick(event,this)">'
  574. +'<div class="m-date"><div class="m-date-inner">'+nd.getDate()+'</div></div></div>';
  575. }
  576. html+='</div></div>';
  577. document.getElementById('viewArea').innerHTML=html;
  578. injectMonthEvents();
  579. }
  580. function injectMonthEvents(){
  581. var grid=document.getElementById('monthGrid'); if(!grid) return;
  582. var sorted=S.events.slice().sort(function(a,b){ return a.start-b.start; });
  583. grid.querySelectorAll('.month-cell').forEach(function(cell){
  584. var cellMs=midnight(new Date(cell.dataset.date)).getTime();
  585. var dayEvs=sorted.filter(function(ev){
  586. return sameDay(new Date(ev.start),new Date(cellMs));
  587. });
  588. dayEvs.slice(0,3).forEach(function(ev){
  589. var pill=document.createElement('div');
  590. pill.className='month-pill'; pill.dataset.evid=ev.id;
  591. applyEvStyle(pill,ev.color); pill.textContent=ev.title||'(no title)';
  592. pill.draggable=true;
  593. pill.addEventListener('dragstart',function(e){ e.stopPropagation(); evDragStart(e,ev.id,0); });
  594. pill.addEventListener('click',function(e){ e.stopPropagation(); openModal(ev,null); });
  595. cell.appendChild(pill);
  596. });
  597. if(dayEvs.length>3){ var m=document.createElement('div'); m.className='month-more'; m.textContent='+'+(dayEvs.length-3)+' more'; cell.appendChild(m); }
  598. });
  599. }
  600. function monthCellClick(e,cell){
  601. if(e.target.classList.contains('month-pill')||e.target.classList.contains('month-more')) return;
  602. var d=midnight(new Date(cell.dataset.date));
  603. openModal(null,{start:new Date(d.getFullYear(),d.getMonth(),d.getDate(),9,0,0),end:new Date(d.getFullYear(),d.getMonth(),d.getDate(),10,0,0)});
  604. }
  605. function monthDragOver(e,cell){ if(!S.dragId) return; e.preventDefault(); e.dataTransfer.dropEffect='move'; }
  606. function monthDrop(e,cell){
  607. e.preventDefault(); if(!S.dragId) return;
  608. var ev=S.events.find(function(x){ return x.id===S.dragId; }); if(!ev) return;
  609. var td=midnight(new Date(cell.dataset.date)), os=new Date(ev.start), dur=new Date(ev.end).getTime()-os.getTime();
  610. ev.start=new Date(td.getFullYear(),td.getMonth(),td.getDate(),os.getHours(),os.getMinutes(),0).getTime();
  611. ev.end=ev.start+dur;
  612. evDragEnd(); apiSaveEvent(ev,null); renderMonth();
  613. }
  614. // ── Week/Day view ─────────────────────────────────────────────────────
  615. function renderWeekView(){
  616. var numDays=S.view==='day'?1:S.view==='workweek'?5:7;
  617. var vStart=viewStartEnd().start;
  618. var days=[]; for(var i=0;i<numDays;i++) days.push(addDays(vStart,i));
  619. var today=midnight(new Date());
  620. // Header
  621. var hdr='<div class="wk-hdr"><div class="wk-time-gutter"></div><div class="wk-day-hdrs" style="grid-template-columns:repeat('+numDays+',1fr)">';
  622. days.forEach(function(d){
  623. var tod=sameDay(d,today);
  624. hdr+='<div class="wk-day-hdr" onclick="wkHdrClick(\''+d.toISOString()+'\')">'
  625. +'<div class="wk-dn">'+DAYS_SHORT[d.getDay()]+'</div>'
  626. +'<div class="wk-dd'+(tod?' is-today':'')+'">'+(tod?'<div style="width:34px;height:34px;background:var(--accent);color:#fff;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto;font-weight:600;font-size:18px">'+d.getDate()+'</div>':d.getDate())+'</div>'
  627. +'</div>';
  628. });
  629. hdr+='</div></div>';
  630. // All-day row
  631. var allDayEvMap={};
  632. var adr='<div class="allday-row"><div class="allday-lbl">'+L('calendar/allday-label','all-day')+'</div><div class="allday-cells" style="grid-template-columns:repeat('+numDays+',1fr)">';
  633. days.forEach(function(d){
  634. adr+='<div class="allday-cell">';
  635. S.events.filter(function(ev){ return ev.allDay&&sameDay(new Date(ev.start),d); }).forEach(function(ev){
  636. allDayEvMap[ev.id]=ev;
  637. adr+='<div class="allday-pill" data-evid="'+ev.id+'" style="background:var(--ev-'+(ev.color||'blue')+'-bg);border-left:3px solid var(--ev-'+(ev.color||'blue')+'-bd);color:var(--ev-'+(ev.color||'blue')+'-tx)">'
  638. +escHtml(ev.title||'(no title)')+'</div>';
  639. });
  640. adr+='</div>';
  641. });
  642. adr+='</div></div>';
  643. // Time labels
  644. var lbls='<div class="time-labels">';
  645. for(var h=0;h<24;h++) lbls+='<div class="t-lbl" style="top:'+(h*60)+'px">'+(h===0?'12 AM':h<12?h+' AM':h===12?'12 PM':(h-12)+' PM')+'</div>';
  646. lbls+='</div>';
  647. // Day columns grid
  648. var cols='<div class="day-columns" id="dayColumns" style="grid-template-columns:repeat('+numDays+',1fr);height:1440px">';
  649. days.forEach(function(d,i){
  650. var isToday=sameDay(d,today);
  651. cols+='<div class="day-col" id="dayCol'+i+'" data-dayidx="'+i+'" data-dayiso="'+d.toISOString()+'"'
  652. +' ondragover="wkDragOver(event,this)" ondrop="wkDrop(event,this)" ondragleave="wkDragLeave(event,this)">';
  653. for(var h=0;h<24;h++){
  654. cols+='<div class="hr-line" style="top:'+(h*60)+'px"></div>';
  655. if(h<23) cols+='<div class="hh-line" style="top:'+(h*60+30)+'px"></div>';
  656. }
  657. if(isToday){ var n=new Date(); cols+='<div class="now-bar" id="nowBar'+i+'" style="top:'+(n.getHours()*60+n.getMinutes())+'px"></div>'; }
  658. cols+='<div class="drop-ghost" id="ghost'+i+'"></div>';
  659. cols+='</div>';
  660. });
  661. cols+='</div>';
  662. var va=document.getElementById('viewArea');
  663. va.innerHTML='<div class="week-view">'+hdr+adr
  664. +'<div class="grid-scroll" id="gridScroll"><div class="grid-content" style="min-height:1440px">'+lbls
  665. +'<div style="flex:1;position:relative">'+cols+'</div></div></div></div>';
  666. // Bind allday pill clicks
  667. va.querySelectorAll('.allday-pill[data-evid]').forEach(function(pill){
  668. var ev=allDayEvMap[pill.dataset.evid];
  669. if(ev) pill.addEventListener('click',function(e){ e.stopPropagation(); openModal(ev,null); });
  670. });
  671. injectWeekEvents(days);
  672. setupColumnDragCreate(days);
  673. var gs=document.getElementById('gridScroll');
  674. if(gs){
  675. // Restore the user's scroll position; only default to 7 am on first ever render
  676. gs.scrollTop = (S.gridScrollTop !== null) ? S.gridScrollTop : 420;
  677. // Keep S.gridScrollTop in sync so future renders restore correctly
  678. gs.addEventListener('scroll', function(){ S.gridScrollTop = gs.scrollTop; }, {passive:true});
  679. }
  680. clearInterval(S.nowTimer);
  681. S.nowTimer=setInterval(function(){ var n=new Date(),top=n.getHours()*60+n.getMinutes(); document.querySelectorAll('[id^="nowBar"]').forEach(function(el){ el.style.top=top+'px'; }); },60000);
  682. }
  683. function injectWeekEvents(days){
  684. days.forEach(function(day,di){
  685. var col=document.getElementById('dayCol'+di); if(!col) return;
  686. var dayStartMs=midnight(day).getTime(), dayEndMs=dayStartMs+86400000;
  687. // Find events overlapping this day (handles multi-day events)
  688. var dayEvs=S.events.filter(function(ev){
  689. if(ev.allDay) return false;
  690. return new Date(ev.start).getTime()<dayEndMs && new Date(ev.end).getTime()>dayStartMs;
  691. });
  692. var laid=layoutEvents(dayEvs);
  693. laid.forEach(function(ev){
  694. // Clip to day bounds
  695. var clipStart=Math.max(new Date(ev.start).getTime(),dayStartMs);
  696. var clipEnd =Math.min(new Date(ev.end).getTime(), dayEndMs);
  697. var startMin =Math.floor((clipStart-dayStartMs)/60000);
  698. var endMin =Math.floor((clipEnd-dayStartMs)/60000);
  699. if(endMin===0) endMin=1440; // midnight end = bottom of day
  700. if(endMin<=startMin) endMin=startMin+30;
  701. var height=Math.max(endMin-startMin,MIN_EV_H);
  702. var leftPct=(ev._col/ev._totalCols)*100, widthPct=(1/ev._totalCols)*100-1;
  703. var isMultiDay=!sameDay(new Date(ev.start),new Date(ev.end));
  704. var el=document.createElement('div');
  705. el.className='cal-event'; el.dataset.evid=ev.id; el.draggable=true;
  706. el.style.cssText='top:'+startMin+'px;height:'+height+'px;left:'+leftPct+'%;width:'+widthPct+'%;';
  707. applyEvStyle(el,ev.color);
  708. var startD=new Date(ev.start);
  709. el.innerHTML='<div class="ev-title">'+(isMultiDay?SVG_MULTIDAY:'')+escHtml(ev.title||'(no title)')+'</div>'
  710. +(height>=40?'<div class="ev-time">'+fmt12(startD)+'</div>':'');
  711. el.addEventListener('click',function(e){ e.stopPropagation(); openModal(ev,null); });
  712. el.addEventListener('dragstart',function(e){
  713. var offsetPx=e.clientY-el.getBoundingClientRect().top;
  714. evDragStart(e,ev.id,Math.max(0,offsetPx));
  715. });
  716. col.appendChild(el);
  717. });
  718. });
  719. }
  720. function layoutEvents(evs){
  721. if(!evs.length) return evs;
  722. evs=evs.slice().sort(function(a,b){ return a.start-b.start; });
  723. var cols=[];
  724. evs.forEach(function(ev){
  725. ev._col=0; ev._totalCols=1;
  726. var placed=false;
  727. for(var c=0;c<cols.length;c++){
  728. if(cols[c][cols[c].length-1].end<=ev.start){ cols[c].push(ev); ev._col=c; placed=true; break; }
  729. }
  730. if(!placed){ cols.push([ev]); ev._col=cols.length-1; }
  731. });
  732. evs.forEach(function(ev){
  733. var mx=ev._col;
  734. evs.forEach(function(o){ if(o!==ev&&o.start<ev.end&&o.end>ev.start) mx=Math.max(mx,o._col); });
  735. ev._totalCols=mx+1;
  736. });
  737. return evs;
  738. }
  739. function wkHdrClick(iso){ S.anchor=midnight(new Date(iso)); if(S.view!=='day') switchView('day'); else render(); }
  740. // ── Drag-to-CREATE (mousedown on empty grid) ──────────────────────────
  741. function setupColumnDragCreate(days){
  742. days.forEach(function(day,di){
  743. var col=document.getElementById('dayCol'+di); if(!col) return;
  744. col.addEventListener('mousedown',function(e){
  745. if(e.button!==0) return;
  746. if(e.target.closest && e.target.closest('.cal-event,.drop-ghost')) return;
  747. if(e.target.classList.contains('cal-event')) return;
  748. e.preventDefault();
  749. var rect=col.getBoundingClientRect();
  750. // getBoundingClientRect already accounts for scroll — no scrollTop needed
  751. var startY=Math.max(0, e.clientY-rect.top);
  752. var sel=document.createElement('div'); sel.className='create-sel';
  753. col.appendChild(sel);
  754. CD={active:true,col:col,dayMs:midnight(day).getTime(),startY:startY,currentY:startY,selEl:sel};
  755. updateCreateSel();
  756. });
  757. });
  758. }
  759. function updateCreateSel(){
  760. if(!CD.selEl) return;
  761. var top=Math.min(CD.startY,CD.currentY), bot=Math.max(CD.startY,CD.currentY);
  762. var snTop=Math.round(top/SNAP_MINS)*SNAP_MINS, snBot=Math.round(bot/SNAP_MINS)*SNAP_MINS;
  763. snBot=Math.max(snBot,snTop+SNAP_MINS);
  764. CD.selEl.style.top=snTop+'px'; CD.selEl.style.height=(snBot-snTop)+'px';
  765. }
  766. document.addEventListener('mousemove',function(e){
  767. if(!CD.active) return;
  768. var rect=CD.col.getBoundingClientRect();
  769. CD.currentY=Math.max(0,e.clientY-rect.top);
  770. updateCreateSel();
  771. });
  772. document.addEventListener('mouseup',function(e){
  773. if(!CD.active) return;
  774. var rect=CD.col.getBoundingClientRect();
  775. var endY=Math.max(0,e.clientY-rect.top);
  776. var wasDrag=Math.abs(endY-CD.startY)>10;
  777. var top=Math.min(CD.startY,endY), bot=Math.max(CD.startY,endY);
  778. var startMin=Math.round(top/SNAP_MINS)*SNAP_MINS;
  779. var endMin =Math.round(bot/SNAP_MINS)*SNAP_MINS;
  780. if(!wasDrag||endMin<=startMin) endMin=startMin+60;
  781. endMin=Math.min(endMin,1440);
  782. if(CD.selEl&&CD.selEl.parentNode) CD.selEl.parentNode.removeChild(CD.selEl);
  783. var savedDay=CD.dayMs;
  784. CD={active:false,col:null,dayMs:0,startY:0,currentY:0,selEl:null};
  785. var base=new Date(savedDay);
  786. var start=new Date(base.getFullYear(),base.getMonth(),base.getDate(),Math.floor(startMin/60),startMin%60,0);
  787. var end =new Date(base.getFullYear(),base.getMonth(),base.getDate(),Math.floor(endMin/60), endMin%60, 0);
  788. openModal(null,{start:start,end:end});
  789. });
  790. // Cancel create-drag if HTML5 drag kicks in (user started on an event)
  791. document.addEventListener('dragstart',function(){
  792. if(CD.active){ if(CD.selEl&&CD.selEl.parentNode) CD.selEl.parentNode.removeChild(CD.selEl); CD.active=false; }
  793. });
  794. // ── Drag-to-MOVE existing event ───────────────────────────────────────
  795. // Edge-scroll state
  796. var _edgeST=null, _edgeDir=0;
  797. var EDGE_PX=60, EDGE_SPEED=12; // px from rim triggers scroll; px per frame
  798. function _startEdgeScroll(dir){
  799. if(_edgeDir===dir) return; // already scrolling this way
  800. _stopEdgeScroll();
  801. _edgeDir=dir;
  802. _edgeST=setInterval(function(){
  803. var gs=document.getElementById('gridScroll'); if(!gs) return;
  804. gs.scrollTop+=dir*EDGE_SPEED;
  805. S.gridScrollTop=gs.scrollTop; // keep state in sync
  806. },16); // ~60 fps
  807. }
  808. function _stopEdgeScroll(){
  809. if(_edgeST){ clearInterval(_edgeST); _edgeST=null; } _edgeDir=0;
  810. }
  811. function evDragStart(e,id,offsetPx){
  812. S.dragId=id; S.dragOffsetPx=offsetPx;
  813. e.dataTransfer.setData('text/plain',id); e.dataTransfer.effectAllowed='move';
  814. // Replace the browser's default drag image with an invisible 1×1 pixel element
  815. // positioned far off-screen. This stops the browser from auto-scrolling the
  816. // grid container to keep the drag image in view — our drop-ghost does the job.
  817. var phantom=document.createElement('div');
  818. phantom.style.cssText='position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;opacity:.01;pointer-events:none';
  819. document.body.appendChild(phantom);
  820. e.dataTransfer.setDragImage(phantom,0,0);
  821. setTimeout(function(){
  822. if(phantom.parentNode) phantom.parentNode.removeChild(phantom);
  823. document.querySelectorAll('[data-evid="'+id+'"]').forEach(function(el){ el.classList.add('dragging'); });
  824. },0);
  825. }
  826. function evDragEnd(){
  827. _stopEdgeScroll();
  828. S.dragId=null;
  829. document.querySelectorAll('.dragging').forEach(function(el){ el.classList.remove('dragging'); });
  830. document.querySelectorAll('.drop-ghost').forEach(function(g){ g.style.display='none'; });
  831. }
  832. function wkDragOver(e,col){
  833. if(!S.dragId) return;
  834. e.preventDefault(); e.dataTransfer.dropEffect='move';
  835. // ── Edge auto-scroll: scroll the grid when the pointer is near the rim ──
  836. var gs=document.getElementById('gridScroll');
  837. if(gs){
  838. var gr=gs.getBoundingClientRect();
  839. var dTop=e.clientY-gr.top, dBot=gr.bottom-e.clientY;
  840. if (dTop>0 && dTop<EDGE_PX) _startEdgeScroll(-1); // near top → scroll up
  841. else if(dBot>0 && dBot<EDGE_PX) _startEdgeScroll( 1); // near bottom → scroll down
  842. else _stopEdgeScroll();
  843. }
  844. // ── Update drop-ghost position ──
  845. var di=parseInt(col.dataset.dayidx);
  846. // getBoundingClientRect() is always in viewport space; no scrollTop adjustment needed
  847. var rect=col.getBoundingClientRect();
  848. var relY=(e.clientY-rect.top)-S.dragOffsetPx;
  849. var snapped=Math.round(relY/SNAP_MINS)*SNAP_MINS;
  850. snapped=Math.max(0,Math.min(snapped,23*60));
  851. var ev=S.events.find(function(x){ return x.id===S.dragId; }); if(!ev) return;
  852. var durMin=Math.round((new Date(ev.end).getTime()-new Date(ev.start).getTime())/60000);
  853. document.querySelectorAll('.drop-ghost').forEach(function(g){
  854. var gdi=parseInt(g.id.replace('ghost',''));
  855. if(gdi===di){
  856. var s=evStyleVars(ev.color||'blue');
  857. g.style.cssText='display:block;top:'+snapped+'px;height:'+Math.max(durMin,MIN_EV_H)+'px;left:2px;right:2px;width:auto;background:'+s.bg+';border-left-color:'+s.bd+';color:'+s.tx;
  858. g.textContent=ev.title||'(no title)';
  859. } else { g.style.display='none'; }
  860. });
  861. }
  862. function wkDragLeave(e,col){
  863. if(!e.relatedTarget||!col.contains(e.relatedTarget)){
  864. var di=parseInt(col.dataset.dayidx);
  865. var g=document.getElementById('ghost'+di); if(g) g.style.display='none';
  866. }
  867. }
  868. function wkDrop(e,col){
  869. e.preventDefault(); if(!S.dragId) return;
  870. _stopEdgeScroll(); // stop any edge-scroll immediately on drop
  871. var ev=S.events.find(function(x){ return x.id===S.dragId; });
  872. if(!ev){ evDragEnd(); return; }
  873. var di=parseInt(col.dataset.dayidx);
  874. var rect=col.getBoundingClientRect();
  875. var relY=(e.clientY-rect.top)-S.dragOffsetPx;
  876. var snapped=Math.round(relY/SNAP_MINS)*SNAP_MINS;
  877. snapped=Math.max(0,Math.min(snapped,23*60));
  878. var dur=new Date(ev.end).getTime()-new Date(ev.start).getTime();
  879. var targetDay=addDays(viewStartEnd().start,di);
  880. var newStart=new Date(targetDay.getFullYear(),targetDay.getMonth(),targetDay.getDate(),Math.floor(snapped/60),snapped%60,0);
  881. ev.start=newStart.getTime(); ev.end=newStart.getTime()+dur;
  882. // Capture the scroll position *before* render() rebuilds the DOM.
  883. // renderWeekView() will restore this value instead of jumping to 7 am.
  884. var gs=document.getElementById('gridScroll');
  885. if(gs) S.gridScrollTop=gs.scrollTop;
  886. evDragEnd(); apiSaveEvent(ev,null); render();
  887. }
  888. document.addEventListener('dragend',evDragEnd);
  889. // ── Time picker ───────────────────────────────────────────────────────
  890. var TIME_OPTS=(function(){
  891. var o=[];
  892. for(var h=0;h<24;h++) for(var m=0;m<60;m+=15) o.push({label:(h%12||12)+':'+(m<10?'0':'')+m+' '+(h<12?'AM':'PM'),h:h,m:m});
  893. return o;
  894. })();
  895. function openTimePicker(target,anchorEl){
  896. closeAllPopups(); S.pickerTarget=target;
  897. anchorEl.classList.add('open');
  898. var ref=target==='start'?S.modalStart:S.modalEnd;
  899. var curH=ref?ref.getHours():9, curM=ref?ref.getMinutes():0;
  900. var tp=document.getElementById('timePicker');
  901. tp.innerHTML=TIME_OPTS.map(function(o,i){
  902. return '<div class="tp-opt'+(o.h===curH&&o.m===curM?' sel':'')+'" data-idx="'+i+'">'+o.label+'</div>';
  903. }).join('');
  904. tp.style.display='block';
  905. var rect=anchorEl.getBoundingClientRect();
  906. var top=rect.bottom+4, left=rect.left;
  907. if(left+160>window.innerWidth) left=window.innerWidth-168;
  908. if(top+220>window.innerHeight) top=rect.top-224;
  909. tp.style.top=top+'px'; tp.style.left=left+'px';
  910. var sel=tp.querySelector('.sel'); if(sel) sel.scrollIntoView({block:'center'});
  911. tp.querySelectorAll('.tp-opt').forEach(function(opt){
  912. opt.addEventListener('click',function(){
  913. var o=TIME_OPTS[parseInt(opt.dataset.idx)];
  914. if(S.pickerTarget==='start'){
  915. var d=S.modalStart||new Date();
  916. S.modalStart=new Date(d.getFullYear(),d.getMonth(),d.getDate(),o.h,o.m,0);
  917. // If end is now before or equal to start, bump end date to start+1 day keeping time
  918. if(S.modalEnd&&S.modalEnd.getTime()<=S.modalStart.getTime()){
  919. S.modalEnd=new Date(S.modalStart.getFullYear(),S.modalStart.getMonth(),S.modalStart.getDate()+1,S.modalEnd.getHours(),S.modalEnd.getMinutes(),0);
  920. }
  921. } else {
  922. var d=S.modalEnd||new Date();
  923. S.modalEnd=new Date(d.getFullYear(),d.getMonth(),d.getDate(),o.h,o.m,0);
  924. // If end ≤ start, move end to next day (allows overnight events)
  925. if(S.modalStart&&S.modalEnd.getTime()<=S.modalStart.getTime()){
  926. S.modalEnd=new Date(S.modalEnd.getTime()+86400000);
  927. }
  928. }
  929. refreshDateTimeFields();
  930. validateTimes();
  931. closeAllPopups();
  932. });
  933. });
  934. }
  935. // ── Date picker ───────────────────────────────────────────────────────
  936. var dpYear, dpMonth;
  937. function openDatePicker(target,anchorEl){
  938. closeAllPopups(); S.dpTarget=target;
  939. anchorEl.classList.add('open');
  940. var ref=target==='start'?S.modalStart:S.modalEnd;
  941. if(!ref) ref=new Date();
  942. dpYear=ref.getFullYear(); dpMonth=ref.getMonth();
  943. renderDatePicker(); var dp=document.getElementById('datePicker');
  944. dp.style.display='block';
  945. var rect=anchorEl.getBoundingClientRect(), top=rect.bottom+4, left=rect.left;
  946. if(left+230>window.innerWidth) left=window.innerWidth-238;
  947. if(top+260>window.innerHeight) top=rect.top-264;
  948. dp.style.top=top+'px'; dp.style.left=left+'px';
  949. }
  950. function renderDatePicker(){
  951. var pickedMs=(S.dpTarget==='start'?(S.modalStart?midnight(S.modalStart).getTime():0):(S.modalEnd?midnight(S.modalEnd).getTime():0));
  952. var dim=new Date(dpYear,dpMonth+1,0).getDate(), dow=new Date(dpYear,dpMonth,1).getDay(), todMs=midnight(new Date()).getTime();
  953. var html='<div class="dp-hdr"><button class="dp-nav" onclick="dpNav(-1)">&#8249;</button><span class="dp-title">'+MONTHS_SHORT[dpMonth]+' '+dpYear+'</span><button class="dp-nav" onclick="dpNav(1)">&#8250;</button></div><div class="dp-grid">';
  954. ['S','M','T','W','T','F','S'].forEach(function(d){ html+='<div class="dp-dow">'+d+'</div>'; });
  955. for(var i=0;i<dow;i++) html+='<div class="dp-day other"></div>';
  956. for(var day=1;day<=dim;day++){
  957. var ms=new Date(dpYear,dpMonth,day).setHours(0,0,0,0);
  958. html+='<div class="dp-day'+(ms===todMs?' today':'')+(ms===pickedMs?' picked':'')+'" onclick="dpSelect('+dpYear+','+dpMonth+','+day+')">'+day+'</div>';
  959. }
  960. html+='</div>';
  961. document.getElementById('datePicker').innerHTML=html;
  962. }
  963. function dpNav(dir){ dpMonth+=dir; if(dpMonth<0){dpMonth=11;dpYear--;} if(dpMonth>11){dpMonth=0;dpYear++;} renderDatePicker(); }
  964. function dpSelect(y,mo,day){
  965. if(S.dpTarget==='start'){
  966. var h=S.modalStart?S.modalStart.getHours():9, m=S.modalStart?S.modalStart.getMinutes():0;
  967. var prev=S.modalStart?midnight(S.modalStart).getTime():0;
  968. S.modalStart=new Date(y,mo,day,h,m,0);
  969. // Shift end date by the same delta if start date changed
  970. if(S.modalEnd){
  971. var delta=midnight(S.modalStart).getTime()-prev;
  972. if(delta!==0) S.modalEnd=new Date(S.modalEnd.getTime()+delta);
  973. }
  974. } else {
  975. var h=S.modalEnd?S.modalEnd.getHours():10, m=S.modalEnd?S.modalEnd.getMinutes():0;
  976. S.modalEnd=new Date(y,mo,day,h,m,0);
  977. // Ensure end is not before start
  978. if(S.modalStart&&S.modalEnd.getTime()<=S.modalStart.getTime()){
  979. S.modalEnd=new Date(S.modalStart.getTime()+3600000);
  980. }
  981. }
  982. refreshDateTimeFields(); validateTimes(); closeAllPopups();
  983. }
  984. function closeAllPopups(){
  985. document.getElementById('timePicker').style.display='none';
  986. document.getElementById('datePicker').style.display='none';
  987. document.querySelectorAll('.date-fld,.time-fld').forEach(function(el){ el.classList.remove('open'); });
  988. }
  989. document.addEventListener('click',function(e){
  990. var tp=document.getElementById('timePicker'),dp=document.getElementById('datePicker');
  991. if(!tp.contains(e.target)&&!dp.contains(e.target)&&!e.target.classList.contains('time-fld')&&!e.target.classList.contains('date-fld')){
  992. if(tp.style.display!=='none'||dp.style.display!=='none') closeAllPopups();
  993. }
  994. },true);
  995. // ── Event modal ───────────────────────────────────────────────────────
  996. function buildColorSwatches(sel){ sel=sel||'blue'; document.getElementById('colorSwatches').innerHTML=COLORS.map(function(c){ return '<div class="cswatch'+(c===sel?' sel':'')+'" data-color="'+c+'" style="background:'+COLOR_HEX[c]+'" onclick="selectColor(\''+c+'\')"></div>'; }).join(''); updateColorDot(sel); }
  997. function selectColor(c){ document.querySelectorAll('.cswatch').forEach(function(el){ el.classList.toggle('sel',el.dataset.color===c); }); updateColorDot(c); }
  998. function updateColorDot(c){ document.getElementById('modalColorDot').style.background=COLOR_HEX[c]||COLOR_HEX.blue; }
  999. function getSelectedColor(){ var s=document.querySelector('.cswatch.sel'); return s?s.dataset.color:'blue'; }
  1000. function refreshDateTimeFields(){
  1001. var sdf=document.getElementById('startDateFld'), stf=document.getElementById('startTimeFld');
  1002. var edf=document.getElementById('endDateFld'), etf=document.getElementById('endTimeFld');
  1003. var adf=document.getElementById('adStartFld');
  1004. if(S.modalStart){
  1005. if(sdf) sdf.textContent=fmtDateShort(S.modalStart);
  1006. if(stf) stf.textContent=fmt12(S.modalStart);
  1007. if(adf) adf.textContent=fmtDateShort(S.modalStart);
  1008. }
  1009. if(S.modalEnd){
  1010. if(edf) edf.textContent=fmtDateShort(S.modalEnd);
  1011. if(etf) etf.textContent=fmt12(S.modalEnd);
  1012. }
  1013. }
  1014. function validateTimes(){
  1015. var errEl=document.getElementById('timeErr'); if(!errEl) return;
  1016. var allDay=document.getElementById('evAllDay').checked;
  1017. var bad=!allDay&&S.modalStart&&S.modalEnd&&S.modalEnd.getTime()<=S.modalStart.getTime();
  1018. errEl.style.display=bad?'block':'none';
  1019. }
  1020. function onAllDayChange(){
  1021. var ad=document.getElementById('evAllDay').checked;
  1022. document.getElementById('dtRowTimed').style.display=ad?'none':'flex';
  1023. document.getElementById('dtRowAllDay').style.display=ad?'flex':'none';
  1024. }
  1025. function openModal(ev,defaults){
  1026. S.editId=ev?ev.id:null;
  1027. if(ev){
  1028. S.modalStart=new Date(ev.start); S.modalEnd=new Date(ev.end);
  1029. document.getElementById('evTitle').value=ev.title||'';
  1030. document.getElementById('evAllDay').checked=!!ev.allDay;
  1031. document.getElementById('evAddress').value=ev.address||'';
  1032. document.getElementById('evNotes').value=ev.notes||'';
  1033. var rv=''; if(ev.reminder&&ev.reminder.unit) rv=ev.reminder.value+':'+ev.reminder.unit;
  1034. document.getElementById('evReminder').value=rv;
  1035. buildColorSwatches(ev.color||'blue');
  1036. document.getElementById('deleteEvBtn').classList.remove('hidden');
  1037. } else {
  1038. var now=new Date();
  1039. var s=defaults&&defaults.start||new Date(now.getFullYear(),now.getMonth(),now.getDate(),9,0,0);
  1040. var e2=defaults&&defaults.end||addMinutes(s,60);
  1041. S.modalStart=s; S.modalEnd=e2;
  1042. document.getElementById('evTitle').value='';
  1043. document.getElementById('evAllDay').checked=!!(defaults&&defaults.allDay);
  1044. document.getElementById('evAddress').value='';
  1045. document.getElementById('evNotes').value='';
  1046. document.getElementById('evReminder').value='';
  1047. buildColorSwatches('blue');
  1048. document.getElementById('deleteEvBtn').classList.add('hidden');
  1049. }
  1050. onAllDayChange(); refreshDateTimeFields();
  1051. document.getElementById('timeErr').style.display='none';
  1052. document.getElementById('eventModal').classList.remove('hidden');
  1053. setTimeout(function(){ document.getElementById('evTitle').focus(); },50);
  1054. }
  1055. function closeModal(){ closeAllPopups(); document.getElementById('eventModal').classList.add('hidden'); S.editId=null; }
  1056. function saveEvent(){
  1057. var title=document.getElementById('evTitle').value.trim();
  1058. if(!title){ document.getElementById('evTitle').focus(); snack(L('calendar/snack/enter-title','Please enter a title')); return; }
  1059. var allDay=document.getElementById('evAllDay').checked;
  1060. // Validate times for non-all-day events
  1061. if(!allDay&&S.modalEnd&&S.modalStart&&S.modalEnd.getTime()<=S.modalStart.getTime()){
  1062. document.getElementById('timeErr').style.display='block';
  1063. snack(L('calendar/snack/time-err','End time must be after start time')); return;
  1064. }
  1065. var rVal=document.getElementById('evReminder').value, reminder=null;
  1066. if(rVal){ var parts=rVal.split(':'); reminder={value:parseInt(parts[0],10),unit:parts[1]}; }
  1067. var ev={
  1068. id:S.editId||'',title:title,allDay:allDay,
  1069. start:S.modalStart?S.modalStart.getTime():Date.now(),
  1070. end:S.modalEnd?S.modalEnd.getTime():Date.now()+3600000,
  1071. address:document.getElementById('evAddress').value.trim(),
  1072. notes:document.getElementById('evNotes').value.trim(),
  1073. reminder:reminder,color:getSelectedColor()
  1074. };
  1075. if(allDay){ var d=S.modalStart||new Date(); ev.start=midnight(d).getTime(); ev.end=ev.start+86399000; }
  1076. var isNew=!ev.id;
  1077. if(isNew){ ev.id='ev_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6); S.events.push(ev); }
  1078. else { for(var i=0;i<S.events.length;i++) if(S.events[i].id===ev.id){ S.events[i]=ev; break; } }
  1079. apiSaveEvent(ev,null); closeModal(); render(); snack(isNew?L('calendar/snack/event-created','Event created'):L('calendar/snack/event-saved','Event saved'));
  1080. }
  1081. function deleteCurrentEvent(){
  1082. if(!S.editId||!confirm('Delete this event?')) return;
  1083. S.events=S.events.filter(function(e){ return e.id!==S.editId; });
  1084. apiDeleteEvent(S.editId,null); closeModal(); render(); snack(L('calendar/snack/event-deleted','Event deleted'));
  1085. }
  1086. // ── ICS ───────────────────────────────────────────────────────────────
  1087. function fmtICSDate(d){ var y=d.getFullYear(),mo=d.getMonth()+1,day=d.getDate(); return y+(mo<10?'0':'')+mo+(day<10?'0':'')+day; }
  1088. function fmtICSDateTime(d){ var y=d.getFullYear(),mo=d.getMonth()+1,day=d.getDate(),h=d.getHours(),mi=d.getMinutes(),s=d.getSeconds(); return y+(mo<10?'0':'')+mo+(day<10?'0':'')+day+'T'+(h<10?'0':'')+h+(mi<10?'0':'')+mi+(s<10?'0':'')+s; }
  1089. function escICS(s){ return (s||'').replace(/\\/g,'\\\\').replace(/\n/g,'\\n'); }
  1090. function exportICS(){
  1091. if(!S.events.length){ snack(L('calendar/snack/no-events','No events to export')); return; }
  1092. var lines=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//ArOZ Calendar//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH'];
  1093. S.events.forEach(function(ev){
  1094. lines.push('BEGIN:VEVENT','UID:'+ev.id+'@arozos');
  1095. var s=new Date(ev.start),e=new Date(ev.end);
  1096. if(ev.allDay){ lines.push('DTSTART;VALUE=DATE:'+fmtICSDate(s),'DTEND;VALUE=DATE:'+fmtICSDate(addDays(e,1))); }
  1097. else { lines.push('DTSTART:'+fmtICSDateTime(s),'DTEND:'+fmtICSDateTime(e)); }
  1098. lines.push('SUMMARY:'+escICS(ev.title));
  1099. if(ev.address) lines.push('LOCATION:'+escICS(ev.address));
  1100. if(ev.notes) lines.push('DESCRIPTION:'+escICS(ev.notes));
  1101. if(ev.reminder&&ev.reminder.unit){
  1102. var mins=ev.reminder.unit==='mins'?ev.reminder.value:ev.reminder.unit==='hours'?ev.reminder.value*60:ev.reminder.value*1440;
  1103. lines.push('BEGIN:VALARM','ACTION:DISPLAY','DESCRIPTION:Reminder','TRIGGER:-PT'+mins+'M','END:VALARM');
  1104. }
  1105. lines.push('END:VEVENT');
  1106. });
  1107. lines.push('END:VCALENDAR');
  1108. closeSidebar();
  1109. var blob=new Blob([lines.join('\r\n')],{type:'text/calendar;charset=utf-8'});
  1110. var url=URL.createObjectURL(blob), a=document.createElement('a');
  1111. a.href=url; a.download='calendar.ics'; document.body.appendChild(a); a.click();
  1112. setTimeout(function(){ document.body.removeChild(a); URL.revokeObjectURL(url); },100);
  1113. snack(L('calendar/snack/exported-n','Exported {n} event(s)').replace('{n}',S.events.length));
  1114. }
  1115. function importFromComputer(){
  1116. closeSidebar();
  1117. ao_module_selectFiles(function(files){
  1118. if(!files||!files.length) return;
  1119. var r=new FileReader(); r.onload=function(ev){ processICSText(ev.target.result); }; r.readAsText(files[0]);
  1120. },'file','.ics',false);
  1121. }
  1122. function importFromFiles(){
  1123. closeSidebar();
  1124. ao_module_openFileSelector(function(sel){
  1125. if(!sel||!sel.length) return;
  1126. apiImportIcs(sel[0].filepath||sel[0],function(resp){
  1127. if(resp.error){ snack(L('calendar/snack/import-failed','Import failed')+': '+resp.error); return; }
  1128. mergeImportedEvents(resp.events||[]);
  1129. });
  1130. },'user:/','file',false);
  1131. }
  1132. function processICSText(text){
  1133. var lines=text.replace(/\r\n/g,'\n').replace(/\r/g,'\n').split('\n'), unfolded=[];
  1134. lines.forEach(function(ln){ if(ln.length>0&&(ln[0]===' '||ln[0]==='\t')){ if(unfolded.length) unfolded[unfolded.length-1]+=ln.slice(1); } else unfolded.push(ln); });
  1135. var events=[],cur=null,inAlarm=false;
  1136. unfolded.forEach(function(line){
  1137. if(line==='BEGIN:VEVENT'){ cur={id:'',title:'',allDay:false,start:0,end:0,address:'',notes:'',reminder:null,color:'blue'}; inAlarm=false; }
  1138. else if(line==='END:VEVENT'){ if(cur&&cur.title&&cur.start){ if(!cur.id) cur.id='ics_'+cur.start.toString(36)+Math.random().toString(36).slice(2,6); events.push(cur); } cur=null; }
  1139. else if(line==='BEGIN:VALARM') inAlarm=true;
  1140. else if(line==='END:VALARM') inAlarm=false;
  1141. else if(cur){
  1142. var ci=line.indexOf(':'); if(ci<0) return;
  1143. var key=line.slice(0,ci).split(';')[0].toUpperCase(), val=line.slice(ci+1);
  1144. if(key==='UID') cur.id='ics_'+val.replace(/[^a-zA-Z0-9_-]/g,'_').slice(0,40);
  1145. else if(key==='SUMMARY') cur.title=val;
  1146. else if(key==='DTSTART'){ val=val.replace(/Z$/,''); var ad=val.indexOf('T')===-1; cur.allDay=ad; cur.start=ad?new Date(+val.slice(0,4),+val.slice(4,6)-1,+val.slice(6,8)).getTime():new Date(+val.slice(0,4),+val.slice(4,6)-1,+val.slice(6,8),+val.slice(9,11),+val.slice(11,13),+val.slice(13,15)).getTime(); }
  1147. else if(key==='DTEND'){ val=val.replace(/Z$/,''); cur.end=val.indexOf('T')===-1?new Date(+val.slice(0,4),+val.slice(4,6)-1,+val.slice(6,8)).getTime():new Date(+val.slice(0,4),+val.slice(4,6)-1,+val.slice(6,8),+val.slice(9,11),+val.slice(11,13),+val.slice(13,15)).getTime(); }
  1148. else if(key==='LOCATION') cur.address=val;
  1149. else if(key==='DESCRIPTION') cur.notes=val.replace(/\\n/g,'\n').replace(/\\,/g,',');
  1150. else if(inAlarm&&key==='TRIGGER'){ var neg=val[0]==='-'; val=val.replace(/^[-+]?P/i,''); var dm=val.match(/(\d+)D/i),hm=val.match(/(\d+)H/i),mm=val.match(/(\d+)M/i); var dv=dm?+dm[1]:0,hv=hm?+hm[1]:0,mv=mm?+mm[1]:0; if(neg){ if(dv&&!hv&&!mv) cur.reminder={value:dv,unit:'days'}; else if(hv&&!mv) cur.reminder={value:hv,unit:'hours'}; else cur.reminder={value:dv*1440+hv*60+mv,unit:'mins'}; } }
  1151. }
  1152. });
  1153. mergeImportedEvents(events);
  1154. }
  1155. function mergeImportedEvents(imported){
  1156. if(!imported.length){ snack(L('calendar/snack/no-events-in-file','No events found in file')); return; }
  1157. var idx={}; S.events.forEach(function(e){ idx[e.id]=true; });
  1158. var added=0;
  1159. imported.forEach(function(ev){ if(!idx[ev.id]){ S.events.push(ev); added++; } else for(var i=0;i<S.events.length;i++) if(S.events[i].id===ev.id){ S.events[i]=ev; break; } });
  1160. apiSaveEvents(S.events,function(){ render(); snack(L('calendar/snack/imported-n','Imported {n} new event(s)').replace('{n}',added)); });
  1161. }
  1162. // ── Helpers ───────────────────────────────────────────────────────────
  1163. function escHtml(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
  1164. // ── Boot ──────────────────────────────────────────────────────────────
  1165. (function boot(){
  1166. // ── 1. Load locale strings first so every rendered string is translated ──
  1167. if(typeof applocale !== 'undefined' && applocale){
  1168. applocale.init('locale/calendar.json', function(){
  1169. applocale.translate(); // translate all static locale= / title= / placeholder= elements
  1170. applyLocaleData(); // refresh JS-side arrays (month names, day names, AM/PM)
  1171. startApp();
  1172. });
  1173. } else {
  1174. startApp(); // applocale unavailable – run with English defaults
  1175. }
  1176. function startApp(){
  1177. ao_module_getSystemThemeColor(function(color){
  1178. applyDark(color!=='whiteTheme');
  1179. switchView('week');
  1180. apiLoadEvents(function(){
  1181. if(window.location.hash.length>1){
  1182. try{
  1183. var payload=JSON.parse(decodeURIComponent(window.location.hash.slice(1)));
  1184. if(Array.isArray(payload)&&payload.length&&payload[0].filepath&&payload[0].filepath.toLowerCase().endsWith('.ics')){
  1185. apiImportIcs(payload[0].filepath,function(resp){
  1186. if(resp&&resp.events&&resp.events.length) mergeImportedEvents(resp.events);
  1187. else render();
  1188. }); return;
  1189. }
  1190. } catch(e){}
  1191. }
  1192. render();
  1193. });
  1194. });
  1195. }
  1196. })();
  1197. </script>
  1198. </body>
  1199. </html>