Kaynağa Gözat

Add wip Cal app

Toby Chui 2 hafta önce
ebeveyn
işleme
3bf8138587

+ 28 - 0
src/web/Calendar/backend/deleteEvent.agi

@@ -0,0 +1,28 @@
+/*
+    Calendar - Delete an event by ID.
+    POST: eventId (string)
+*/
+requirelib("filelib");
+
+var evPath = "user:/Document/Calendar/events.json";
+
+if (!eventId || eventId === "") {
+    sendJSONResp({ error: "Missing eventId" });
+} else {
+    var events = [];
+    if (filelib.fileExists(evPath)) {
+        try {
+            var raw    = filelib.readFile(evPath);
+            var parsed = JSON.parse(raw);
+            if (Array.isArray(parsed)) events = parsed;
+        } catch (e) {}
+    }
+
+    var remaining = [];
+    for (var i = 0; i < events.length; i++) {
+        if (events[i].id !== eventId) remaining.push(events[i]);
+    }
+
+    filelib.writeFile(evPath, JSON.stringify(remaining));
+    sendJSONResp({ ok: true });
+}

+ 130 - 0
src/web/Calendar/backend/importIcs.agi

@@ -0,0 +1,130 @@
+/*
+    Calendar - Read and parse an ICS file from the arozos VFS.
+    POST: filePath (string, arozos VFS path e.g. user:/Desktop/events.ics)
+    Returns: { ok: true, events: [...] }  or  { error: "..." }
+*/
+requirelib("filelib");
+
+if (!filePath || filePath === "") {
+    sendJSONResp({ error: "Missing filePath" });
+} else if (!filelib.fileExists(filePath)) {
+    sendJSONResp({ error: "File not found: " + filePath });
+} else {
+    var raw = filelib.readFile(filePath);
+
+    // ---- ICS parser ----
+    // Unfold continuation lines (RFC 5545 §3.1)
+    var unfolded = "";
+    var lines = raw.split("\n");
+    for (var i = 0; i < lines.length; i++) {
+        var ln = lines[i];
+        if (ln.length > 0 && (ln[0] === " " || ln[0] === "\t")) {
+            unfolded = unfolded.slice(0, unfolded.lastIndexOf("\n")) + ln.slice(1) + "\n";
+        } else {
+            unfolded += ln + "\n";
+        }
+    }
+
+    var parsedLines = unfolded.split("\n");
+    var events = [];
+    var inEvent = false;
+    var inAlarm = false;
+    var cur = null;
+
+    function trimCRLF(s) {
+        while (s.length > 0 && (s[s.length - 1] === "\r" || s[s.length - 1] === "\n")) {
+            s = s.slice(0, s.length - 1);
+        }
+        return s;
+    }
+
+    function parseDT(val) {
+        // Remove VALUE=DATE: prefix if present (already stripped by key split)
+        val = val.replace(/Z$/, "");
+        var allDay = val.indexOf("T") === -1;
+        var y, mo, d, h, mi, s2;
+        if (allDay) {
+            y  = parseInt(val.slice(0, 4), 10);
+            mo = parseInt(val.slice(4, 6), 10) - 1;
+            d  = parseInt(val.slice(6, 8), 10);
+            return { allDay: true, ms: new Date(y, mo, d, 0, 0, 0).getTime() };
+        } else {
+            y  = parseInt(val.slice(0, 4), 10);
+            mo = parseInt(val.slice(4, 6), 10) - 1;
+            d  = parseInt(val.slice(6, 8), 10);
+            h  = parseInt(val.slice(9, 11), 10);
+            mi = parseInt(val.slice(11, 13), 10);
+            s2 = parseInt(val.slice(13, 15), 10);
+            return { allDay: false, ms: new Date(y, mo, d, h, mi, s2).getTime() };
+        }
+    }
+
+    function parseTrigger(val) {
+        // e.g. -PT15M, -P1D, -PT2H
+        var neg = val[0] === "-";
+        val = val.replace(/^[-+]?P/i, "");
+        var days = 0, hours = 0, mins = 0;
+        var dayM = val.match(/(\d+)D/i);
+        var hourM = val.match(/(\d+)H/i);
+        var minM  = val.match(/(\d+)M/i);
+        if (dayM)  days  = parseInt(dayM[1], 10);
+        if (hourM) hours = parseInt(hourM[1], 10);
+        if (minM)  mins  = parseInt(minM[1], 10);
+        var total = days * 1440 + hours * 60 + mins;
+        if (neg) {
+            // Before event – store as positive minutes before
+            if (days > 0 && hours === 0 && mins === 0) return { value: days, unit: "days" };
+            if (hours > 0 && mins === 0) return { value: hours, unit: "hours" };
+            return { value: total, unit: "mins" };
+        }
+        return null;
+    }
+
+    for (var k = 0; k < parsedLines.length; k++) {
+        var line = trimCRLF(parsedLines[k]);
+        if (line === "BEGIN:VEVENT") {
+            inEvent = true;
+            inAlarm = false;
+            cur = { id: "", title: "", allDay: false, start: 0, end: 0, address: "", notes: "", reminder: null, color: "blue" };
+        } else if (line === "END:VEVENT") {
+            if (cur && cur.title !== "" && cur.start !== 0) {
+                if (cur.end === 0) cur.end = cur.start + 3600000;
+                if (cur.id === "") cur.id = "ics_" + cur.start.toString(36) + Math.random().toString(36).slice(2, 6);
+                events.push(cur);
+            }
+            inEvent = false;
+            cur = null;
+        } else if (line === "BEGIN:VALARM") {
+            inAlarm = true;
+        } else if (line === "END:VALARM") {
+            inAlarm = false;
+        } else if (inEvent && cur) {
+            var colonPos = line.indexOf(":");
+            if (colonPos < 0) continue;
+            var keyPart = line.slice(0, colonPos);
+            var value   = line.slice(colonPos + 1);
+            // Key may have params: DTSTART;TZID=America/New_York → key = DTSTART
+            var key = keyPart.split(";")[0].toUpperCase();
+
+            if (key === "UID") {
+                cur.id = "ics_" + value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40);
+            } else if (key === "SUMMARY") {
+                cur.title = value;
+            } else if (key === "DTSTART") {
+                var dtInfo = parseDT(value);
+                cur.allDay = dtInfo.allDay;
+                cur.start  = dtInfo.ms;
+            } else if (key === "DTEND") {
+                cur.end = parseDT(value).ms;
+            } else if (key === "LOCATION") {
+                cur.address = value;
+            } else if (key === "DESCRIPTION") {
+                cur.notes = value.replace(/\\n/g, "\n").replace(/\\,/g, ",");
+            } else if (inAlarm && key === "TRIGGER") {
+                cur.reminder = parseTrigger(value);
+            }
+        }
+    }
+
+    sendJSONResp({ ok: true, events: events });
+}

+ 21 - 0
src/web/Calendar/backend/init.agi

@@ -0,0 +1,21 @@
+/*
+    Calendar - Initialize
+    Ensures the Calendar directory exists and returns all events.
+*/
+requirelib("filelib");
+
+var calDir    = "user:/Document/Calendar";
+var evPath    = "user:/Document/Calendar/events.json";
+
+filelib.mkdir(calDir);
+
+var events = [];
+if (filelib.fileExists(evPath)) {
+    try {
+        var raw    = filelib.readFile(evPath);
+        var parsed = JSON.parse(raw);
+        if (Array.isArray(parsed)) events = parsed;
+    } catch (e) {}
+}
+
+sendJSONResp({ events: events });

+ 46 - 0
src/web/Calendar/backend/saveEvent.agi

@@ -0,0 +1,46 @@
+/*
+    Calendar - Save (create or update) a single event.
+    POST: eventData (JSON string of event object)
+*/
+requirelib("filelib");
+
+var evPath = "user:/Document/Calendar/events.json";
+
+var ev;
+try {
+    ev = JSON.parse(eventData);
+} catch (e) {
+    sendJSONResp({ error: "Invalid event data" });
+}
+
+if (ev) {
+    // Ensure the event has an id
+    if (!ev.id || ev.id === "") {
+        var ts    = new Date().getTime();
+        ev.id = "ev_" + ts.toString(36) + Math.random().toString(36).slice(2, 7);
+    }
+
+    // Read existing events
+    var events = [];
+    if (filelib.fileExists(evPath)) {
+        try {
+            var raw    = filelib.readFile(evPath);
+            var parsed = JSON.parse(raw);
+            if (Array.isArray(parsed)) events = parsed;
+        } catch (e) {}
+    }
+
+    // Upsert
+    var found = false;
+    for (var i = 0; i < events.length; i++) {
+        if (events[i].id === ev.id) {
+            events[i] = ev;
+            found = true;
+            break;
+        }
+    }
+    if (!found) events.push(ev);
+
+    filelib.writeFile(evPath, JSON.stringify(events));
+    sendJSONResp({ ok: true, id: ev.id });
+}

+ 50 - 0
src/web/Calendar/backend/saveEvents.agi

@@ -0,0 +1,50 @@
+/*
+    Calendar - Save (merge) an array of events (used for ICS import).
+    POST: eventsData (JSON array string)
+*/
+requirelib("filelib");
+
+var evPath = "user:/Document/Calendar/events.json";
+
+var incoming;
+try {
+    incoming = JSON.parse(eventsData);
+} catch (e) {
+    sendJSONResp({ error: "Invalid events data" });
+}
+
+if (incoming && Array.isArray(incoming)) {
+    // Read existing events
+    var events = [];
+    if (filelib.fileExists(evPath)) {
+        try {
+            var raw    = filelib.readFile(evPath);
+            var parsed = JSON.parse(raw);
+            if (Array.isArray(parsed)) events = parsed;
+        } catch (e) {}
+    }
+
+    // Build index of existing events by id
+    var idx = {};
+    for (var i = 0; i < events.length; i++) {
+        idx[events[i].id] = i;
+    }
+
+    var added = 0;
+    var ts = new Date().getTime();
+    for (var j = 0; j < incoming.length; j++) {
+        var ev = incoming[j];
+        if (!ev.id || ev.id === "") {
+            ev.id = "ev_" + (ts + j).toString(36) + Math.random().toString(36).slice(2, 7);
+        }
+        if (idx[ev.id] !== undefined) {
+            events[idx[ev.id]] = ev;
+        } else {
+            events.push(ev);
+            added++;
+        }
+    }
+
+    filelib.writeFile(evPath, JSON.stringify(events));
+    sendJSONResp({ ok: true, added: added, total: events.length });
+}

+ 16 - 0
src/web/Calendar/img/icon.svg

@@ -0,0 +1,16 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
+  <rect width="100" height="100" rx="20" fill="#007AFF"/>
+  <rect x="12" y="24" width="76" height="62" rx="8" fill="white"/>
+  <rect x="12" y="24" width="76" height="22" rx="8" fill="#005ED8"/>
+  <rect x="12" y="38" width="76" height="8" fill="#005ED8"/>
+  <rect x="28" y="16" width="8" height="18" rx="4" fill="white"/>
+  <rect x="64" y="16" width="8" height="18" rx="4" fill="white"/>
+  <rect x="22" y="57" width="10" height="8" rx="2" fill="#D1E8FF"/>
+  <rect x="38" y="57" width="10" height="8" rx="2" fill="#D1E8FF"/>
+  <circle cx="64" cy="61" r="8" fill="#007AFF"/>
+  <rect x="76" y="57" width="10" height="8" rx="2" fill="#D1E8FF"/>
+  <rect x="22" y="73" width="10" height="8" rx="2" fill="#D1E8FF"/>
+  <rect x="38" y="73" width="10" height="8" rx="2" fill="#D1E8FF"/>
+  <rect x="54" y="73" width="10" height="8" rx="2" fill="#D1E8FF"/>
+  <rect x="70" y="73" width="10" height="8" rx="2" fill="#D1E8FF"/>
+</svg>

+ 1240 - 0
src/web/Calendar/index.html

