в2: список участников на сервере (data.json), статичный предпросмотр без сервера на localStorage

This commit is contained in:
tore 2026-09-20 19:06:42 +00:00
parent 993b9df72f
commit 4bcba9cf90
7 changed files with 281 additions and 58 deletions

4
.vibe42-run.log Normal file
View 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
View File

@ -0,0 +1 @@
86

98
data.json Normal file
View 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
View File

@ -0,0 +1,10 @@
{
"name": "app",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app"
}
}
}

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"name": "app",
"private": true,
"type": "commonjs",
"scripts": { "start": "node server.js" }
}

159
script.js
View File

@ -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
View 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));