diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90be1ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +data.json +node_modules/ +.vibe42-run.log +.vibe42-run.pid diff --git a/index.html b/index.html index 1f53f3b..846c6de 100644 --- a/index.html +++ b/index.html @@ -3,18 +3,20 @@ -Календарь дней +Календарь-планер -
-
-

Календарь дней

-

Листай месяцы стрелками, сегодняшний день отмечен

-
+
+
+

Календарь-планер

+

Нажми на день — добавь дела и планы. Всё сохранится само.

+
+
+
@@ -30,7 +32,27 @@
-

Нажми на любой день, чтобы увидеть дату

+
diff --git a/package.json b/package.json new file mode 100644 index 0000000..3b2cf7b --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "name": "planner", + "private": true, + "type": "commonjs", + "scripts": { "start": "node server.js" } +} diff --git a/script.js b/script.js index a370b5a..bfc0a46 100644 --- a/script.js +++ b/script.js @@ -6,17 +6,30 @@ var MONTHS = [ var now = new Date(); var view = { year: now.getFullYear(), month: now.getMonth() }; -var selected = null; +var openDate = null; +var allByDate = {}; var gridEl = document.getElementById("grid"); var monthNameEl = document.getElementById("monthName"); -var hintEl = document.getElementById("hint"); +var panelBody = document.getElementById("panelBody"); +var panelEmpty = document.getElementById("panelEmpty"); +var panelDateEl = document.getElementById("panelDate"); +var notesEl = document.getElementById("notes"); +var countEl = document.getElementById("count"); function pad(n) { return (n < 10 ? "0" : "") + n; } function fmt(d) { return pad(d.getDate()) + "." + pad(d.getMonth() + 1) + "." + d.getFullYear(); } +function iso(date) { + return date.getFullYear() + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate()); +} +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); +} function renderWeekdays() { document.getElementById("weekdays").innerHTML = @@ -30,7 +43,6 @@ function render() { var startPad = (first.getDay() + 6) % 7; var daysInMonth = new Date(view.year, view.month + 1, 0).getDate(); var prevDays = new Date(view.year, view.month, 0).getDate(); - var html = ""; for (var i = startPad - 1; i >= 0; i--) { @@ -38,18 +50,21 @@ function render() { } for (var d = 1; d <= daysInMonth; d++) { - var cls = "day"; var date = new Date(view.year, view.month, d); + var key = iso(date); + var cls = "day"; var isToday = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(); if (isToday) cls += " today"; + if (openDate === key) cls += " selected"; - if (selected && fmt(date) === fmt(selected)) cls += " selected"; + var has = (allByDate[key] || []).length > 0; + var dot = has ? '' : ''; - html += '
' + d + "
"; + html += '
' + d + dot + "
"; } var endPad = 7 - ((startPad + daysInMonth) % 7); @@ -66,28 +81,121 @@ function changeMonth(delta) { view.month += delta; if (view.month < 0) { view.month = 11; view.year--; } if (view.month > 11) { view.month = 0; view.year++; } - selected = null; render(); } function goToday() { now = new Date(); view = { year: now.getFullYear(), month: now.getMonth() }; - selected = null; render(); } +function api(url, opts) { + return fetch(url, opts).then(function (r) { + if (!r.ok) throw new Error("Ошибка сервера " + r.status); + return r.json(); + }); +} + +function loadAll() { + return api("/api/notes").then(function (list) { + allByDate = {}; + list.forEach(function (n) { + (allByDate[n.date] = allByDate[n.date] || []).push(n); + }); + render(); + if (openDate) renderPanel(); + }); +} + +function renderPanel() { + var list = allByDate[openDate] || []; + notesEl.innerHTML = list.map(function (n) { + return ( + '
  • ' + + '" + + '' + escapeHtml(n.text) + "" + + '' + + "
  • " + ); + }).join(""); + countEl.textContent = list.length ? "Дела: " + list.length : ""; +} + +function openDay(key) { + openDate = key; + panelEmpty.classList.add("hidden"); + panelBody.classList.remove("hidden"); + var parts = key.split("-"); + var date = new Date(parts[0], +parts[1] - 1, +parts[2]); + panelDateEl.textContent = fmt(date); + render(); + renderPanel(); +} + +function closePanel() { + openDate = null; + panelBody.classList.add("hidden"); + panelEmpty.classList.remove("hidden"); + render(); +} + +function addNote(text) { + return api("/api/notes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ date: openDate, text: text }) + }).then(loadAll); +} + +function toggleNote(id, done) { + return api("/api/notes", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: id, done: done }) + }).then(loadAll); +} + +function deleteNote(id) { + return api("/api/notes/" + id, { method: "DELETE" }).then(loadAll); +} + gridEl.addEventListener("click", function (e) { var day = e.target.closest(".day"); if (!day || day.classList.contains("empty")) return; - selected = new Date(view.year, view.month, parseInt(day.textContent, 10)); - hintEl.textContent = "Выбрано: " + fmt(selected); - render(); + openDay(day.dataset.key); +}); + +notesEl.addEventListener("click", function (e) { + var li = e.target.closest(".note"); + if (!li) return; + var id = +li.dataset.id; + var item = (allByDate[openDate] || []).find(function (n) { return n.id === id; }); + if (!item) return; + + if (e.target.classList.contains("del")) { + if (confirm("Удалить это дело?")) deleteNote(id); + return; + } + if (e.target.classList.contains("chk")) { + toggleNote(id, !item.done); + } +}); + +document.getElementById("addForm").addEventListener("submit", function (e) { + e.preventDefault(); + var input = document.getElementById("noteText"); + var text = input.value.trim(); + if (!text || !openDate) return; + addNote(text); + input.value = ""; }); document.getElementById("prev").addEventListener("click", function () { changeMonth(-1); }); document.getElementById("next").addEventListener("click", function () { changeMonth(1); }); document.getElementById("today").addEventListener("click", goToday); +document.getElementById("closePanel").addEventListener("click", closePanel); renderWeekdays(); render(); +loadAll(); diff --git a/server.js b/server.js new file mode 100644 index 0000000..4289a16 --- /dev/null +++ b/server.js @@ -0,0 +1,81 @@ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = process.env.PORT || 3000; +const DATA = path.join(__dirname, "data.json"); + +function load() { + try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { notes: [] }; } +} +function save(d) { + fs.writeFileSync(DATA, JSON.stringify(d, null, 2)); +} +function body(req) { + return new Promise((resolve) => { + let s = ""; + req.on("data", (c) => (s += c)); + req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); + }); +} + +http.createServer(async (req, res) => { + const url = req.url.split("?")[0]; + + // Список всех заметок (опционально фильтр по дате) + if (req.method === "GET" && url === "/api/notes") { + const q = new URL(req.url, "http://x").searchParams; + const date = q.get("date"); + const db = load(); + const list = date ? db.notes.filter((n) => n.date === date) : db.notes; + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(list)); + } + + // Добавить заметку + if (req.method === "POST" && url === "/api/notes") { + const note = await body(req); + if (!note.date || !note.text) { + res.writeHead(400, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ ok: false, error: "date и text обязательны" })); + } + const db = load(); + const item = { id: Date.now(), date: note.date, text: String(note.text).slice(0, 300), done: !!note.done }; + db.notes.push(item); + save(db); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(item)); + } + + // Переключить «сделано» + if (req.method === "PATCH" && url === "/api/notes") { + const patch = await body(req); + const db = load(); + const note = db.notes.find((n) => n.id === patch.id); + if (note) note.done = !!patch.done; + save(db); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ ok: !!note })); + } + + // Удалить заметку + if (req.method === "DELETE" && url.startsWith("/api/notes/")) { + const id = Number(url.split("/").pop()); + const db = load(); + db.notes = db.notes.filter((n) => n.id !== id); + save(db); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ ok: true })); + } + + // Статика: index.html / style.css / script.js + let file = req.url === "/" ? "/index.html" : url; + const full = path.join(__dirname, file); + if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) { + const ext = path.extname(full); + const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : "text/html"; + res.writeHead(200, { "Content-Type": type + "; charset=utf-8" }); + return res.end(fs.readFileSync(full)); + } + res.writeHead(404); res.end("Not found"); +}).listen(PORT, () => console.log("Сервер на порту " + PORT)); diff --git a/style.css b/style.css index f85fe28..1f1ae51 100644 --- a/style.css +++ b/style.css @@ -1,15 +1,17 @@ :root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7} *{box-sizing:border-box;margin:0;padding:0} body{font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--ink);background:var(--gray-100);min-height:100vh} +.container{max-width:1060px;margin:0 auto;padding:40px 24px} +.hidden{display:none!important} -.wrap{max-width:560px;margin:0 auto;padding:48px 20px} +.top{background:var(--ink);color:#fff} +.top h1{font-size:40px;font-weight:800;line-height:1.1} +.top .sub{color:#9aa3b2;margin-top:8px} -.top{text-align:center;margin-bottom:32px} -.top h1{font-size:36px;font-weight:800;line-height:1.1;color:var(--ink)} -.top .sub{color:var(--gray-500);font-size:16px;margin-top:8px} - -.cal{background:var(--white);border-radius:16px;box-shadow:0 12px 32px rgba(15,18,24,.08);padding:24px;overflow:hidden} +.layout{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:28px;align-items:start} +/* ===== Календарь ===== */ +.cal{background:var(--white);border-radius:16px;box-shadow:0 12px 32px rgba(15,18,24,.08);padding:24px} .cal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px} .month-name{font-size:24px;font-weight:800;text-transform:capitalize} .arrow{border:none;background:var(--ink);color:#fff;width:42px;height:42px;border-radius:10px;font-size:20px;cursor:pointer;transition:transform .15s ease,opacity .15s ease} @@ -20,23 +22,51 @@ body{font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,s .weekdays span{text-align:center;font-size:13px;font-weight:700;color:var(--gray-500);text-transform:uppercase;letter-spacing:.05em} .grid{display:grid;grid-template-columns:repeat(7,1fr);gap:6px} -.day{display:flex;align-items:center;justify-content:center;height:52px;border-radius:10px;cursor:pointer;font-size:16px;font-weight:600;color:var(--ink);transition:background .15s ease,transform .15s ease} +.day{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;height:56px;border-radius:10px;cursor:pointer;font-size:16px;font-weight:600;color:var(--ink);transition:background .15s ease,transform .15s ease} .day:hover{background:var(--cyan-50);transform:scale(1.05)} .day.empty{pointer-events:none} .day.today{background:var(--ink);color:#fff;box-shadow:0 4px 12px rgba(15,18,24,.25)} .day.today:hover{background:var(--ink)} .day.selected{background:var(--cyan);color:var(--ink);box-shadow:0 4px 12px rgba(0,229,255,.4)} +.day.selected:hover{background:var(--cyan)} +.dot{width:6px;height:6px;border-radius:50%;background:var(--cyan)} +.day.today .dot,.day.selected .dot{background:#fff} .cal-foot{text-align:center;margin-top:18px;padding-top:18px;border-top:1px solid var(--gray-100)} .today-btn{border:none;background:var(--cyan-50);color:var(--ink);padding:10px 24px;border-radius:10px;font-size:15px;font-weight:700;cursor:pointer;transition:background .15s ease} .today-btn:hover{background:var(--cyan)} -.hint{text-align:center;color:var(--gray-500);font-size:15px;margin-top:20px} +/* ===== Панель дел ===== */ +.panel{background:var(--white);border-radius:16px;box-shadow:0 12px 32px rgba(15,18,24,.08);padding:24px;min-height:320px} +.panel-empty{text-align:center;color:var(--gray-500);padding:70px 10px} +.pe-emoji{font-size:44px;margin-bottom:12px} +.panel-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px} +.panel-date{font-size:22px;font-weight:800} +.close-btn{border:none;background:var(--gray-100);width:36px;height:36px;border-radius:10px;font-size:20px;line-height:1;cursor:pointer;color:var(--gray-500)} +.close-btn:hover{background:var(--cyan-50);color:var(--ink)} -@media (max-width:480px){ - .wrap{padding:32px 14px} +.count{font-size:14px;color:var(--gray-500);margin-bottom:14px} +.notes{list-style:none;margin-bottom:18px;max-height:340px;overflow-y:auto} +.notes:empty::after{content:"Пока пусто — добавь первое дело ниже";color:#b4bcc7;font-size:15px;display:block;padding:8px 0} + +.note{display:flex;align-items:center;gap:12px;padding:12px 0;border-bottom:1px solid var(--gray-100)} +.note:last-child{border-bottom:none} +.chk{width:24px;height:24px;border:2px solid #c4ccd6;border-radius:8px;background:var(--white);cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:14px;color:#fff;transition:background .15s ease,border-color .15s ease} +.chk.on{background:var(--cyan);border-color:var(--cyan)} +.note-text{flex:1;font-size:16px} +.note.done .note-text{text-decoration:line-through;color:#aab3bf} +.del{border:none;background:none;color:#c4ccd6;font-size:20px;cursor:pointer;padding:4px;line-height:1} +.del:hover{color:#e5484d} + +.add-form{display:flex;gap:10px} +.add-form input{flex:1;border:2px solid var(--gray-100);border-radius:10px;padding:12px 14px;font-size:16px;font-family:inherit;outline:none;transition:border-color .15s ease} +.add-form input:focus{border-color:var(--cyan)} +.add-form button{border:none;background:var(--ink);color:#fff;width:50px;border-radius:10px;font-size:26px;cursor:pointer;flex-shrink:0} +.add-form button:hover{background:#232a35} + +@media (max-width:860px){ + .layout{grid-template-columns:1fr} + .container{padding:28px 16px} .top h1{font-size:30px} - .cal{padding:16px} - .day{height:44px;font-size:14px} - .month-name{font-size:20px} + .day{height:48px} }