@@ -0,0 +1,1240 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Calendar</title>
+<script src="../script/jquery.min.js"></script>
+<script src="../script/ao_module.js"></script>
+<script src="../script/applocale.js"></script>
+<style>
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+:root{
+  --bg:#fff;--bg2:#f5f5f7;--border:#d1d1d6;--text:#1c1c1e;--text2:#6c6c70;--text3:#aeaeb2;
+  --accent:#007aff;--accent2:#0051d5;--hover:rgba(0,0,0,.04);--hover2:rgba(0,0,0,.09);
+  --shadow:0 8px 32px rgba(0,0,0,.14);--r:12px;--r2:8px;--r3:6px;
+  --ev-blue-bg:#dbeafe;--ev-blue-bd:#3b82f6;--ev-blue-tx:#1d4ed8;
+  --ev-red-bg:#fee2e2;--ev-red-bd:#ef4444;--ev-red-tx:#991b1b;
+  --ev-orange-bg:#ffedd5;--ev-orange-bd:#f97316;--ev-orange-tx:#9a3412;
+  --ev-green-bg:#dcfce7;--ev-green-bd:#22c55e;--ev-green-tx:#15803d;
+  --ev-purple-bg:#ede9fe;--ev-purple-bd:#a855f7;--ev-purple-tx:#6b21a8;
+  --ev-teal-bg:#ccfbf1;--ev-teal-bd:#14b8a6;--ev-teal-tx:#0f766e;
+}
+body.dark{
+  --bg:#1c1c1e;--bg2:#2c2c2e;--border:#3a3a3c;--text:#fff;--text2:#8e8e93;--text3:#636366;
+  --accent:#0a84ff;--accent2:#409cff;--hover:rgba(255,255,255,.05);--hover2:rgba(255,255,255,.11);
+  --shadow:0 8px 32px rgba(0,0,0,.5);
+  --ev-blue-bg:rgba(59,130,246,.18);--ev-blue-bd:#60a5fa;--ev-blue-tx:#93c5fd;
+  --ev-red-bg:rgba(239,68,68,.18);--ev-red-bd:#f87171;--ev-red-tx:#fca5a5;
+  --ev-orange-bg:rgba(249,115,22,.18);--ev-orange-bd:#fb923c;--ev-orange-tx:#fdba74;
+  --ev-green-bg:rgba(34,197,94,.18);--ev-green-bd:#4ade80;--ev-green-tx:#86efac;
+  --ev-purple-bg:rgba(168,85,247,.18);--ev-purple-bd:#c084fc;--ev-purple-tx:#d8b4fe;
+  --ev-teal-bg:rgba(20,184,166,.18);--ev-teal-bd:#2dd4bf;--ev-teal-tx:#5eead4;
+}
+body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:grid;grid-template-rows:52px 1fr}
+
+/* TOOLBAR */
+#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}
+.app-title{font-size:17px;font-weight:700;margin-right:4px}
+.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}
+.tb-btn:hover{background:var(--hover2)}
+.period-label{font-size:15px;font-weight:600;min-width:170px;text-align:center;color:var(--text)}
+.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}
+.today-btn:hover{background:var(--hover)}
+.view-switcher{display:flex;background:var(--hover);border-radius:var(--r2);padding:2px;gap:1px}
+.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}
+.view-btn.active{background:var(--bg);color:var(--text);box-shadow:0 1px 3px rgba(0,0,0,.14)}
+.tb-spacer{flex:1}
+.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}
+.dark-toggle:hover{background:var(--hover)}
+.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}
+.add-btn:hover{background:var(--accent2);transform:scale(1.07)}
+
+/* MAIN */
+#main{display:grid;grid-template-columns:220px 1fr;overflow:hidden}
+
+/* SIDEBAR */
+#sidebar{border-right:1px solid var(--border);background:var(--bg2);display:flex;flex-direction:column;overflow-y:auto;overflow-x:hidden}
+.mini-cal{padding:12px 10px 6px}
+.mc-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
+.mc-title{font-size:13px;font-weight:600}
+.mc-nav{background:none;border:none;cursor:pointer;color:var(--accent);font-size:16px;padding:2px 6px;border-radius:4px;transition:background .12s}
+.mc-nav:hover{background:var(--hover2)}
+.mc-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:1px}
+.mc-dow{font-size:10px;text-align:center;color:var(--text3);font-weight:600;padding:2px 0}
+.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}
+.mc-day:hover{background:var(--hover2)}
+.mc-day.other{color:var(--text3)}
+.mc-day.today{background:var(--accent);color:#fff;font-weight:700}
+.mc-day.in-view{font-weight:600;color:var(--accent)}
+.mc-day.today.in-view{background:var(--accent);color:#fff}
+.sb-section{padding:8px 12px 12px;border-top:1px solid var(--border);margin-top:auto}
+.sb-section-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text3);margin-bottom:6px}
+.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}
+.sb-btn:hover{background:var(--hover2)}
+
+/* VIEW AREA */
+#viewArea{overflow:hidden;display:flex;flex-direction:column;background:var(--bg)}
+
+/* MONTH VIEW */
+.month-view{display:flex;flex-direction:column;height:100%;overflow:hidden}
+.month-dow-hdr{display:grid;grid-template-columns:repeat(7,1fr);border-bottom:1px solid var(--border);flex-shrink:0}
+.month-dow{text-align:center;font-size:11px;font-weight:600;color:var(--text2);padding:8px 0;text-transform:uppercase;letter-spacing:.04em}
+.month-grid{flex:1;display:grid;grid-template-columns:repeat(7,1fr);grid-auto-rows:1fr;overflow:hidden}
+.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}
+.month-cell:last-child,.month-cell:nth-child(7n){border-right:none}
+.month-cell:hover{background:var(--hover)}
+.month-cell.other-month .m-date{color:var(--text3)}
+.m-date{font-size:12px;font-weight:500;margin-bottom:2px;display:flex;justify-content:center}
+.m-date-inner{width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:50%}
+.month-cell.today .m-date-inner{background:var(--accent);color:#fff;font-weight:700}
+.month-pill{font-size:11px;padding:1px 6px;border-radius:4px;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
+.month-pill:hover{filter:brightness(.92)}
+.month-more{font-size:11px;color:var(--text2);padding:1px 4px;cursor:pointer}
+.month-more:hover{text-decoration:underline}
+
+/* WEEK/DAY VIEW */
+.week-view{display:flex;flex-direction:column;height:100%;overflow:hidden}
+.wk-hdr{display:flex;border-bottom:1px solid var(--border);flex-shrink:0}
+.wk-time-gutter{width:52px;min-width:52px;flex-shrink:0}
+.wk-day-hdrs{flex:1;display:grid}
+.wk-day-hdr{text-align:center;padding:8px 4px 6px;border-left:1px solid var(--border);cursor:pointer}
+.wk-dn{font-size:11px;font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:.04em}
+.wk-dd{font-size:22px;font-weight:300;line-height:1.15;color:var(--text)}
+.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}
+.allday-row{display:flex;border-bottom:2px solid var(--border);flex-shrink:0;min-height:28px}
+.allday-lbl{width:52px;min-width:52px;font-size:10px;color:var(--text3);text-align:right;padding:6px 8px 0 0;flex-shrink:0}
+.allday-cells{flex:1;display:grid}
+.allday-cell{border-left:1px solid var(--border);padding:2px 4px;min-height:28px}
+.allday-pill{font-size:11px;padding:2px 6px;border-radius:4px;margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}
+.allday-pill:hover{filter:brightness(.92)}
+.grid-scroll{flex:1;overflow-y:auto;overflow-x:hidden;position:relative}
+.grid-content{display:flex;position:relative}
+.time-labels{width:52px;min-width:52px;flex-shrink:0;position:relative}
+.t-lbl{position:absolute;right:8px;font-size:10px;color:var(--text3);transform:translateY(-50%);pointer-events:none;white-space:nowrap}
+.day-columns{flex:1;display:grid;position:relative}
+.day-col{position:relative;border-left:1px solid var(--border);cursor:crosshair}
+.hr-line{position:absolute;left:0;right:0;border-top:1px solid var(--border);pointer-events:none}
+.hh-line{position:absolute;left:0;right:0;border-top:1px dashed var(--border);opacity:.45;pointer-events:none}
+.now-bar{position:absolute;left:-1px;right:0;pointer-events:none;z-index:10}
+.now-bar::before{content:'';position:absolute;left:0;top:-4px;width:8px;height:8px;background:#ff3b30;border-radius:50%}
+.now-bar::after{content:'';position:absolute;left:7px;right:0;top:-1px;height:2px;background:#ff3b30}
+.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}
+.cal-event:hover{box-shadow:0 2px 8px rgba(0,0,0,.2)}
+.cal-event.dragging{opacity:.4;pointer-events:none}
+.ev-title{font-weight:600;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.ev-time{font-size:10px;opacity:.75;white-space:nowrap}
+/* Drag-to-create selection */
+.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}
+/* Drop ghost for event move */
+.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}
+
+/* MODAL */
+.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)}
+.modal-back.hidden{display:none}
+.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}
+.modal-hdr{padding:18px 20px 6px;display:flex;align-items:center;gap:8px}
+.ev-color-dot{width:12px;height:12px;border-radius:50%;flex-shrink:0}
+.modal-title-inp{font-size:20px;font-weight:600;border:none;background:transparent;color:var(--text);width:100%;outline:none;font-family:inherit}
+.modal-title-inp::placeholder{color:var(--text3)}
+.modal-body{padding:4px 20px 4px}
+.f-row{display:flex;align-items:flex-start;gap:10px;padding:9px 0;border-bottom:1px solid var(--border)}
+.f-row:last-child{border-bottom:none}
+.f-icon{width:20px;flex-shrink:0;color:var(--text3);margin-top:2px;display:flex;align-items:center;justify-content:center}
+.f-body{flex:1;display:flex;flex-direction:column;gap:5px}
+.f-row-h{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--border)}
+/* Toggle */
+.toggle-wrap{display:flex;align-items:center;justify-content:space-between;width:100%}
+.toggle-lbl{font-size:13px;color:var(--text)}
+.toggle{position:relative;width:38px;height:22px;flex-shrink:0}
+.toggle input{display:none}
+.toggle-track{display:block;width:38px;height:22px;border-radius:11px;background:var(--border);cursor:pointer;transition:background .2s;position:relative}
+.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)}
+.toggle input:checked+.toggle-track{background:var(--accent)}
+.toggle input:checked+.toggle-track::after{transform:translateX(16px)}
+/* From/To date+time rows */
+.ft-row{display:flex;align-items:center;gap:6px;margin-bottom:5px}
+.ft-row:last-child{margin-bottom:0}
+.ft-lbl{font-size:11px;font-weight:600;color:var(--text3);width:28px;flex-shrink:0;text-align:right}
+.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}
+.date-fld:hover,.time-fld:hover,.date-fld.open,.time-fld.open{border-color:var(--accent)}
+.dt-sep{font-size:13px;color:var(--text3)}
+/* Text inputs */
+.text-inp,.ta-inp{background:transparent;border:none;padding:0;font-size:13px;color:var(--text);width:100%;outline:none;font-family:inherit}
+.ta-inp{resize:none;min-height:54px;line-height:1.55}
+.text-inp::placeholder,.ta-inp::placeholder{color:var(--text3)}
+.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%}
+.color-swatches{display:flex;gap:10px;flex-wrap:wrap;padding:2px 0}
+.cswatch{width:22px;height:22px;border-radius:50%;cursor:pointer;transition:transform .1s;border:2px solid transparent}
+.cswatch:hover{transform:scale(1.18)}
+.cswatch.sel{border-color:var(--text);transform:scale(1.1)}
+/* Time validation error */
+.time-err{font-size:11px;color:#ff3b30;display:none;margin-top:2px}
+/* Footer */
+.modal-foot{padding:10px 20px 16px;display:flex;align-items:center;gap:8px}
+.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}
+.del-btn:hover{background:rgba(255,59,48,.1)}
+.mf-spacer{flex:1}
+.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}
+.cancel-btn:hover{background:var(--hover)}
+.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}
+.save-btn:hover{background:var(--accent2)}
+
+/* TIME PICKER POPUP */
+#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}
+.tp-opt{padding:7px 14px;font-size:13px;cursor:pointer;transition:background .08s;color:var(--text)}
+.tp-opt:hover{background:var(--hover2)}
+.tp-opt.sel{background:var(--accent);color:#fff;border-radius:6px}
+
+/* DATE PICKER POPUP */
+#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}
+.dp-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
+.dp-title{font-size:13px;font-weight:600}
+.dp-nav{background:none;border:none;cursor:pointer;color:var(--accent);font-size:16px;padding:2px 6px;border-radius:4px}
+.dp-nav:hover{background:var(--hover2)}
+.dp-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:1px}
+.dp-dow{font-size:10px;text-align:center;color:var(--text3);font-weight:600;padding:2px 0}
+.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}
+.dp-day:hover{background:var(--hover2)}
+.dp-day.other{color:var(--text3)}
+.dp-day.today{background:var(--accent);color:#fff;font-weight:700}
+.dp-day.picked{background:var(--hover2);font-weight:600}
+.dp-day.today.picked{background:var(--accent);color:#fff}
+
+/* SCROLLBARS */
+::-webkit-scrollbar{width:4px;height:4px}
+::-webkit-scrollbar-track{background:transparent}
+::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
+
+/* SNACKBAR */
+#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}
+#snackbar.show{opacity:1;transform:translateX(-50%) translateY(0)}
+</style>
+</head>
+<body>
+
+<header id="toolbar">
+  <span class="app-title" locale="calendar/app/title">Calendar</span>
+  <button class="today-btn" onclick="goToday()" locale="calendar/btn/today">Today</button>
+  <button class="tb-btn" onclick="navPrev()">&#8249;</button>
+  <span class="period-label" id="periodLabel"></span>
+  <button class="tb-btn" onclick="navNext()">&#8250;</button>
+  <div class="view-switcher">
+    <button class="view-btn" data-view="day"      onclick="switchView('day')"      locale="calendar/view/day">Day</button>
+    <button class="view-btn" data-view="workweek" onclick="switchView('workweek')" locale="calendar/view/workweek">Work Week</button>
+    <button class="view-btn" data-view="week"     onclick="switchView('week')"     locale="calendar/view/week">Week</button>
+    <button class="view-btn" data-view="month"    onclick="switchView('month')"    locale="calendar/view/month">Month</button>
+  </div>
+  <div class="tb-spacer"></div>
+  <button class="dark-toggle" id="darkToggle" onclick="toggleDark()" title="Toggle dark mode">
+    <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>
+  </button>
+  <button class="add-btn" title="New Event" onclick="openModal(null,null)">+</button>
+</header>
+
+<div id="main">
+  <aside id="sidebar">
+    <div class="mini-cal" id="miniCal"></div>
+    <div class="sb-section">
+      <div class="sb-section-title" locale="calendar/sidebar/section-title">Import / Export</div>
+      <button class="sb-btn" onclick="importFromComputer()" title="Import from Computer">
+        <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>
+        <span locale="calendar/sidebar/import-computer">Import from Computer</span>
+      </button>
+      <button class="sb-btn" onclick="importFromFiles()" title="Import from Files">
+        <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>
+        <span locale="calendar/sidebar/import-files">Import from Files</span>
+      </button>
+      <button class="sb-btn" onclick="exportICS()" title="Export as .ics">
+        <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>
+        <span locale="calendar/sidebar/export-ics">Export as .ics</span>
+      </button>
+    </div>
+  </aside>
+  <div id="viewArea"></div>
+</div>
+
+<!-- Event Modal -->
+<div class="modal-back hidden" id="eventModal">
+  <div class="modal">
+    <div class="modal-hdr">
+      <span class="ev-color-dot" id="modalColorDot"></span>
+      <input class="modal-title-inp" id="evTitle" placeholder="New Event" autocomplete="off">
+    </div>
+    <div class="modal-body">
+      <!-- All-day toggle -->
+      <div class="f-row-h" style="border-bottom:1px solid var(--border);padding:9px 0">
+        <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>
+        <div class="toggle-wrap">
+          <span class="toggle-lbl" locale="calendar/modal/allday">All-day</span>
+          <label class="toggle"><input type="checkbox" id="evAllDay" onchange="onAllDayChange()"><span class="toggle-track"></span></label>
+        </div>
+      </div>
+      <!-- Date / time — timed -->
+      <div class="f-row" id="dtRowTimed">
+        <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>
+        <div class="f-body">
+          <div class="ft-row">
+            <span class="ft-lbl" locale="calendar/modal/from">From</span>
+            <span class="date-fld" id="startDateFld" onclick="openDatePicker('start',this)"></span>
+            <span class="time-fld" id="startTimeFld" onclick="openTimePicker('start',this)"></span>
+          </div>
+          <div class="ft-row">
+            <span class="ft-lbl" locale="calendar/modal/to">To</span>
+            <span class="date-fld" id="endDateFld" onclick="openDatePicker('end',this)"></span>
+            <span class="time-fld" id="endTimeFld" onclick="openTimePicker('end',this)"></span>
+          </div>
+          <div class="time-err" id="timeErr" locale="calendar/modal/time-err">End time must be after start time</div>
+        </div>
+      </div>
+      <!-- Date — all-day -->
+      <div class="f-row" id="dtRowAllDay" style="display:none">
+        <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>
+        <div class="f-body">
+          <div class="ft-row">
+            <span class="ft-lbl" locale="calendar/modal/date">Date</span>
+            <span class="date-fld" id="adStartFld" onclick="openDatePicker('start',this)"></span>
+          </div>
+        </div>
+      </div>
+      <!-- Address -->
+      <div class="f-row">
+        <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>
+        <div class="f-body"><input class="text-inp" id="evAddress" placeholder="Add location" autocomplete="off"></div>
+      </div>
+      <!-- Notes -->
+      <div class="f-row">
+        <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>
+        <div class="f-body"><textarea class="ta-inp" id="evNotes" placeholder="Add notes" rows="3"></textarea></div>
+      </div>
+      <!-- Reminder -->
+      <div class="f-row">
+        <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>
+        <div class="f-body">
+          <select class="sel-inp" id="evReminder">
+            <option value=""        locale="calendar/reminder/none">None</option>
+            <option value="5:mins"  locale="calendar/reminder/5min">5 minutes before</option>
+            <option value="10:mins" locale="calendar/reminder/10min">10 minutes before</option>
+            <option value="15:mins" locale="calendar/reminder/15min">15 minutes before</option>
+            <option value="30:mins" locale="calendar/reminder/30min">30 minutes before</option>
+            <option value="1:hours" locale="calendar/reminder/1hour">1 hour before</option>
+            <option value="2:hours" locale="calendar/reminder/2hours">2 hours before</option>
+            <option value="1:days"  locale="calendar/reminder/1day">1 day before</option>
+            <option value="2:days"  locale="calendar/reminder/2days">2 days before</option>
+            <option value="7:days"  locale="calendar/reminder/1week">1 week before</option>
+          </select>
+        </div>
+      </div>
+      <!-- Color -->
+      <div class="f-row" style="border-bottom:none">
+        <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>
+        <div class="f-body"><div class="color-swatches" id="colorSwatches"></div></div>
+      </div>
+    </div>
+    <div class="modal-foot">
+      <button class="del-btn hidden" id="deleteEvBtn" onclick="deleteCurrentEvent()" locale="calendar/modal/delete">Delete</button>
+      <div class="mf-spacer"></div>
+      <button class="cancel-btn" onclick="closeModal()" locale="calendar/modal/cancel">Cancel</button>
+      <button class="save-btn" onclick="saveEvent()" locale="calendar/modal/save">Save</button>
+    </div>
+  </div>
+</div>
+
+<div id="timePicker"></div>
+<div id="datePicker"></div>
+<div id="snackbar"></div>
+
+<script>
+// ── Config ────────────────────────────────────────────────────────────
+var SNAP_MINS  = 15;
+var MIN_EV_H   = 20;
+var COLORS     = ['blue','red','orange','green','purple','teal'];
+var COLOR_HEX  = {blue:'#3b82f6',red:'#ef4444',orange:'#f97316',green:'#22c55e',purple:'#a855f7',teal:'#14b8a6'};
+// These arrays are re-populated from locale data after applocale loads.
+var DAYS_SHORT  = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
+var MONTHS_LONG = ['January','February','March','April','May','June','July','August','September','October','November','December'];
+var MONTHS_SHORT= ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
+var LOC_AM = 'AM', LOC_PM = 'PM';   // overridden by locale
+
+// ── Locale helper ─────────────────────────────────────────────────────
+// Short-hand: returns translated string, or 'fallback' when locale unavailable.
+function L(key, fallback){
+  return (typeof applocale !== 'undefined' && applocale) ? applocale.getString(key, fallback) : fallback;
+}
+// Rebuild day/month name arrays and AM/PM strings from the loaded locale data.
+function applyLocaleData(){
+  LOC_AM = L('calendar/time/am', 'AM');
+  LOC_PM = L('calendar/time/pm', 'PM');
+  DAYS_SHORT = [
+    L('calendar/day/sun','Sun'), L('calendar/day/mon','Mon'), L('calendar/day/tue','Tue'),
+    L('calendar/day/wed','Wed'), L('calendar/day/thu','Thu'), L('calendar/day/fri','Fri'),
+    L('calendar/day/sat','Sat')
+  ];
+  MONTHS_LONG = [
+    L('calendar/month/january','January'),   L('calendar/month/february','February'),
+    L('calendar/month/march','March'),       L('calendar/month/april','April'),
+    L('calendar/month/may','May'),           L('calendar/month/june','June'),
+    L('calendar/month/july','July'),         L('calendar/month/august','August'),
+    L('calendar/month/september','September'),L('calendar/month/october','October'),
+    L('calendar/month/november','November'), L('calendar/month/december','December')
+  ];
+  MONTHS_SHORT = [
+    L('calendar/month/jan','Jan'), L('calendar/month/feb','Feb'), L('calendar/month/mar','Mar'),
+    L('calendar/month/apr','Apr'), L('calendar/month/may-short','May'), L('calendar/month/jun','Jun'),
+    L('calendar/month/jul','Jul'), L('calendar/month/aug','Aug'), L('calendar/month/sep','Sep'),
+    L('calendar/month/oct','Oct'), L('calendar/month/nov','Nov'), L('calendar/month/dec','Dec')
+  ];
+}
+
+// ── State ─────────────────────────────────────────────────────────────
+var S = {
+  view:'week', anchor:new Date(), events:[],
+  editId:null, modalStart:null, modalEnd:null,
+  pickerTarget:null, dpTarget:null,
+  dragId:null, dragOffsetPx:0,
+  gridScrollTop:null,           // persists scroll between renders
+  miniCalAnchor:new Date(), dark:false, nowTimer:null
+};
+
+// Drag-to-create state
+var CD = { active:false, col:null, dayMs:0, startY:0, currentY:0, selEl:null };
+
+// ── Date utils ────────────────────────────────────────────────────────
+function midnight(d){ var r=new Date(d); r.setHours(0,0,0,0); return r; }
+function addDays(d,n){ var r=new Date(d); r.setDate(r.getDate()+n); return r; }
+function addMinutes(d,n){ return new Date(d.getTime()+n*60000); }
+function sameDay(a,b){ return midnight(a).getTime()===midnight(b).getTime(); }
+function totalMinutes(d){ return d.getHours()*60+d.getMinutes(); }
+function startOfWeek(d){ var r=new Date(d); r.setDate(r.getDate()-r.getDay()); r.setHours(0,0,0,0); return r; }
+function startOfMonth(d){ return new Date(d.getFullYear(),d.getMonth(),1); }
+function endOfMonth(d){ return new Date(d.getFullYear(),d.getMonth()+1,0); }
+
+function fmt12(d){
+  var h=d.getHours(),m=d.getMinutes(),ap=h<12?LOC_AM:LOC_PM;
+  return (h%12||12)+':'+(m<10?'0':'')+m+' '+ap;
+}
+function fmtDateShort(d){
+  return ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()]+', '+MONTHS_SHORT[d.getMonth()]+' '+d.getDate();
+}
+function fmtDateLong(d){
+  return ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][d.getDay()]+
+    ', '+MONTHS_LONG[d.getMonth()]+' '+d.getDate()+', '+d.getFullYear();
+}
+
+// ── Event colours ─────────────────────────────────────────────────────
+function evStyleVars(c){ c=c||'blue'; return {bg:'var(--ev-'+c+'-bg)',bd:'var(--ev-'+c+'-bd)',tx:'var(--ev-'+c+'-tx)'}; }
+function applyEvStyle(el,c){ var s=evStyleVars(c); el.style.background=s.bg; el.style.borderLeftColor=s.bd; el.style.color=s.tx; }
+
+// ── API ───────────────────────────────────────────────────────────────
+function apiLoadEvents(cb){
+  ao_module_agirun('Calendar/backend/init.agi',{},function(d){
+    try{ S.events=(typeof d==='string'?JSON.parse(d):d).events||[]; }catch(e){ S.events=[]; } cb();
+  },function(){ S.events=[]; cb(); });
+}
+function apiSaveEvent(ev,cb){
+  ao_module_agirun('Calendar/backend/saveEvent.agi',{eventData:JSON.stringify(ev)},function(){ cb&&cb(); },function(){ cb&&cb(); });
+}
+function apiSaveEvents(arr,cb){
+  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); });
+}
+function apiDeleteEvent(id,cb){
+  ao_module_agirun('Calendar/backend/deleteEvent.agi',{eventId:id},function(){ cb&&cb(); },function(){ cb&&cb(); });
+}
+function apiImportIcs(path,cb){
+  ao_module_agirun('Calendar/backend/importIcs.agi',{filePath:path},function(d){ cb(typeof d==='string'?JSON.parse(d):d); },function(){ cb({error:'Request failed'}); });
+}
+
+// ── Snackbar ──────────────────────────────────────────────────────────
+var _sbT;
+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); }
+
+// ── SVG icon strings (used by JS-generated content) ───────────────────
+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>';
+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>';
+// Small left-right arrows shown on events that span multiple days
+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>';
+
+// ── Dark mode ─────────────────────────────────────────────────────────
+function applyDark(dark){
+  S.dark=dark;
+  document.body.classList.toggle('dark',dark);
+  // Use innerHTML so we can swap between moon/sun SVGs
+  document.getElementById('darkToggle').innerHTML=dark?SVG_SUN:SVG_MOON;
+  if(typeof ao_module_setWindowTheme==='function') ao_module_setWindowTheme(dark?'dark':'white');
+}
+function toggleDark(){ applyDark(!S.dark); }
+
+// ── Mini calendar ─────────────────────────────────────────────────────
+function renderMiniCal(){
+  var a=S.miniCalAnchor, y=a.getFullYear(), mo=a.getMonth();
+  var dow=new Date(y,mo,1).getDay();
+  var today=midnight(new Date()), viewSet={};
+  getViewDays().forEach(function(d){ viewSet[midnight(d).getTime()]=1; });
+  var html='<div class="mc-header"><button class="mc-nav" onclick="mcPrev()">&#8249;</button>'
+    +'<span class="mc-title">'+MONTHS_SHORT[mo]+' '+y+'</span>'
+    +'<button class="mc-nav" onclick="mcNext()">&#8250;</button></div><div class="mc-grid">';
+  ['S','M','T','W','T','F','S'].forEach(function(d){ html+='<div class="mc-dow">'+d+'</div>'; });
+  for(var i=0;i<dow;i++) html+='<div class="mc-day other"></div>';
+  var dim=new Date(y,mo+1,0).getDate();
+  for(var day=1;day<=dim;day++){
+    var d=new Date(y,mo,day), cls='mc-day';
+    if(sameDay(d,today)) cls+=' today';
+    if(viewSet[midnight(d).getTime()]) cls+=' in-view';
+    html+='<div class="'+cls+'" onclick="mcDayClick('+y+','+mo+','+day+')">'+day+'</div>';
+  }
+  html+='</div>';
+  document.getElementById('miniCal').innerHTML=html;
+}
+function getViewDays(){
+  var days=[], se=viewStartEnd();
+  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)); }
+  else { for(var d=new Date(se.start);d<=se.end;d=addDays(d,1)) days.push(new Date(d)); }
+  return days;
+}
+function mcPrev(){ S.miniCalAnchor=new Date(S.miniCalAnchor.getFullYear(),S.miniCalAnchor.getMonth()-1,1); renderMiniCal(); }
+function mcNext(){ S.miniCalAnchor=new Date(S.miniCalAnchor.getFullYear(),S.miniCalAnchor.getMonth()+1,1); renderMiniCal(); }
+function mcDayClick(y,mo,day){ S.anchor=new Date(y,mo,day); S.miniCalAnchor=new Date(y,mo,1); render(); }
+
+// ── Navigation ────────────────────────────────────────────────────────
+function goToday(){ S.anchor=new Date(); S.miniCalAnchor=new Date(S.anchor.getFullYear(),S.anchor.getMonth(),1); render(); }
+function navPrev(){
+  if(S.view==='day') S.anchor=addDays(S.anchor,-1);
+  else if(S.view==='workweek'||S.view==='week') S.anchor=addDays(S.anchor,-7);
+  else S.anchor=new Date(S.anchor.getFullYear(),S.anchor.getMonth()-1,1);
+  render();
+}
+function navNext(){
+  if(S.view==='day') S.anchor=addDays(S.anchor,1);
+  else if(S.view==='workweek'||S.view==='week') S.anchor=addDays(S.anchor,7);
+  else S.anchor=new Date(S.anchor.getFullYear(),S.anchor.getMonth()+1,1);
+  render();
+}
+function switchView(v){
+  S.view=v;
+  S.gridScrollTop=null;  // fresh view always starts at 7 am
+  document.querySelectorAll('.view-btn').forEach(function(b){ b.classList.toggle('active',b.dataset.view===v); });
+  render();
+}
+function viewStartEnd(){
+  if(S.view==='day') return {start:midnight(S.anchor),end:midnight(S.anchor)};
+  if(S.view==='workweek'){
+    var day=S.anchor.getDay(), mon=addDays(midnight(S.anchor),day===0?1:day===6?2:-(day-1));
+    return {start:mon,end:addDays(mon,4)};
+  }
+  if(S.view==='week'){ var sun=startOfWeek(S.anchor); return {start:sun,end:addDays(sun,6)}; }
+  return {start:startOfMonth(S.anchor),end:endOfMonth(S.anchor)};
+}
+function updatePeriodLabel(){
+  var se=viewStartEnd(), lbl='';
+  if(S.view==='day') lbl=fmtDateLong(S.anchor);
+  else if(S.view==='month') lbl=MONTHS_LONG[S.anchor.getMonth()]+' '+S.anchor.getFullYear();
+  else {
+    var s=se.start,e=se.end;
+    if(s.getMonth()===e.getMonth()) lbl=MONTHS_LONG[s.getMonth()]+' '+s.getFullYear();
+    else if(s.getFullYear()===e.getFullYear()) lbl=MONTHS_SHORT[s.getMonth()]+' – '+MONTHS_SHORT[e.getMonth()]+' '+s.getFullYear();
+    else lbl=MONTHS_SHORT[s.getMonth()]+' '+s.getFullYear()+' – '+MONTHS_SHORT[e.getMonth()]+' '+e.getFullYear();
+  }
+  document.getElementById('periodLabel').textContent=lbl;
+}
+
+// ── Main render ───────────────────────────────────────────────────────
+function render(){ updatePeriodLabel(); renderMiniCal(); if(S.view==='month') renderMonth(); else renderWeekView(); }
+
+// ── Month view ────────────────────────────────────────────────────────
+function renderMonth(){
+  var y=S.anchor.getFullYear(), mo=S.anchor.getMonth();
+  var startDow=new Date(y,mo,1).getDay(), daysInMo=new Date(y,mo+1,0).getDate();
+  var today=midnight(new Date());
+  var html='<div class="month-view"><div class="month-dow-hdr">';
+  ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(function(d){ html+='<div class="month-dow">'+d+'</div>'; });
+  html+='</div><div class="month-grid" id="monthGrid">';
+  for(var i=0;i<startDow;i++){
+    var pd=new Date(y,mo,1-startDow+i);
+    html+='<div class="month-cell other-month" data-date="'+pd.toISOString()+'" ondragover="monthDragOver(event,this)" ondrop="monthDrop(event,this)" onclick="monthCellClick(event,this)">'
+      +'<div class="m-date"><div class="m-date-inner">'+pd.getDate()+'</div></div></div>';
+  }
+  for(var day=1;day<=daysInMo;day++){
+    var d=new Date(y,mo,day), isTod=sameDay(d,today);
+    html+='<div class="month-cell'+(isTod?' today':'')
+      +'" data-date="'+d.toISOString()+'" ondragover="monthDragOver(event,this)" ondrop="monthDrop(event,this)" onclick="monthCellClick(event,this)">'
+      +'<div class="m-date"><div class="m-date-inner">'+day+'</div></div></div>';
+  }
+  var trailing=Math.ceil((startDow+daysInMo)/7)*7-(startDow+daysInMo);
+  for(var i=1;i<=trailing;i++){
+    var nd=new Date(y,mo+1,i);
+    html+='<div class="month-cell other-month" data-date="'+nd.toISOString()+'" ondragover="monthDragOver(event,this)" ondrop="monthDrop(event,this)" onclick="monthCellClick(event,this)">'
+      +'<div class="m-date"><div class="m-date-inner">'+nd.getDate()+'</div></div></div>';
+  }
+  html+='</div></div>';
+  document.getElementById('viewArea').innerHTML=html;
+  injectMonthEvents();
+}
+function injectMonthEvents(){
+  var grid=document.getElementById('monthGrid'); if(!grid) return;
+  var sorted=S.events.slice().sort(function(a,b){ return a.start-b.start; });
+  grid.querySelectorAll('.month-cell').forEach(function(cell){
+    var cellMs=midnight(new Date(cell.dataset.date)).getTime();
+    var dayEvs=sorted.filter(function(ev){
+      return sameDay(new Date(ev.start),new Date(cellMs));
+    });
+    dayEvs.slice(0,3).forEach(function(ev){
+      var pill=document.createElement('div');
+      pill.className='month-pill'; pill.dataset.evid=ev.id;
+      applyEvStyle(pill,ev.color); pill.textContent=ev.title||'(no title)';
+      pill.draggable=true;
+      pill.addEventListener('dragstart',function(e){ e.stopPropagation(); evDragStart(e,ev.id,0); });
+      pill.addEventListener('click',function(e){ e.stopPropagation(); openModal(ev,null); });
+      cell.appendChild(pill);
+    });
+    if(dayEvs.length>3){ var m=document.createElement('div'); m.className='month-more'; m.textContent='+'+(dayEvs.length-3)+' more'; cell.appendChild(m); }
+  });
+}
+function monthCellClick(e,cell){
+  if(e.target.classList.contains('month-pill')||e.target.classList.contains('month-more')) return;
+  var d=midnight(new Date(cell.dataset.date));
+  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)});
+}
+function monthDragOver(e,cell){ if(!S.dragId) return; e.preventDefault(); e.dataTransfer.dropEffect='move'; }
+function monthDrop(e,cell){
+  e.preventDefault(); if(!S.dragId) return;
+  var ev=S.events.find(function(x){ return x.id===S.dragId; }); if(!ev) return;
+  var td=midnight(new Date(cell.dataset.date)), os=new Date(ev.start), dur=new Date(ev.end).getTime()-os.getTime();
+  ev.start=new Date(td.getFullYear(),td.getMonth(),td.getDate(),os.getHours(),os.getMinutes(),0).getTime();
+  ev.end=ev.start+dur;
+  evDragEnd(); apiSaveEvent(ev,null); renderMonth();
+}
+
+// ── Week/Day view ─────────────────────────────────────────────────────
+function renderWeekView(){
+  var numDays=S.view==='day'?1:S.view==='workweek'?5:7;
+  var vStart=viewStartEnd().start;
+  var days=[]; for(var i=0;i<numDays;i++) days.push(addDays(vStart,i));
+  var today=midnight(new Date());
+
+  // Header
+  var hdr='<div class="wk-hdr"><div class="wk-time-gutter"></div><div class="wk-day-hdrs" style="grid-template-columns:repeat('+numDays+',1fr)">';
+  days.forEach(function(d){
+    var tod=sameDay(d,today);
+    hdr+='<div class="wk-day-hdr" onclick="wkHdrClick(\''+d.toISOString()+'\')">'
+      +'<div class="wk-dn">'+DAYS_SHORT[d.getDay()]+'</div>'
+      +'<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>'
+      +'</div>';
+  });
+  hdr+='</div></div>';
+
+  // All-day row
+  var allDayEvMap={};
+  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)">';
+  days.forEach(function(d){
+    adr+='<div class="allday-cell">';
+    S.events.filter(function(ev){ return ev.allDay&&sameDay(new Date(ev.start),d); }).forEach(function(ev){
+      allDayEvMap[ev.id]=ev;
+      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)">'
+        +escHtml(ev.title||'(no title)')+'</div>';
+    });
+    adr+='</div>';
+  });
+  adr+='</div></div>';
+
+  // Time labels
+  var lbls='<div class="time-labels">';
+  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>';
+  lbls+='</div>';
+
+  // Day columns grid
+  var cols='<div class="day-columns" id="dayColumns" style="grid-template-columns:repeat('+numDays+',1fr);height:1440px">';
+  days.forEach(function(d,i){
+    var isToday=sameDay(d,today);
+    cols+='<div class="day-col" id="dayCol'+i+'" data-dayidx="'+i+'" data-dayiso="'+d.toISOString()+'"'
+      +' ondragover="wkDragOver(event,this)" ondrop="wkDrop(event,this)" ondragleave="wkDragLeave(event,this)">';
+    for(var h=0;h<24;h++){
+      cols+='<div class="hr-line" style="top:'+(h*60)+'px"></div>';
+      if(h<23) cols+='<div class="hh-line" style="top:'+(h*60+30)+'px"></div>';
+    }
+    if(isToday){ var n=new Date(); cols+='<div class="now-bar" id="nowBar'+i+'" style="top:'+(n.getHours()*60+n.getMinutes())+'px"></div>'; }
+    cols+='<div class="drop-ghost" id="ghost'+i+'"></div>';
+    cols+='</div>';
+  });
+  cols+='</div>';
+
+  var va=document.getElementById('viewArea');
+  va.innerHTML='<div class="week-view">'+hdr+adr
+    +'<div class="grid-scroll" id="gridScroll"><div class="grid-content" style="min-height:1440px">'+lbls
+    +'<div style="flex:1;position:relative">'+cols+'</div></div></div></div>';
+
+  // Bind allday pill clicks
+  va.querySelectorAll('.allday-pill[data-evid]').forEach(function(pill){
+    var ev=allDayEvMap[pill.dataset.evid];
+    if(ev) pill.addEventListener('click',function(e){ e.stopPropagation(); openModal(ev,null); });
+  });
+
+  injectWeekEvents(days);
+  setupColumnDragCreate(days);
+
+  var gs=document.getElementById('gridScroll');
+  if(gs){
+    // Restore the user's scroll position; only default to 7 am on first ever render
+    gs.scrollTop = (S.gridScrollTop !== null) ? S.gridScrollTop : 420;
+    // Keep S.gridScrollTop in sync so future renders restore correctly
+    gs.addEventListener('scroll', function(){ S.gridScrollTop = gs.scrollTop; }, {passive:true});
+  }
+
+  clearInterval(S.nowTimer);
+  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);
+}
+
+function injectWeekEvents(days){
+  days.forEach(function(day,di){
+    var col=document.getElementById('dayCol'+di); if(!col) return;
+    var dayStartMs=midnight(day).getTime(), dayEndMs=dayStartMs+86400000;
+    // Find events overlapping this day (handles multi-day events)
+    var dayEvs=S.events.filter(function(ev){
+      if(ev.allDay) return false;
+      return new Date(ev.start).getTime()<dayEndMs && new Date(ev.end).getTime()>dayStartMs;
+    });
+    var laid=layoutEvents(dayEvs);
+    laid.forEach(function(ev){
+      // Clip to day bounds
+      var clipStart=Math.max(new Date(ev.start).getTime(),dayStartMs);
+      var clipEnd  =Math.min(new Date(ev.end).getTime(),  dayEndMs);
+      var startMin =Math.floor((clipStart-dayStartMs)/60000);
+      var endMin   =Math.floor((clipEnd-dayStartMs)/60000);
+      if(endMin===0) endMin=1440; // midnight end = bottom of day
+      if(endMin<=startMin) endMin=startMin+30;
+      var height=Math.max(endMin-startMin,MIN_EV_H);
+      var leftPct=(ev._col/ev._totalCols)*100, widthPct=(1/ev._totalCols)*100-1;
+      var isMultiDay=!sameDay(new Date(ev.start),new Date(ev.end));
+
+      var el=document.createElement('div');
+      el.className='cal-event'; el.dataset.evid=ev.id; el.draggable=true;
+      el.style.cssText='top:'+startMin+'px;height:'+height+'px;left:'+leftPct+'%;width:'+widthPct+'%;';
+      applyEvStyle(el,ev.color);
+      var startD=new Date(ev.start);
+      el.innerHTML='<div class="ev-title">'+(isMultiDay?SVG_MULTIDAY:'')+escHtml(ev.title||'(no title)')+'</div>'
+        +(height>=40?'<div class="ev-time">'+fmt12(startD)+'</div>':'');
+      el.addEventListener('click',function(e){ e.stopPropagation(); openModal(ev,null); });
+      el.addEventListener('dragstart',function(e){
+        var offsetPx=e.clientY-el.getBoundingClientRect().top;
+        evDragStart(e,ev.id,Math.max(0,offsetPx));
+      });
+      col.appendChild(el);
+    });
+  });
+}
+
+function layoutEvents(evs){
+  if(!evs.length) return evs;
+  evs=evs.slice().sort(function(a,b){ return a.start-b.start; });
+  var cols=[];
+  evs.forEach(function(ev){
+    ev._col=0; ev._totalCols=1;
+    var placed=false;
+    for(var c=0;c<cols.length;c++){
+      if(cols[c][cols[c].length-1].end<=ev.start){ cols[c].push(ev); ev._col=c; placed=true; break; }
+    }
+    if(!placed){ cols.push([ev]); ev._col=cols.length-1; }
+  });
+  evs.forEach(function(ev){
+    var mx=ev._col;
+    evs.forEach(function(o){ if(o!==ev&&o.start<ev.end&&o.end>ev.start) mx=Math.max(mx,o._col); });
+    ev._totalCols=mx+1;
+  });
+  return evs;
+}
+
+function wkHdrClick(iso){ S.anchor=midnight(new Date(iso)); if(S.view!=='day') switchView('day'); else render(); }
+
+// ── Drag-to-CREATE (mousedown on empty grid) ──────────────────────────
+function setupColumnDragCreate(days){
+  days.forEach(function(day,di){
+    var col=document.getElementById('dayCol'+di); if(!col) return;
+    col.addEventListener('mousedown',function(e){
+      if(e.button!==0) return;
+      if(e.target.closest && e.target.closest('.cal-event,.drop-ghost')) return;
+      if(e.target.classList.contains('cal-event')) return;
+      e.preventDefault();
+      var rect=col.getBoundingClientRect();
+      // getBoundingClientRect already accounts for scroll — no scrollTop needed
+      var startY=Math.max(0, e.clientY-rect.top);
+      var sel=document.createElement('div'); sel.className='create-sel';
+      col.appendChild(sel);
+      CD={active:true,col:col,dayMs:midnight(day).getTime(),startY:startY,currentY:startY,selEl:sel};
+      updateCreateSel();
+    });
+  });
+}
+
+function updateCreateSel(){
+  if(!CD.selEl) return;
+  var top=Math.min(CD.startY,CD.currentY), bot=Math.max(CD.startY,CD.currentY);
+  var snTop=Math.round(top/SNAP_MINS)*SNAP_MINS, snBot=Math.round(bot/SNAP_MINS)*SNAP_MINS;
+  snBot=Math.max(snBot,snTop+SNAP_MINS);
+  CD.selEl.style.top=snTop+'px'; CD.selEl.style.height=(snBot-snTop)+'px';
+}
+
+document.addEventListener('mousemove',function(e){
+  if(!CD.active) return;
+  var rect=CD.col.getBoundingClientRect();
+  CD.currentY=Math.max(0,e.clientY-rect.top);
+  updateCreateSel();
+});
+
+document.addEventListener('mouseup',function(e){
+  if(!CD.active) return;
+  var rect=CD.col.getBoundingClientRect();
+  var endY=Math.max(0,e.clientY-rect.top);
+  var wasDrag=Math.abs(endY-CD.startY)>10;
+  var top=Math.min(CD.startY,endY), bot=Math.max(CD.startY,endY);
+  var startMin=Math.round(top/SNAP_MINS)*SNAP_MINS;
+  var endMin  =Math.round(bot/SNAP_MINS)*SNAP_MINS;
+  if(!wasDrag||endMin<=startMin) endMin=startMin+60;
+  endMin=Math.min(endMin,1440);
+  if(CD.selEl&&CD.selEl.parentNode) CD.selEl.parentNode.removeChild(CD.selEl);
+  var savedDay=CD.dayMs;
+  CD={active:false,col:null,dayMs:0,startY:0,currentY:0,selEl:null};
+  var base=new Date(savedDay);
+  var start=new Date(base.getFullYear(),base.getMonth(),base.getDate(),Math.floor(startMin/60),startMin%60,0);
+  var end  =new Date(base.getFullYear(),base.getMonth(),base.getDate(),Math.floor(endMin/60),  endMin%60,  0);
+  openModal(null,{start:start,end:end});
+});
+
+// Cancel create-drag if HTML5 drag kicks in (user started on an event)
+document.addEventListener('dragstart',function(){
+  if(CD.active){ if(CD.selEl&&CD.selEl.parentNode) CD.selEl.parentNode.removeChild(CD.selEl); CD.active=false; }
+});
+
+// ── Drag-to-MOVE existing event ───────────────────────────────────────
+
+// Edge-scroll state
+var _edgeST=null, _edgeDir=0;
+var EDGE_PX=60, EDGE_SPEED=12; // px from rim triggers scroll; px per frame
+
+function _startEdgeScroll(dir){
+  if(_edgeDir===dir) return;          // already scrolling this way
+  _stopEdgeScroll();
+  _edgeDir=dir;
+  _edgeST=setInterval(function(){
+    var gs=document.getElementById('gridScroll'); if(!gs) return;
+    gs.scrollTop+=dir*EDGE_SPEED;
+    S.gridScrollTop=gs.scrollTop;     // keep state in sync
+  },16);                              // ~60 fps
+}
+function _stopEdgeScroll(){
+  if(_edgeST){ clearInterval(_edgeST); _edgeST=null; } _edgeDir=0;
+}
+
+function evDragStart(e,id,offsetPx){
+  S.dragId=id; S.dragOffsetPx=offsetPx;
+  e.dataTransfer.setData('text/plain',id); e.dataTransfer.effectAllowed='move';
+  // Replace the browser's default drag image with an invisible 1×1 pixel element
+  // positioned far off-screen.  This stops the browser from auto-scrolling the
+  // grid container to keep the drag image in view — our drop-ghost does the job.
+  var phantom=document.createElement('div');
+  phantom.style.cssText='position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;opacity:.01;pointer-events:none';
+  document.body.appendChild(phantom);
+  e.dataTransfer.setDragImage(phantom,0,0);
+  setTimeout(function(){
+    if(phantom.parentNode) phantom.parentNode.removeChild(phantom);
+    document.querySelectorAll('[data-evid="'+id+'"]').forEach(function(el){ el.classList.add('dragging'); });
+  },0);
+}
+
+function evDragEnd(){
+  _stopEdgeScroll();
+  S.dragId=null;
+  document.querySelectorAll('.dragging').forEach(function(el){ el.classList.remove('dragging'); });
+  document.querySelectorAll('.drop-ghost').forEach(function(g){ g.style.display='none'; });
+}
+
+function wkDragOver(e,col){
+  if(!S.dragId) return;
+  e.preventDefault(); e.dataTransfer.dropEffect='move';
+
+  // ── Edge auto-scroll: scroll the grid when the pointer is near the rim ──
+  var gs=document.getElementById('gridScroll');
+  if(gs){
+    var gr=gs.getBoundingClientRect();
+    var dTop=e.clientY-gr.top, dBot=gr.bottom-e.clientY;
+    if     (dTop>0 && dTop<EDGE_PX) _startEdgeScroll(-1);  // near top → scroll up
+    else if(dBot>0 && dBot<EDGE_PX) _startEdgeScroll( 1);  // near bottom → scroll down
+    else                             _stopEdgeScroll();
+  }
+
+  // ── Update drop-ghost position ──
+  var di=parseInt(col.dataset.dayidx);
+  // getBoundingClientRect() is always in viewport space; no scrollTop adjustment needed
+  var rect=col.getBoundingClientRect();
+  var relY=(e.clientY-rect.top)-S.dragOffsetPx;
+  var snapped=Math.round(relY/SNAP_MINS)*SNAP_MINS;
+  snapped=Math.max(0,Math.min(snapped,23*60));
+  var ev=S.events.find(function(x){ return x.id===S.dragId; }); if(!ev) return;
+  var durMin=Math.round((new Date(ev.end).getTime()-new Date(ev.start).getTime())/60000);
+  document.querySelectorAll('.drop-ghost').forEach(function(g){
+    var gdi=parseInt(g.id.replace('ghost',''));
+    if(gdi===di){
+      var s=evStyleVars(ev.color||'blue');
+      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;
+      g.textContent=ev.title||'(no title)';
+    } else { g.style.display='none'; }
+  });
+}
+
+function wkDragLeave(e,col){
+  if(!e.relatedTarget||!col.contains(e.relatedTarget)){
+    var di=parseInt(col.dataset.dayidx);
+    var g=document.getElementById('ghost'+di); if(g) g.style.display='none';
+  }
+}
+
+function wkDrop(e,col){
+  e.preventDefault(); if(!S.dragId) return;
+  _stopEdgeScroll();  // stop any edge-scroll immediately on drop
+
+  var ev=S.events.find(function(x){ return x.id===S.dragId; });
+  if(!ev){ evDragEnd(); return; }
+
+  var di=parseInt(col.dataset.dayidx);
+  var rect=col.getBoundingClientRect();
+  var relY=(e.clientY-rect.top)-S.dragOffsetPx;
+  var snapped=Math.round(relY/SNAP_MINS)*SNAP_MINS;
+  snapped=Math.max(0,Math.min(snapped,23*60));
+
+  var dur=new Date(ev.end).getTime()-new Date(ev.start).getTime();
+  var targetDay=addDays(viewStartEnd().start,di);
+  var newStart=new Date(targetDay.getFullYear(),targetDay.getMonth(),targetDay.getDate(),Math.floor(snapped/60),snapped%60,0);
+  ev.start=newStart.getTime(); ev.end=newStart.getTime()+dur;
+
+  // Capture the scroll position *before* render() rebuilds the DOM.
+  // renderWeekView() will restore this value instead of jumping to 7 am.
+  var gs=document.getElementById('gridScroll');
+  if(gs) S.gridScrollTop=gs.scrollTop;
+
+  evDragEnd(); apiSaveEvent(ev,null); render();
+}
+
+document.addEventListener('dragend',evDragEnd);
+
+// ── Time picker ───────────────────────────────────────────────────────
+var TIME_OPTS=(function(){
+  var o=[];
+  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});
+  return o;
+})();
+
+function openTimePicker(target,anchorEl){
+  closeAllPopups(); S.pickerTarget=target;
+  anchorEl.classList.add('open');
+  var ref=target==='start'?S.modalStart:S.modalEnd;
+  var curH=ref?ref.getHours():9, curM=ref?ref.getMinutes():0;
+  var tp=document.getElementById('timePicker');
+  tp.innerHTML=TIME_OPTS.map(function(o,i){
+    return '<div class="tp-opt'+(o.h===curH&&o.m===curM?' sel':'')+'" data-idx="'+i+'">'+o.label+'</div>';
+  }).join('');
+  tp.style.display='block';
+  var rect=anchorEl.getBoundingClientRect();
+  var top=rect.bottom+4, left=rect.left;
+  if(left+160>window.innerWidth) left=window.innerWidth-168;
+  if(top+220>window.innerHeight) top=rect.top-224;
+  tp.style.top=top+'px'; tp.style.left=left+'px';
+  var sel=tp.querySelector('.sel'); if(sel) sel.scrollIntoView({block:'center'});
+  tp.querySelectorAll('.tp-opt').forEach(function(opt){
+    opt.addEventListener('click',function(){
+      var o=TIME_OPTS[parseInt(opt.dataset.idx)];
+      if(S.pickerTarget==='start'){
+        var d=S.modalStart||new Date();
+        S.modalStart=new Date(d.getFullYear(),d.getMonth(),d.getDate(),o.h,o.m,0);
+        // If end is now before or equal to start, bump end date to start+1 day keeping time
+        if(S.modalEnd&&S.modalEnd.getTime()<=S.modalStart.getTime()){
+          S.modalEnd=new Date(S.modalStart.getFullYear(),S.modalStart.getMonth(),S.modalStart.getDate()+1,S.modalEnd.getHours(),S.modalEnd.getMinutes(),0);
+        }
+      } else {
+        var d=S.modalEnd||new Date();
+        S.modalEnd=new Date(d.getFullYear(),d.getMonth(),d.getDate(),o.h,o.m,0);
+        // If end ≤ start, move end to next day (allows overnight events)
+        if(S.modalStart&&S.modalEnd.getTime()<=S.modalStart.getTime()){
+          S.modalEnd=new Date(S.modalEnd.getTime()+86400000);
+        }
+      }
+      refreshDateTimeFields();
+      validateTimes();
+      closeAllPopups();
+    });
+  });
+}
+
+// ── Date picker ───────────────────────────────────────────────────────
+var dpYear, dpMonth;
+function openDatePicker(target,anchorEl){
+  closeAllPopups(); S.dpTarget=target;
+  anchorEl.classList.add('open');
+  var ref=target==='start'?S.modalStart:S.modalEnd;
+  if(!ref) ref=new Date();
+  dpYear=ref.getFullYear(); dpMonth=ref.getMonth();
+  renderDatePicker(); var dp=document.getElementById('datePicker');
+  dp.style.display='block';
+  var rect=anchorEl.getBoundingClientRect(), top=rect.bottom+4, left=rect.left;
+  if(left+230>window.innerWidth) left=window.innerWidth-238;
+  if(top+260>window.innerHeight) top=rect.top-264;
+  dp.style.top=top+'px'; dp.style.left=left+'px';
+}
+function renderDatePicker(){
+  var pickedMs=(S.dpTarget==='start'?(S.modalStart?midnight(S.modalStart).getTime():0):(S.modalEnd?midnight(S.modalEnd).getTime():0));
+  var dim=new Date(dpYear,dpMonth+1,0).getDate(), dow=new Date(dpYear,dpMonth,1).getDay(), todMs=midnight(new Date()).getTime();
+  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">';
+  ['S','M','T','W','T','F','S'].forEach(function(d){ html+='<div class="dp-dow">'+d+'</div>'; });
+  for(var i=0;i<dow;i++) html+='<div class="dp-day other"></div>';
+  for(var day=1;day<=dim;day++){
+    var ms=new Date(dpYear,dpMonth,day).setHours(0,0,0,0);
+    html+='<div class="dp-day'+(ms===todMs?' today':'')+(ms===pickedMs?' picked':'')+'" onclick="dpSelect('+dpYear+','+dpMonth+','+day+')">'+day+'</div>';
+  }
+  html+='</div>';
+  document.getElementById('datePicker').innerHTML=html;
+}
+function dpNav(dir){ dpMonth+=dir; if(dpMonth<0){dpMonth=11;dpYear--;} if(dpMonth>11){dpMonth=0;dpYear++;} renderDatePicker(); }
+function dpSelect(y,mo,day){
+  if(S.dpTarget==='start'){
+    var h=S.modalStart?S.modalStart.getHours():9, m=S.modalStart?S.modalStart.getMinutes():0;
+    var prev=S.modalStart?midnight(S.modalStart).getTime():0;
+    S.modalStart=new Date(y,mo,day,h,m,0);
+    // Shift end date by the same delta if start date changed
+    if(S.modalEnd){
+      var delta=midnight(S.modalStart).getTime()-prev;
+      if(delta!==0) S.modalEnd=new Date(S.modalEnd.getTime()+delta);
+    }
+  } else {
+    var h=S.modalEnd?S.modalEnd.getHours():10, m=S.modalEnd?S.modalEnd.getMinutes():0;
+    S.modalEnd=new Date(y,mo,day,h,m,0);
+    // Ensure end is not before start
+    if(S.modalStart&&S.modalEnd.getTime()<=S.modalStart.getTime()){
+      S.modalEnd=new Date(S.modalStart.getTime()+3600000);
+    }
+  }
+  refreshDateTimeFields(); validateTimes(); closeAllPopups();
+}
+
+function closeAllPopups(){
+  document.getElementById('timePicker').style.display='none';
+  document.getElementById('datePicker').style.display='none';
+  document.querySelectorAll('.date-fld,.time-fld').forEach(function(el){ el.classList.remove('open'); });
+}
+
+document.addEventListener('click',function(e){
+  var tp=document.getElementById('timePicker'),dp=document.getElementById('datePicker');
+  if(!tp.contains(e.target)&&!dp.contains(e.target)&&!e.target.classList.contains('time-fld')&&!e.target.classList.contains('date-fld')){
+    if(tp.style.display!=='none'||dp.style.display!=='none') closeAllPopups();
+  }
+},true);
+
+// ── Event modal ───────────────────────────────────────────────────────
+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); }
+function selectColor(c){ document.querySelectorAll('.cswatch').forEach(function(el){ el.classList.toggle('sel',el.dataset.color===c); }); updateColorDot(c); }
+function updateColorDot(c){ document.getElementById('modalColorDot').style.background=COLOR_HEX[c]||COLOR_HEX.blue; }
+function getSelectedColor(){ var s=document.querySelector('.cswatch.sel'); return s?s.dataset.color:'blue'; }
+
+function refreshDateTimeFields(){
+  var sdf=document.getElementById('startDateFld'), stf=document.getElementById('startTimeFld');
+  var edf=document.getElementById('endDateFld'),   etf=document.getElementById('endTimeFld');
+  var adf=document.getElementById('adStartFld');
+  if(S.modalStart){
+    if(sdf) sdf.textContent=fmtDateShort(S.modalStart);
+    if(stf) stf.textContent=fmt12(S.modalStart);
+    if(adf) adf.textContent=fmtDateShort(S.modalStart);
+  }
+  if(S.modalEnd){
+    if(edf) edf.textContent=fmtDateShort(S.modalEnd);
+    if(etf) etf.textContent=fmt12(S.modalEnd);
+  }
+}
+
+function validateTimes(){
+  var errEl=document.getElementById('timeErr'); if(!errEl) return;
+  var allDay=document.getElementById('evAllDay').checked;
+  var bad=!allDay&&S.modalStart&&S.modalEnd&&S.modalEnd.getTime()<=S.modalStart.getTime();
+  errEl.style.display=bad?'block':'none';
+}
+
+function onAllDayChange(){
+  var ad=document.getElementById('evAllDay').checked;
+  document.getElementById('dtRowTimed').style.display=ad?'none':'flex';
+  document.getElementById('dtRowAllDay').style.display=ad?'flex':'none';
+}
+
+function openModal(ev,defaults){
+  S.editId=ev?ev.id:null;
+  if(ev){
+    S.modalStart=new Date(ev.start); S.modalEnd=new Date(ev.end);
+    document.getElementById('evTitle').value=ev.title||'';
+    document.getElementById('evAllDay').checked=!!ev.allDay;
+    document.getElementById('evAddress').value=ev.address||'';
+    document.getElementById('evNotes').value=ev.notes||'';
+    var rv=''; if(ev.reminder&&ev.reminder.unit) rv=ev.reminder.value+':'+ev.reminder.unit;
+    document.getElementById('evReminder').value=rv;
+    buildColorSwatches(ev.color||'blue');
+    document.getElementById('deleteEvBtn').classList.remove('hidden');
+  } else {
+    var now=new Date();
+    var s=defaults&&defaults.start||new Date(now.getFullYear(),now.getMonth(),now.getDate(),9,0,0);
+    var e2=defaults&&defaults.end||addMinutes(s,60);
+    S.modalStart=s; S.modalEnd=e2;
+    document.getElementById('evTitle').value='';
+    document.getElementById('evAllDay').checked=!!(defaults&&defaults.allDay);
+    document.getElementById('evAddress').value='';
+    document.getElementById('evNotes').value='';
+    document.getElementById('evReminder').value='';
+    buildColorSwatches('blue');
+    document.getElementById('deleteEvBtn').classList.add('hidden');
+  }
+  onAllDayChange(); refreshDateTimeFields();
+  document.getElementById('timeErr').style.display='none';
+  document.getElementById('eventModal').classList.remove('hidden');
+  setTimeout(function(){ document.getElementById('evTitle').focus(); },50);
+}
+
+function closeModal(){ closeAllPopups(); document.getElementById('eventModal').classList.add('hidden'); S.editId=null; }
+
+function saveEvent(){
+  var title=document.getElementById('evTitle').value.trim();
+  if(!title){ document.getElementById('evTitle').focus(); snack(L('calendar/snack/enter-title','Please enter a title')); return; }
+  var allDay=document.getElementById('evAllDay').checked;
+  // Validate times for non-all-day events
+  if(!allDay&&S.modalEnd&&S.modalStart&&S.modalEnd.getTime()<=S.modalStart.getTime()){
+    document.getElementById('timeErr').style.display='block';
+    snack(L('calendar/snack/time-err','End time must be after start time')); return;
+  }
+  var rVal=document.getElementById('evReminder').value, reminder=null;
+  if(rVal){ var parts=rVal.split(':'); reminder={value:parseInt(parts[0],10),unit:parts[1]}; }
+  var ev={
+    id:S.editId||'',title:title,allDay:allDay,
+    start:S.modalStart?S.modalStart.getTime():Date.now(),
+    end:S.modalEnd?S.modalEnd.getTime():Date.now()+3600000,
+    address:document.getElementById('evAddress').value.trim(),
+    notes:document.getElementById('evNotes').value.trim(),
+    reminder:reminder,color:getSelectedColor()
+  };
+  if(allDay){ var d=S.modalStart||new Date(); ev.start=midnight(d).getTime(); ev.end=ev.start+86399000; }
+  var isNew=!ev.id;
+  if(isNew){ ev.id='ev_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6); S.events.push(ev); }
+  else { for(var i=0;i<S.events.length;i++) if(S.events[i].id===ev.id){ S.events[i]=ev; break; } }
+  apiSaveEvent(ev,null); closeModal(); render(); snack(isNew?L('calendar/snack/event-created','Event created'):L('calendar/snack/event-saved','Event saved'));
+}
+
+function deleteCurrentEvent(){
+  if(!S.editId||!confirm('Delete this event?')) return;
+  S.events=S.events.filter(function(e){ return e.id!==S.editId; });
+  apiDeleteEvent(S.editId,null); closeModal(); render(); snack(L('calendar/snack/event-deleted','Event deleted'));
+}
+
+// ── ICS ───────────────────────────────────────────────────────────────
+function fmtICSDate(d){ var y=d.getFullYear(),mo=d.getMonth()+1,day=d.getDate(); return y+(mo<10?'0':'')+mo+(day<10?'0':'')+day; }
+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; }
+function escICS(s){ return (s||'').replace(/\\/g,'\\\\').replace(/\n/g,'\\n'); }
+function exportICS(){
+  if(!S.events.length){ snack(L('calendar/snack/no-events','No events to export')); return; }
+  var lines=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//ArOZ Calendar//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH'];
+  S.events.forEach(function(ev){
+    lines.push('BEGIN:VEVENT','UID:'+ev.id+'@arozos');
+    var s=new Date(ev.start),e=new Date(ev.end);
+    if(ev.allDay){ lines.push('DTSTART;VALUE=DATE:'+fmtICSDate(s),'DTEND;VALUE=DATE:'+fmtICSDate(addDays(e,1))); }
+    else { lines.push('DTSTART:'+fmtICSDateTime(s),'DTEND:'+fmtICSDateTime(e)); }
+    lines.push('SUMMARY:'+escICS(ev.title));
+    if(ev.address) lines.push('LOCATION:'+escICS(ev.address));
+    if(ev.notes)   lines.push('DESCRIPTION:'+escICS(ev.notes));
+    if(ev.reminder&&ev.reminder.unit){
+      var mins=ev.reminder.unit==='mins'?ev.reminder.value:ev.reminder.unit==='hours'?ev.reminder.value*60:ev.reminder.value*1440;
+      lines.push('BEGIN:VALARM','ACTION:DISPLAY','DESCRIPTION:Reminder','TRIGGER:-PT'+mins+'M','END:VALARM');
+    }
+    lines.push('END:VEVENT');
+  });
+  lines.push('END:VCALENDAR');
+  var blob=new Blob([lines.join('\r\n')],{type:'text/calendar;charset=utf-8'});
+  var url=URL.createObjectURL(blob), a=document.createElement('a');
+  a.href=url; a.download='calendar.ics'; document.body.appendChild(a); a.click();
+  setTimeout(function(){ document.body.removeChild(a); URL.revokeObjectURL(url); },100);
+  snack(L('calendar/snack/exported-n','Exported {n} event(s)').replace('{n}',S.events.length));
+}
+function importFromComputer(){
+  ao_module_selectFiles(function(files){
+    if(!files||!files.length) return;
+    var r=new FileReader(); r.onload=function(ev){ processICSText(ev.target.result); }; r.readAsText(files[0]);
+  },'file','.ics',false);
+}
+function importFromFiles(){
+  ao_module_openFileSelector(function(sel){
+    if(!sel||!sel.length) return;
+    apiImportIcs(sel[0].filepath||sel[0],function(resp){
+      if(resp.error){ snack(L('calendar/snack/import-failed','Import failed')+': '+resp.error); return; }
+      mergeImportedEvents(resp.events||[]);
+    });
+  },'user:/','file',false);
+}
+function processICSText(text){
+  var lines=text.replace(/\r\n/g,'\n').replace(/\r/g,'\n').split('\n'), unfolded=[];
+  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); });
+  var events=[],cur=null,inAlarm=false;
+  unfolded.forEach(function(line){
+    if(line==='BEGIN:VEVENT'){ cur={id:'',title:'',allDay:false,start:0,end:0,address:'',notes:'',reminder:null,color:'blue'}; inAlarm=false; }
+    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; }
+    else if(line==='BEGIN:VALARM') inAlarm=true;
+    else if(line==='END:VALARM') inAlarm=false;
+    else if(cur){
+      var ci=line.indexOf(':'); if(ci<0) return;
+      var key=line.slice(0,ci).split(';')[0].toUpperCase(), val=line.slice(ci+1);
+      if(key==='UID') cur.id='ics_'+val.replace(/[^a-zA-Z0-9_-]/g,'_').slice(0,40);
+      else if(key==='SUMMARY') cur.title=val;
+      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(); }
+      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(); }
+      else if(key==='LOCATION') cur.address=val;
+      else if(key==='DESCRIPTION') cur.notes=val.replace(/\\n/g,'\n').replace(/\\,/g,',');
+      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'}; } }
+    }
+  });
+  mergeImportedEvents(events);
+}
+function mergeImportedEvents(imported){
+  if(!imported.length){ snack(L('calendar/snack/no-events-in-file','No events found in file')); return; }
+  var idx={}; S.events.forEach(function(e){ idx[e.id]=true; });
+  var added=0;
+  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; } });
+  apiSaveEvents(S.events,function(){ render(); snack(L('calendar/snack/imported-n','Imported {n} new event(s)').replace('{n}',added)); });
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────
+function escHtml(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
+
+// ── Boot ──────────────────────────────────────────────────────────────
+(function boot(){
+  // ── 1. Load locale strings first so every rendered string is translated ──
+  if(typeof applocale !== 'undefined' && applocale){
+    applocale.init('locale/calendar.json', function(){
+      applocale.translate();   // translate all static locale= / title= / placeholder= elements
+      applyLocaleData();       // refresh JS-side arrays (month names, day names, AM/PM)
+      startApp();
+    });
+  } else {
+    startApp();  // applocale unavailable – run with English defaults
+  }
+
+  function startApp(){
+    ao_module_getSystemThemeColor(function(color){
+      applyDark(color!=='whiteTheme');
+      switchView('week');
+      apiLoadEvents(function(){
+        if(window.location.hash.length>1){
+          try{
+            var payload=JSON.parse(decodeURIComponent(window.location.hash.slice(1)));
+            if(Array.isArray(payload)&&payload.length&&payload[0].filepath&&payload[0].filepath.toLowerCase().endsWith('.ics')){
+              apiImportIcs(payload[0].filepath,function(resp){
+                if(resp&&resp.events&&resp.events.length) mergeImportedEvents(resp.events);
+                else render();
+              }); return;
+            }
+          } catch(e){}
+        }
+        render();
+      });
+    });
+  }
+})();
+</script>
+</body>
+</html>

+ 19 - 0
src/web/Calendar/init.agi

@@ -0,0 +1,19 @@
+/*
+    Calendar Module Registration
+*/
+
+var moduleLaunchInfo = {
+    Name: "Calendar",
+    Desc: "A calendar app for scheduling and managing events",
+    Group: "Office",
+    IconPath: "Calendar/img/icon.svg",
+    Version: "1.0",
+    StartDir: "Calendar/index.html",
+    SupportFW: true,
+    LaunchFWDir: "Calendar/index.html",
+    SupportEmb: false,
+    InitFWSize: [1100, 700],
+    SupportedExt: [".ics"]
+}
+
+registerModule(JSON.stringify(moduleLaunchInfo));

+ 550 - 0
src/web/Calendar/locale/calendar.json

@@ -0,0 +1,550 @@
+{
+    "author": "tobychui",
+    "version": "1.0",
+    "keys": {
+        "en-us": {
+            "name": "English (US)",
+            "strings": {
+                "calendar/app/title": "Calendar",
+                "calendar/btn/today": "Today",
+                "calendar/view/day": "Day",
+                "calendar/view/workweek": "Work Week",
+                "calendar/view/week": "Week",
+                "calendar/view/month": "Month",
+                "calendar/sidebar/section-title": "Import / Export",
+                "calendar/sidebar/import-computer": "Import from Computer",
+                "calendar/sidebar/import-files": "Import from Files",
+                "calendar/sidebar/export-ics": "Export as .ics",
+                "calendar/modal/allday": "All-day",
+                "calendar/modal/from": "From",
+                "calendar/modal/to": "To",
+                "calendar/modal/date": "Date",
+                "calendar/modal/time-err": "End time must be after start time",
+                "calendar/reminder/none": "None",
+                "calendar/reminder/5min": "5 minutes before",
+                "calendar/reminder/10min": "10 minutes before",
+                "calendar/reminder/15min": "15 minutes before",
+                "calendar/reminder/30min": "30 minutes before",
+                "calendar/reminder/1hour": "1 hour before",
+                "calendar/reminder/2hours": "2 hours before",
+                "calendar/reminder/1day": "1 day before",
+                "calendar/reminder/2days": "2 days before",
+                "calendar/reminder/1week": "1 week before",
+                "calendar/modal/delete": "Delete",
+                "calendar/modal/cancel": "Cancel",
+                "calendar/modal/save": "Save",
+                "calendar/allday-label": "all-day",
+                "calendar/snack/event-created": "Event created",
+                "calendar/snack/event-saved": "Event saved",
+                "calendar/snack/event-deleted": "Event deleted",
+                "calendar/snack/no-events": "No events to export",
+                "calendar/snack/no-events-in-file": "No events found in file",
+                "calendar/snack/import-failed": "Import failed",
+                "calendar/snack/enter-title": "Please enter a title",
+                "calendar/snack/time-err": "End time must be after start time",
+                "calendar/snack/exported-n": "Exported {n} event(s)",
+                "calendar/snack/imported-n": "Imported {n} new event(s)",
+                "calendar/time/am": "AM",
+                "calendar/time/pm": "PM",
+                "calendar/day/sun": "Sun",
+                "calendar/day/mon": "Mon",
+                "calendar/day/tue": "Tue",
+                "calendar/day/wed": "Wed",
+                "calendar/day/thu": "Thu",
+                "calendar/day/fri": "Fri",
+                "calendar/day/sat": "Sat",
+                "calendar/month/january": "January",
+                "calendar/month/february": "February",
+                "calendar/month/march": "March",
+                "calendar/month/april": "April",
+                "calendar/month/may": "May",
+                "calendar/month/june": "June",
+                "calendar/month/july": "July",
+                "calendar/month/august": "August",
+                "calendar/month/september": "September",
+                "calendar/month/october": "October",
+                "calendar/month/november": "November",
+                "calendar/month/december": "December",
+                "calendar/month/jan": "Jan",
+                "calendar/month/feb": "Feb",
+                "calendar/month/mar": "Mar",
+                "calendar/month/apr": "Apr",
+                "calendar/month/may-short": "May",
+                "calendar/month/jun": "Jun",
+                "calendar/month/jul": "Jul",
+                "calendar/month/aug": "Aug",
+                "calendar/month/sep": "Sep",
+                "calendar/month/oct": "Oct",
+                "calendar/month/nov": "Nov",
+                "calendar/month/dec": "Dec"
+            },
+            "titles": {
+                "Toggle dark mode": "Toggle dark mode",
+                "New Event": "New Event",
+                "Import from Computer": "Import from Computer",
+                "Import from Files": "Import from Files",
+                "Export as .ics": "Export as .ics"
+            },
+            "placeholder": {
+                "New Event": "New Event",
+                "Add location": "Add location",
+                "Add notes": "Add notes"
+            }
+        },
+        "zh-tw": {
+            "name": "繁體中文(台灣)",
+            "fwtitle": "行事曆",
+            "fontFamily": "\"Microsoft JhengHei\", \"Apple LiGothic Medium\", \"STHeiti\", sans-serif",
+            "strings": {
+                "calendar/app/title": "行事曆",
+                "calendar/btn/today": "今天",
+                "calendar/view/day": "日",
+                "calendar/view/workweek": "工作週",
+                "calendar/view/week": "週",
+                "calendar/view/month": "月",
+                "calendar/sidebar/section-title": "匯入 / 匯出",
+                "calendar/sidebar/import-computer": "從電腦匯入",
+                "calendar/sidebar/import-files": "從檔案匯入",
+                "calendar/sidebar/export-ics": "匯出為 .ics",
+                "calendar/modal/allday": "整天",
+                "calendar/modal/from": "從",
+                "calendar/modal/to": "到",
+                "calendar/modal/date": "日期",
+                "calendar/modal/time-err": "結束時間必須晚於開始時間",
+                "calendar/reminder/none": "無",
+                "calendar/reminder/5min": "5 分鐘前",
+                "calendar/reminder/10min": "10 分鐘前",
+                "calendar/reminder/15min": "15 分鐘前",
+                "calendar/reminder/30min": "30 分鐘前",
+                "calendar/reminder/1hour": "1 小時前",
+                "calendar/reminder/2hours": "2 小時前",
+                "calendar/reminder/1day": "1 天前",
+                "calendar/reminder/2days": "2 天前",
+                "calendar/reminder/1week": "1 週前",
+                "calendar/modal/delete": "刪除",
+                "calendar/modal/cancel": "取消",
+                "calendar/modal/save": "儲存",
+                "calendar/allday-label": "整天",
+                "calendar/snack/event-created": "活動已建立",
+                "calendar/snack/event-saved": "活動已儲存",
+                "calendar/snack/event-deleted": "活動已刪除",
+                "calendar/snack/no-events": "沒有可匯出的活動",
+                "calendar/snack/no-events-in-file": "在檔案中找不到活動",
+                "calendar/snack/import-failed": "匯入失敗",
+                "calendar/snack/enter-title": "請輸入標題",
+                "calendar/snack/time-err": "結束時間必須晚於開始時間",
+                "calendar/snack/exported-n": "已匯出 {n} 個活動",
+                "calendar/snack/imported-n": "已匯入 {n} 個新活動",
+                "calendar/time/am": "上午",
+                "calendar/time/pm": "下午",
+                "calendar/day/sun": "日",
+                "calendar/day/mon": "一",
+                "calendar/day/tue": "二",
+                "calendar/day/wed": "三",
+                "calendar/day/thu": "四",
+                "calendar/day/fri": "五",
+                "calendar/day/sat": "六",
+                "calendar/month/january": "一月",
+                "calendar/month/february": "二月",
+                "calendar/month/march": "三月",
+                "calendar/month/april": "四月",
+                "calendar/month/may": "五月",
+                "calendar/month/june": "六月",
+                "calendar/month/july": "七月",
+                "calendar/month/august": "八月",
+                "calendar/month/september": "九月",
+                "calendar/month/october": "十月",
+                "calendar/month/november": "十一月",
+                "calendar/month/december": "十二月",
+                "calendar/month/jan": "1月",
+                "calendar/month/feb": "2月",
+                "calendar/month/mar": "3月",
+                "calendar/month/apr": "4月",
+                "calendar/month/may-short": "5月",
+                "calendar/month/jun": "6月",
+                "calendar/month/jul": "7月",
+                "calendar/month/aug": "8月",
+                "calendar/month/sep": "9月",
+                "calendar/month/oct": "10月",
+                "calendar/month/nov": "11月",
+                "calendar/month/dec": "12月"
+            },
+            "titles": {
+                "Toggle dark mode": "切換深色/淺色主題",
+                "New Event": "新增活動",
+                "Import from Computer": "從電腦匯入",
+                "Import from Files": "從檔案匯入",
+                "Export as .ics": "匯出為 .ics"
+            },
+            "placeholder": {
+                "New Event": "新增活動",
+                "Add location": "新增地點",
+                "Add notes": "新增備註"
+            }
+        },
+        "zh-hk": {
+            "name": "繁體中文(香港)",
+            "fwtitle": "日曆",
+            "fontFamily": "\"Microsoft JhengHei\", \"Apple LiGothic Medium\", \"STHeiti\", sans-serif",
+            "strings": {
+                "calendar/app/title": "日曆",
+                "calendar/btn/today": "今天",
+                "calendar/view/day": "日",
+                "calendar/view/workweek": "工作週",
+                "calendar/view/week": "週",
+                "calendar/view/month": "月",
+                "calendar/sidebar/section-title": "匯入 / 匯出",
+                "calendar/sidebar/import-computer": "從電腦匯入",
+                "calendar/sidebar/import-files": "從檔案匯入",
+                "calendar/sidebar/export-ics": "匯出為 .ics",
+                "calendar/modal/allday": "全日",
+                "calendar/modal/from": "由",
+                "calendar/modal/to": "至",
+                "calendar/modal/date": "日期",
+                "calendar/modal/time-err": "結束時間必須晚於開始時間",
+                "calendar/reminder/none": "無",
+                "calendar/reminder/5min": "5 分鐘前",
+                "calendar/reminder/10min": "10 分鐘前",
+                "calendar/reminder/15min": "15 分鐘前",
+                "calendar/reminder/30min": "30 分鐘前",
+                "calendar/reminder/1hour": "1 小時前",
+                "calendar/reminder/2hours": "2 小時前",
+                "calendar/reminder/1day": "1 日前",
+                "calendar/reminder/2days": "2 日前",
+                "calendar/reminder/1week": "1 星期前",
+                "calendar/modal/delete": "刪除",
+                "calendar/modal/cancel": "取消",
+                "calendar/modal/save": "儲存",
+                "calendar/allday-label": "全日",
+                "calendar/snack/event-created": "活動已建立",
+                "calendar/snack/event-saved": "活動已儲存",
+                "calendar/snack/event-deleted": "活動已刪除",
+                "calendar/snack/no-events": "冇可匯出嘅活動",
+                "calendar/snack/no-events-in-file": "喺檔案中搵唔到活動",
+                "calendar/snack/import-failed": "匯入失敗",
+                "calendar/snack/enter-title": "請輸入標題",
+                "calendar/snack/time-err": "結束時間必須晚於開始時間",
+                "calendar/snack/exported-n": "已匯出 {n} 個活動",
+                "calendar/snack/imported-n": "已匯入 {n} 個新活動",
+                "calendar/time/am": "上午",
+                "calendar/time/pm": "下午",
+                "calendar/day/sun": "日",
+                "calendar/day/mon": "一",
+                "calendar/day/tue": "二",
+                "calendar/day/wed": "三",
+                "calendar/day/thu": "四",
+                "calendar/day/fri": "五",
+                "calendar/day/sat": "六",
+                "calendar/month/january": "一月",
+                "calendar/month/february": "二月",
+                "calendar/month/march": "三月",
+                "calendar/month/april": "四月",
+                "calendar/month/may": "五月",
+                "calendar/month/june": "六月",
+                "calendar/month/july": "七月",
+                "calendar/month/august": "八月",
+                "calendar/month/september": "九月",
+                "calendar/month/october": "十月",
+                "calendar/month/november": "十一月",
+                "calendar/month/december": "十二月",
+                "calendar/month/jan": "1月",
+                "calendar/month/feb": "2月",
+                "calendar/month/mar": "3月",
+                "calendar/month/apr": "4月",
+                "calendar/month/may-short": "5月",
+                "calendar/month/jun": "6月",
+                "calendar/month/jul": "7月",
+                "calendar/month/aug": "8月",
+                "calendar/month/sep": "9月",
+                "calendar/month/oct": "10月",
+                "calendar/month/nov": "11月",
+                "calendar/month/dec": "12月"
+            },
+            "titles": {
+                "Toggle dark mode": "切換深色/淺色主題",
+                "New Event": "新增活動",
+                "Import from Computer": "從電腦匯入",
+                "Import from Files": "從檔案匯入",
+                "Export as .ics": "匯出為 .ics"
+            },
+            "placeholder": {
+                "New Event": "新增活動",
+                "Add location": "新增地點",
+                "Add notes": "新增備註"
+            }
+        },
+        "zh-cn": {
+            "name": "简体中文",
+            "fwtitle": "日历",
+            "fontFamily": "\"Microsoft YaHei\", \"PingFang SC\", \"SimHei\", sans-serif",
+            "strings": {
+                "calendar/app/title": "日历",
+                "calendar/btn/today": "今天",
+                "calendar/view/day": "日",
+                "calendar/view/workweek": "工作周",
+                "calendar/view/week": "周",
+                "calendar/view/month": "月",
+                "calendar/sidebar/section-title": "导入 / 导出",
+                "calendar/sidebar/import-computer": "从电脑导入",
+                "calendar/sidebar/import-files": "从文件导入",
+                "calendar/sidebar/export-ics": "导出为 .ics",
+                "calendar/modal/allday": "全天",
+                "calendar/modal/from": "从",
+                "calendar/modal/to": "至",
+                "calendar/modal/date": "日期",
+                "calendar/modal/time-err": "结束时间必须晚于开始时间",
+                "calendar/reminder/none": "无",
+                "calendar/reminder/5min": "5 分钟前",
+                "calendar/reminder/10min": "10 分钟前",
+                "calendar/reminder/15min": "15 分钟前",
+                "calendar/reminder/30min": "30 分钟前",
+                "calendar/reminder/1hour": "1 小时前",
+                "calendar/reminder/2hours": "2 小时前",
+                "calendar/reminder/1day": "1 天前",
+                "calendar/reminder/2days": "2 天前",
+                "calendar/reminder/1week": "1 周前",
+                "calendar/modal/delete": "删除",
+                "calendar/modal/cancel": "取消",
+                "calendar/modal/save": "保存",
+                "calendar/allday-label": "全天",
+                "calendar/snack/event-created": "日程已创建",
+                "calendar/snack/event-saved": "日程已保存",
+                "calendar/snack/event-deleted": "日程已删除",
+                "calendar/snack/no-events": "没有可导出的日程",
+                "calendar/snack/no-events-in-file": "文件中没有找到日程",
+                "calendar/snack/import-failed": "导入失败",
+                "calendar/snack/enter-title": "请输入标题",
+                "calendar/snack/time-err": "结束时间必须晚于开始时间",
+                "calendar/snack/exported-n": "已导出 {n} 个日程",
+                "calendar/snack/imported-n": "已导入 {n} 个新日程",
+                "calendar/time/am": "上午",
+                "calendar/time/pm": "下午",
+                "calendar/day/sun": "日",
+                "calendar/day/mon": "一",
+                "calendar/day/tue": "二",
+                "calendar/day/wed": "三",
+                "calendar/day/thu": "四",
+                "calendar/day/fri": "五",
+                "calendar/day/sat": "六",
+                "calendar/month/january": "一月",
+                "calendar/month/february": "二月",
+                "calendar/month/march": "三月",
+                "calendar/month/april": "四月",
+                "calendar/month/may": "五月",
+                "calendar/month/june": "六月",
+                "calendar/month/july": "七月",
+                "calendar/month/august": "八月",
+                "calendar/month/september": "九月",
+                "calendar/month/october": "十月",
+                "calendar/month/november": "十一月",
+                "calendar/month/december": "十二月",
+                "calendar/month/jan": "1月",
+                "calendar/month/feb": "2月",
+                "calendar/month/mar": "3月",
+                "calendar/month/apr": "4月",
+                "calendar/month/may-short": "5月",
+                "calendar/month/jun": "6月",
+                "calendar/month/jul": "7月",
+                "calendar/month/aug": "8月",
+                "calendar/month/sep": "9月",
+                "calendar/month/oct": "10月",
+                "calendar/month/nov": "11月",
+                "calendar/month/dec": "12月"
+            },
+            "titles": {
+                "Toggle dark mode": "切换深色/浅色主题",
+                "New Event": "新建日程",
+                "Import from Computer": "从电脑导入",
+                "Import from Files": "从文件导入",
+                "Export as .ics": "导出为 .ics"
+            },
+            "placeholder": {
+                "New Event": "新建日程",
+                "Add location": "添加位置",
+                "Add notes": "添加备注"
+            }
+        },
+        "ja-jp": {
+            "name": "日本語",
+            "fwtitle": "カレンダー",
+            "fontFamily": "\"Hiragino Kaku Gothic Pro\", \"Meiryo\", \"MS Gothic\", sans-serif",
+            "strings": {
+                "calendar/app/title": "カレンダー",
+                "calendar/btn/today": "今日",
+                "calendar/view/day": "日",
+                "calendar/view/workweek": "週(平日)",
+                "calendar/view/week": "週",
+                "calendar/view/month": "月",
+                "calendar/sidebar/section-title": "インポート / エクスポート",
+                "calendar/sidebar/import-computer": "コンピューターからインポート",
+                "calendar/sidebar/import-files": "ファイルからインポート",
+                "calendar/sidebar/export-ics": ".ics としてエクスポート",
+                "calendar/modal/allday": "終日",
+                "calendar/modal/from": "開始",
+                "calendar/modal/to": "終了",
+                "calendar/modal/date": "日付",
+                "calendar/modal/time-err": "終了時間は開始時間より後にしてください",
+                "calendar/reminder/none": "なし",
+                "calendar/reminder/5min": "5分前",
+                "calendar/reminder/10min": "10分前",
+                "calendar/reminder/15min": "15分前",
+                "calendar/reminder/30min": "30分前",
+                "calendar/reminder/1hour": "1時間前",
+                "calendar/reminder/2hours": "2時間前",
+                "calendar/reminder/1day": "1日前",
+                "calendar/reminder/2days": "2日前",
+                "calendar/reminder/1week": "1週間前",
+                "calendar/modal/delete": "削除",
+                "calendar/modal/cancel": "キャンセル",
+                "calendar/modal/save": "保存",
+                "calendar/allday-label": "終日",
+                "calendar/snack/event-created": "予定を作成しました",
+                "calendar/snack/event-saved": "予定を保存しました",
+                "calendar/snack/event-deleted": "予定を削除しました",
+                "calendar/snack/no-events": "エクスポートする予定がありません",
+                "calendar/snack/no-events-in-file": "ファイルに予定が見つかりませんでした",
+                "calendar/snack/import-failed": "インポートに失敗しました",
+                "calendar/snack/enter-title": "タイトルを入力してください",
+                "calendar/snack/time-err": "終了時間は開始時間より後にしてください",
+                "calendar/snack/exported-n": "{n}件の予定をエクスポートしました",
+                "calendar/snack/imported-n": "{n}件の予定をインポートしました",
+                "calendar/time/am": "午前",
+                "calendar/time/pm": "午後",
+                "calendar/day/sun": "日",
+                "calendar/day/mon": "月",
+                "calendar/day/tue": "火",
+                "calendar/day/wed": "水",
+                "calendar/day/thu": "木",
+                "calendar/day/fri": "金",
+                "calendar/day/sat": "土",
+                "calendar/month/january": "1月",
+                "calendar/month/february": "2月",
+                "calendar/month/march": "3月",
+                "calendar/month/april": "4月",
+                "calendar/month/may": "5月",
+                "calendar/month/june": "6月",
+                "calendar/month/july": "7月",
+                "calendar/month/august": "8月",
+                "calendar/month/september": "9月",
+                "calendar/month/october": "10月",
+                "calendar/month/november": "11月",
+                "calendar/month/december": "12月",
+                "calendar/month/jan": "1月",
+                "calendar/month/feb": "2月",
+                "calendar/month/mar": "3月",
+                "calendar/month/apr": "4月",
+                "calendar/month/may-short": "5月",
+                "calendar/month/jun": "6月",
+                "calendar/month/jul": "7月",
+                "calendar/month/aug": "8月",
+                "calendar/month/sep": "9月",
+                "calendar/month/oct": "10月",
+                "calendar/month/nov": "11月",
+                "calendar/month/dec": "12月"
+            },
+            "titles": {
+                "Toggle dark mode": "ダーク/ライトテーマ切替",
+                "New Event": "新しい予定",
+                "Import from Computer": "コンピューターからインポート",
+                "Import from Files": "ファイルからインポート",
+                "Export as .ics": ".ics としてエクスポート"
+            },
+            "placeholder": {
+                "New Event": "新しい予定",
+                "Add location": "場所を追加",
+                "Add notes": "メモを追加"
+            }
+        },
+        "ko-kr": {
+            "name": "한국어",
+            "fwtitle": "캘린더",
+            "fontFamily": "\"Malgun Gothic\", \"Apple SD Gothic Neo\", sans-serif",
+            "strings": {
+                "calendar/app/title": "캘린더",
+                "calendar/btn/today": "오늘",
+                "calendar/view/day": "일",
+                "calendar/view/workweek": "주(평일)",
+                "calendar/view/week": "주",
+                "calendar/view/month": "월",
+                "calendar/sidebar/section-title": "가져오기 / 내보내기",
+                "calendar/sidebar/import-computer": "컴퓨터에서 가져오기",
+                "calendar/sidebar/import-files": "파일에서 가져오기",
+                "calendar/sidebar/export-ics": ".ics로 내보내기",
+                "calendar/modal/allday": "하루 종일",
+                "calendar/modal/from": "시작",
+                "calendar/modal/to": "종료",
+                "calendar/modal/date": "날짜",
+                "calendar/modal/time-err": "종료 시간은 시작 시간보다 늦어야 합니다",
+                "calendar/reminder/none": "없음",
+                "calendar/reminder/5min": "5분 전",
+                "calendar/reminder/10min": "10분 전",
+                "calendar/reminder/15min": "15분 전",
+                "calendar/reminder/30min": "30분 전",
+                "calendar/reminder/1hour": "1시간 전",
+                "calendar/reminder/2hours": "2시간 전",
+                "calendar/reminder/1day": "1일 전",
+                "calendar/reminder/2days": "2일 전",
+                "calendar/reminder/1week": "1주 전",
+                "calendar/modal/delete": "삭제",
+                "calendar/modal/cancel": "취소",
+                "calendar/modal/save": "저장",
+                "calendar/allday-label": "하루 종일",
+                "calendar/snack/event-created": "일정이 생성되었습니다",
+                "calendar/snack/event-saved": "일정이 저장되었습니다",
+                "calendar/snack/event-deleted": "일정이 삭제되었습니다",
+                "calendar/snack/no-events": "내보낼 일정이 없습니다",
+                "calendar/snack/no-events-in-file": "파일에서 일정을 찾을 수 없습니다",
+                "calendar/snack/import-failed": "가져오기 실패",
+                "calendar/snack/enter-title": "제목을 입력하세요",
+                "calendar/snack/time-err": "종료 시간은 시작 시간보다 늦어야 합니다",
+                "calendar/snack/exported-n": "{n}개의 일정을 내보냈습니다",
+                "calendar/snack/imported-n": "{n}개의 새 일정을 가져왔습니다",
+                "calendar/time/am": "오전",
+                "calendar/time/pm": "오후",
+                "calendar/day/sun": "일",
+                "calendar/day/mon": "월",
+                "calendar/day/tue": "화",
+                "calendar/day/wed": "수",
+                "calendar/day/thu": "목",
+                "calendar/day/fri": "금",
+                "calendar/day/sat": "토",
+                "calendar/month/january": "1월",
+                "calendar/month/february": "2월",
+                "calendar/month/march": "3월",
+                "calendar/month/april": "4월",
+                "calendar/month/may": "5월",
+                "calendar/month/june": "6월",
+                "calendar/month/july": "7월",
+                "calendar/month/august": "8월",
+                "calendar/month/september": "9월",
+                "calendar/month/october": "10월",
+                "calendar/month/november": "11월",
+                "calendar/month/december": "12월",
+                "calendar/month/jan": "1월",
+                "calendar/month/feb": "2월",
+                "calendar/month/mar": "3월",
+                "calendar/month/apr": "4월",
+                "calendar/month/may-short": "5월",
+                "calendar/month/jun": "6월",
+                "calendar/month/jul": "7월",
+                "calendar/month/aug": "8월",
+                "calendar/month/sep": "9월",
+                "calendar/month/oct": "10월",
+                "calendar/month/nov": "11월",
+                "calendar/month/dec": "12월"
+            },
+            "titles": {
+                "Toggle dark mode": "다크/라이트 테마 전환",
+                "New Event": "새 일정",
+                "Import from Computer": "컴퓨터에서 가져오기",
+                "Import from Files": "파일에서 가져오기",
+                "Export as .ics": ".ics로 내보내기"
+            },
+            "placeholder": {
+                "New Event": "새 일정",
+                "Add location": "위치 추가",
+                "Add notes": "메모 추가"
+            }
+        }
+    }
+}