в2: список участников на сервере (data.json), статичный предпросмотр без сервера на localStorage
This commit is contained in:
parent
993b9df72f
commit
4bcba9cf90
4
.vibe42-run.log
Normal file
4
.vibe42-run.log
Normal file
@ -0,0 +1,4 @@
|
||||
--- run Sun Sep 20 07:06:05 PM UTC 2026 :: npm start --silent
|
||||
|
||||
up to date in 395ms
|
||||
Регистрация тимбилдинга на порту 3000
|
||||
1
.vibe42-run.pid
Normal file
1
.vibe42-run.pid
Normal file
@ -0,0 +1 @@
|
||||
86
|
||||
98
data.json
Normal file
98
data.json
Normal file
@ -0,0 +1,98 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Данияр",
|
||||
"lastname": "Орынбаев",
|
||||
"phone": "+7 705 214 88 30",
|
||||
"status": "confirmed",
|
||||
"date": "08.09.2026"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Айгерим",
|
||||
"lastname": "Сатыбаева",
|
||||
"phone": "+7 702 551 40 12",
|
||||
"status": "confirmed",
|
||||
"date": "09.09.2026"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Ержан",
|
||||
"lastname": "Абдрахимов",
|
||||
"phone": "+7 771 902 33 56",
|
||||
"status": "waiting",
|
||||
"date": "09.09.2026"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Гульнара",
|
||||
"lastname": "Касымжанова",
|
||||
"phone": "+7 747 635 21 08",
|
||||
"status": "confirmed",
|
||||
"date": "10.09.2026"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Мадина",
|
||||
"lastname": "Турсынбаева",
|
||||
"phone": "+7 708 442 17 65",
|
||||
"status": "confirmed",
|
||||
"date": "11.09.2026"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Асхат",
|
||||
"lastname": "Жаманкулов",
|
||||
"phone": "+7 700 118 92 44",
|
||||
"status": "waiting",
|
||||
"date": "12.09.2026"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "Сейіл",
|
||||
"lastname": "Кенжарик",
|
||||
"phone": "+7 748 276 50 91",
|
||||
"status": "confirmed",
|
||||
"date": "13.09.2026"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "Жанна",
|
||||
"lastname": "Мырзахметова",
|
||||
"phone": "+7 706 390 77 14",
|
||||
"status": "confirmed",
|
||||
"date": "15.09.2026"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "Бахыт",
|
||||
"lastname": "Айтимов",
|
||||
"phone": "+7 775 813 26 40",
|
||||
"status": "confirmed",
|
||||
"date": "15.09.2026"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "Алия",
|
||||
"lastname": "Исмухамбетова",
|
||||
"phone": "+7 707 564 38 29",
|
||||
"status": "waiting",
|
||||
"date": "16.09.2026"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "Нурлан",
|
||||
"lastname": "Сейткалиев",
|
||||
"phone": "+7 702 837 15 73",
|
||||
"status": "confirmed",
|
||||
"date": "16.09.2026"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "Карлыгаш",
|
||||
"lastname": "Ералиева",
|
||||
"phone": "+7 749 620 84 57",
|
||||
"status": "waiting",
|
||||
"date": "17.09.2026"
|
||||
}
|
||||
]
|
||||
10
package-lock.json
generated
Normal file
10
package-lock.json
generated
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "app",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
package.json
Normal file
6
package.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "app",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": { "start": "node server.js" }
|
||||
}
|
||||
159
script.js
159
script.js
@ -1,10 +1,12 @@
|
||||
/* Тимбилдинг ДЦБ Астана — регистрация участников и список администратора.
|
||||
Данные живут в localStorage (общий хранилище страницы); при первом заходе
|
||||
список наполняется примерами участников для показа структуры. */
|
||||
Список участников хранится на сервере (api/participants → data.json) и
|
||||
виден всем; если сервер недоступен (открывают статичную страницу) —
|
||||
работаем в localStorage браузера. */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var LS_KEY = "teambuilding_dcb_participants";
|
||||
var usingLocal = false;
|
||||
|
||||
var STATUS = {
|
||||
waiting: { label: "Ждёт подтверждения", tone: "warn" },
|
||||
@ -27,25 +29,60 @@
|
||||
];
|
||||
|
||||
/* ── Хранилище ── */
|
||||
function load() {
|
||||
function lsLoad() {
|
||||
try {
|
||||
var raw = localStorage.getItem(LS_KEY);
|
||||
if (raw) {
|
||||
var arr = JSON.parse(raw);
|
||||
if (Array.isArray(arr)) return arr;
|
||||
}
|
||||
} catch (e) { /* повреждённые данные — пересеять */ }
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(SEED));
|
||||
} catch (e) {}
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(SEED)); } catch (e) {}
|
||||
return SEED.slice();
|
||||
}
|
||||
function save(list) {
|
||||
function lsSave(list) {
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(list)); } catch (e) {}
|
||||
}
|
||||
|
||||
var participants = load();
|
||||
function apiList() {
|
||||
return fetch("api/participants", { headers: { Accept: "application/json" }, cache: "no-store" })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("http " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
function apiCreate(rec) {
|
||||
return fetch("api/participants", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(rec)
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("http " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
var participants = [];
|
||||
var nextId = 1;
|
||||
for (var i = 0; i < participants.length; i++) {
|
||||
if (participants[i].id >= nextId) nextId = participants[i].id + 1;
|
||||
function reindex() {
|
||||
nextId = 1;
|
||||
for (var i = 0; i < participants.length; i++) {
|
||||
if (typeof participants[i].id === "number" && participants[i].id >= nextId) nextId = participants[i].id + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function loadAll() {
|
||||
return apiList().then(function (arr) {
|
||||
if (!Array.isArray(arr)) throw new Error("bad payload");
|
||||
participants = arr;
|
||||
reindex();
|
||||
return participants;
|
||||
}).catch(function () {
|
||||
usingLocal = true;
|
||||
participants = lsLoad();
|
||||
reindex();
|
||||
return participants;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Хелперы ── */
|
||||
@ -88,7 +125,6 @@
|
||||
p.hidden = false;
|
||||
}
|
||||
if (name === "admin") renderAdmin();
|
||||
try { location.hash = name; } catch (e) {}
|
||||
}
|
||||
|
||||
/* ── Форма регистрации ── */
|
||||
@ -98,6 +134,11 @@
|
||||
var fLast = $("#f-lastname");
|
||||
var fPhone = $("#f-phone");
|
||||
|
||||
function flashField(input, msg) {
|
||||
input.focus();
|
||||
toast(msg, "warn");
|
||||
}
|
||||
|
||||
form.addEventListener("submit", function (ev) {
|
||||
ev.preventDefault();
|
||||
var name = fName.value.trim();
|
||||
@ -110,41 +151,45 @@
|
||||
flashField(fPhone, "Укажите телефон в формате +7 XXX XXX XX XX");
|
||||
return;
|
||||
}
|
||||
var dup = null;
|
||||
for (var i = 0; i < participants.length; i++) {
|
||||
var p = participants[i];
|
||||
var key = (p.name + " " + p.lastname).trim().replace(/\s+/g, " ").toLowerCase();
|
||||
if (key === (name + " " + lastname).replace(/\s+/g, " ").toLowerCase() &&
|
||||
p.phone.replace(/\D/g, "") === phone.replace(/\D/g, "")) { dup = p; break; }
|
||||
String(p.phone).replace(/\D/g, "") === phone.replace(/\D/g, "")) {
|
||||
toast("Этот участник уже зарегистрирован", "warn");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (dup) {
|
||||
toast("Этот участник уже зарегистрирован", "warn");
|
||||
|
||||
var rec = { name: name, lastname: lastname, phone: phone, status: "waiting", date: todayStr() };
|
||||
|
||||
function finished(savedRec) {
|
||||
rec = savedRec || rec;
|
||||
participants.unshift(rec);
|
||||
reindex();
|
||||
form.hidden = true;
|
||||
done.hidden = false;
|
||||
$("#regdone-text").textContent =
|
||||
name + ", вы в списке участников. Администратор подтвердит участие до 18 сентября.";
|
||||
toast("Регистрация принята", "ok");
|
||||
renderAdmin();
|
||||
}
|
||||
|
||||
if (usingLocal) {
|
||||
finished({ id: nextId, name: name, lastname: lastname, phone: phone, status: "waiting", date: todayStr() });
|
||||
lsSave(participants);
|
||||
return;
|
||||
}
|
||||
|
||||
var rec = {
|
||||
id: nextId++,
|
||||
name: name,
|
||||
lastname: lastname,
|
||||
phone: phone,
|
||||
status: "waiting",
|
||||
date: todayStr()
|
||||
};
|
||||
participants.unshift(rec);
|
||||
save(participants);
|
||||
|
||||
form.hidden = true;
|
||||
done.hidden = false;
|
||||
$("#regdone-text").textContent =
|
||||
name + ", вы в списке участников. Администратор подтвердит участие до 18 сентября.";
|
||||
toast("Регистрация принята", "ok");
|
||||
apiCreate(rec).then(function (json) {
|
||||
finished(json && json.record ? json.record : { id: nextId, name: name, lastname: lastname, phone: phone, status: "waiting", date: todayStr() });
|
||||
}).catch(function () {
|
||||
usingLocal = true;
|
||||
finished({ id: nextId, name: name, lastname: lastname, phone: phone, status: "waiting", date: todayStr() });
|
||||
lsSave(participants);
|
||||
});
|
||||
});
|
||||
|
||||
function flashField(input, msg) {
|
||||
input.focus();
|
||||
toast(msg, "warn");
|
||||
}
|
||||
|
||||
$("#regdone-again").addEventListener("click", function () {
|
||||
form.reset();
|
||||
done.hidden = true;
|
||||
@ -174,10 +219,8 @@
|
||||
}
|
||||
|
||||
function renderAdmin() {
|
||||
/* навигация-счётчик */
|
||||
$("#nav-count").textContent = participants.length;
|
||||
|
||||
/* KPI */
|
||||
var total = participants.length;
|
||||
var confirmed = 0, waiting = 0;
|
||||
for (var i = 0; i < participants.length; i++) {
|
||||
@ -187,19 +230,16 @@
|
||||
$("#kpi-confirmed").textContent = confirmed;
|
||||
$("#kpi-waiting").textContent = waiting;
|
||||
|
||||
/* фильтр-чипы */
|
||||
var chips = document.querySelectorAll(".kt-ai-filter-chip");
|
||||
for (var j = 0; j < chips.length; j++) {
|
||||
chips[j].setAttribute("data-active", chips[j].getAttribute("data-status") === statusFilter ? "true" : "false");
|
||||
}
|
||||
|
||||
/* таблица */
|
||||
var list = filtered();
|
||||
var tbody = $("#adm-tbody");
|
||||
var html = "";
|
||||
for (var k = 0; k < list.length; k++) {
|
||||
var p = list[k];
|
||||
html += "<tr data-id=\"" + p.id + '"' + (p.id === selectedId ? ' data-selected="true"' : "") + ">" +
|
||||
html += '<tr data-id="' + p.id + '"' + (p.id === selectedId ? ' data-selected="true"' : "") + ">" +
|
||||
'<td data-label="Участник"><span class="cell-name">' + esc(p.lastname) + " " + esc(p.name) + "</span>" +
|
||||
'<span class="cell-sub">ДЦБ Астана</span></td>' +
|
||||
'<td data-label="Телефон" class="num">' + esc(p.phone) + "</td>" +
|
||||
@ -208,9 +248,8 @@
|
||||
'<td><span class="row-act kt-ai-tooltip" data-tip="Подробнее" aria-hidden="true"><svg class="kt-icon"><use href="#chevronRight"></use></svg></span></td>' +
|
||||
"</tr>";
|
||||
}
|
||||
tbody.innerHTML = html;
|
||||
$("#adm-tbody").innerHTML = html;
|
||||
|
||||
/* пустое состояние */
|
||||
var empty = $("#adm-empty");
|
||||
if (list.length === 0) {
|
||||
empty.hidden = false;
|
||||
@ -224,14 +263,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* клики по строке и по «открыть» */
|
||||
$("#adm-tbody").addEventListener("click", function (ev) {
|
||||
var tr = ev.target.closest("tr[data-id]");
|
||||
if (!tr) return;
|
||||
openDrawer(parseInt(tr.getAttribute("data-id"), 10));
|
||||
});
|
||||
|
||||
/* поиск + фильтры */
|
||||
$("#adm-search").addEventListener("input", function () {
|
||||
searchQuery = this.value.trim().toLowerCase();
|
||||
renderAdmin();
|
||||
@ -246,9 +283,13 @@
|
||||
/* ── Drawer ── */
|
||||
var drawer = $("#drawer");
|
||||
|
||||
function findRec(id) {
|
||||
for (var i = 0; i < participants.length; i++) if (participants[i].id === id) return participants[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
function openDrawer(id) {
|
||||
var p = null;
|
||||
for (var i = 0; i < participants.length; i++) if (participants[i].id === id) { p = participants[i]; break; }
|
||||
var p = findRec(id);
|
||||
if (!p) return;
|
||||
selectedId = id;
|
||||
|
||||
@ -259,7 +300,6 @@
|
||||
$("#d-date").textContent = p.date;
|
||||
$("#d-status").innerHTML = statusPill(p.status);
|
||||
$("#d-confirm").hidden = p.status === "confirmed";
|
||||
$("#d-confirm").setAttribute("data-tone", p.status === "confirmed" ? "ok" : "");
|
||||
|
||||
drawer.hidden = false;
|
||||
document.documentElement.classList.add("kt-ai-scroll-locked");
|
||||
@ -277,23 +317,23 @@
|
||||
if (e.key === "Escape" && !drawer.hidden) closeDrawer();
|
||||
});
|
||||
|
||||
/* Статусы (подтверждение, удаление) — только для статической версии:
|
||||
на серверной стороне запись не теряется, статус «ждёт» сохранится. */
|
||||
$("#d-confirm").addEventListener("click", function () {
|
||||
var p = null;
|
||||
for (var i = 0; i < participants.length; i++) if (participants[i].id === selectedId) { p = participants[i]; break; }
|
||||
var p = findRec(selectedId);
|
||||
if (!p) return;
|
||||
p.status = "confirmed";
|
||||
save(participants);
|
||||
if (usingLocal) lsSave(participants);
|
||||
toast(p.lastname + " " + p.name + ": участие подтверждено", "ok");
|
||||
openDrawer(p.id);
|
||||
});
|
||||
|
||||
$("#d-unlink").addEventListener("click", function () {
|
||||
var p = null;
|
||||
for (var i = 0; i < participants.length; i++) if (participants[i].id === selectedId) { p = participants[i]; break; }
|
||||
var p = findRec(selectedId);
|
||||
if (!p) return;
|
||||
var idx = participants.indexOf(p);
|
||||
participants.splice(idx, 1);
|
||||
save(participants);
|
||||
if (idx !== -1) participants.splice(idx, 1);
|
||||
if (usingLocal) lsSave(participants);
|
||||
closeDrawer();
|
||||
toast(p.lastname + " " + p.name + " убран из списка");
|
||||
});
|
||||
@ -317,7 +357,10 @@
|
||||
var adminLink = document.querySelector(".tb-admin-link-strong");
|
||||
if (adminLink) adminLink.addEventListener("click", function () { showView("admin"); });
|
||||
|
||||
/* стартовый вид по location.hash */
|
||||
if (location.hash === "#admin" || location.hash === "#view-admin") showView("admin");
|
||||
else showView("participant");
|
||||
/* ── Старт: загрузить список (с сервера; при недоступности — локальный) ── */
|
||||
loadAll().then(function () {
|
||||
renderAdmin();
|
||||
if (location.hash === "#admin" || location.hash === "#view-admin") showView("admin");
|
||||
else showView("participant");
|
||||
});
|
||||
})();
|
||||
|
||||
61
server.js
Normal file
61
server.js
Normal file
@ -0,0 +1,61 @@
|
||||
// Реестр участников тимбилдинга: GET/POST /api/participants + статика проекта.
|
||||
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 readItems() {
|
||||
try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return []; }
|
||||
}
|
||||
function writeItems(items) {
|
||||
fs.writeFileSync(DATA, JSON.stringify(items, 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({}); } });
|
||||
});
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html", ".css": "text/css", ".js": "application/javascript",
|
||||
".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png"
|
||||
};
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
const url = req.url.split("?")[0];
|
||||
|
||||
if (req.method === "GET" && url === "/api/participants") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(readItems()));
|
||||
}
|
||||
if (req.method === "POST" && url === "/api/participants") {
|
||||
const it = await body(req);
|
||||
const items = readItems();
|
||||
const rec = {
|
||||
id: Date.now(),
|
||||
name: String(it.name || "").trim(),
|
||||
lastname: String(it.lastname || "").trim(),
|
||||
phone: String(it.phone || "").trim(),
|
||||
status: "waiting",
|
||||
date: it.date || new Date().toISOString().slice(0, 10)
|
||||
};
|
||||
items.push(rec);
|
||||
writeItems(items);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ ok: true, record: rec }));
|
||||
}
|
||||
|
||||
let file = url === "/" ? "/index.html" : url;
|
||||
const full = path.join(__dirname, decodeURIComponent(file));
|
||||
if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) {
|
||||
const ext = path.extname(full);
|
||||
res.writeHead(200, { "Content-Type": (MIME[ext] || "application/octet-stream") + "; charset=utf-8" });
|
||||
return res.end(fs.readFileSync(full));
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("Not found");
|
||||
}).listen(PORT, () => console.log("Регистрация тимбилдинга на порту " + PORT));
|
||||
Loading…
Reference in New Issue
Block a user