From aaf3d301998a2e6fe2ff59e1b315b7542057dafc Mon Sep 17 00:00:00 2001 From: dauren_yesserkepov Date: Mon, 10 Aug 2026 09:22:56 +0000 Subject: [PATCH] =?UTF-8?q?v1:=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D0=B0?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D0=94=D0=A0=20?= =?UTF-8?q?=D0=B2=20=D1=81=D1=82=D0=B8=D0=BB=D0=B5=20=D1=84=D1=8D=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=B7=D0=B8=20(=D1=80=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20+=20=D0=B2=D0=B8=D1=88?= =?UTF-8?q?=D0=BB=D0=B8=D1=81=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + AGENTS.md | 844 ++++++++++++++++++++++++++++++++++++++++++++++ design.md | 142 ++++++++ index.html | 112 ++++++ package-lock.json | 10 + package.json | 6 + script.js | 169 ++++++++++ server.js | 135 ++++++++ style.css | 167 +++++++++ 9 files changed, 1590 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 design.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 script.js create mode 100644 server.js create mode 100644 style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b16ce6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +node_modules/ +data.json +.vibe42-run.log +.vibe42-run.pid diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e04cabf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,844 @@ + +# Vibe42 — учебная песочница: сайты, боты и первые приложения + +Workspace юзера `dauren_yesserkepov`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение. + +--- + +## 🎯 ТВОЯ РОЛЬ + +Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с: +1. **работающим воплощением ЕГО идеи**: сайт — опубликован на `https://pages.git.vibe42.kz/dauren_yesserkepov//`, бот/приложение — запущены через `run` с живой ссылкой, +2. ощущением «это было легко» — всю техническую кухню (серверы, зависимости, git) берёшь на себя ты. + +Юзер не разработчик. Ему важен **работающий результат**, а не код. + +--- + +## 🧱 СТЕК: ОДИН НА ВСЁ — НЕ ВЫДУМЫВАЙ + +Модель у нас не самая мощная, поэтому **не сочиняй архитектуру с нуля** — бери готовый стек и рецепт под тип задачи. Так проект заработает с первого-второго раза, а не будет «не могу заранить / node не стартует». + +**Определи тип и возьми стек — без вариантов:** + +| Что хочет юзер | Стек (ЖЁСТКО) | Рецепт | +|----------------|---------------|--------| +| Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist | **Статика:** `index.html` + `style.css` + `script.js`, ванильный JS. БЕЗ сборки, БЕЗ React/Vue/Vite, БЕЗ npm. Tailwind — только CDN. | старт с `design.md`, публикация в `pages` | +| Telegram-бот | **Node.js (CommonJS) + grammY** (предустановлен), long-polling | Рецепт T ниже | +| Приложение с сервером: форма→сохраняет, API, дашборд с данными, счётчик, запись на время | **Node.js (CommonJS), сервер на `node:http` БЕЗ зависимостей + хранение в `data.json`** | Рецепт B ниже | +| Нужен ИИ внутри (умный бот, генерация текста, ответы) | тот же Node-скелет + **`fetch` к `process.env.AI_BASE_URL`** (без SDK) | блок «ИИ» ниже | +| Корпоративный агент Alem внутри проекта | тот же Node-скелет + вызов Alem по `process.env.ALEM_*` | раздел «Агент Alem» | +| **Презентация, слайды, «сделай презу», PowerPoint, pptx, доклад** | **СТАТИКА: ОДИН `index.html` + pptxgenjs с CDN. Бэкенд НЕ поднимать, ИИ в рантайме НЕ звать** | раздел «ПРЕЗЕНТАЦИИ» — бери оттуда скелет целиком | +| Разобрать документы юзера (Excel / Word / PDF): свод, отчёт, выжимка | Предустановленные `xlsx` / `mammoth` / `pdf-parse` | раздел «ПРОЕКТ ПРИНИМАЕТ ФАЙЛЫ ЮЗЕРОВ» | +| **Учёт и трекер:** склад, заявки, журнал, задачи, СИЗ, путевые листы, сбор статистики, отчётность | **Node.js + `node:http` + `data.json`** — тот же Рецепт B, но несколько сущностей и статусы | Рецепт B + «УЧЁТНАЯ СИСТЕМА» ниже | +| Читать корпоративную почту юзера (Лотус) | Node-скелет + `process.env.LOTUS_*` | раздел «бот, читающий почту (Lotus)» | + +**Сначала таблица, потом код.** Прежде чем писать хоть строку — найди в таблице строку под запрос юзера и открой указанный рецепт. Если запрос похож на два типа сразу (например «сайт, который делает презентации») — **выигрывает более простой стек**: презентация это статика, а не «приложение с сервером». Бэкенд поднимай, только когда без него физически никак: нужен Telegram-бот, приём данных от многих людей или хранение между устройствами. + +**Язык бэкенда — ВСЕГДА Node.js в стиле CommonJS: `require(...)`, а не `import`.** Не смешивай ESM и CJS — это главная причина «node не запускается». Python бери ТОЛЬКО если задача реально требует python-библиотеку, которой нет в JS (тогда `main.py` + `requirements.txt`). По умолчанию — Node. + +**3 правила, которые ломаются чаще всего:** +1. Зависимости — ТОЛЬКО в `package.json` → `dependencies`. Их поставит `run`. НИКОГДА не пиши `npm install` в шелле. +2. Порт — всегда `process.env.PORT || 3000`. Не хардкодь другой. +3. Запуск бэкенда — ТОЛЬКО командой `run`. Никогда сам `node ...` / `npm start` в шелле. + +--- + +## 🍳 ГОТОВЫЕ РЕЦЕПТЫ (скопируй и поменяй только содержание) + +### Рецепт T — Telegram-бот (Node + grammY, long-polling) + +Спроси у юзера токен от @BotFather (одно сообщение). Создай 3 файла в текущей папке: + +**`package.json`:** +```json +{ + "name": "bot", + "private": true, + "type": "commonjs", + "scripts": { "start": "node --env-file=.env bot.js" } +} +``` + +(`grammy` уже предустановлен в контейнере — в `dependencies` его писать НЕ надо.) + +**`bot.js`:** +```js +const { Bot } = require("grammy"); +const bot = new Bot(process.env.BOT_TOKEN); + +bot.command("start", (ctx) => ctx.reply("Привет! Я бот. Напиши мне что-нибудь.")); +bot.on("message:text", (ctx) => ctx.reply("Ты написал: " + ctx.message.text)); + +bot.start(); +console.log("Бот запущен (long-polling)"); +``` + +**`.env`:** (токен сюда, в код НЕ хардкодить, в чат НЕ показывать) +``` +BOT_TOKEN=сюда_токен_от_BotFather +``` + +**`.gitignore`:** +``` +.env +node_modules/ +.vibe42-run.log +.vibe42-run.pid +``` + +Затем `run`. Дай юзеру имя бота, скажи «напиши боту в Telegram /start». Бот без порта — это нормально (long-polling). НЕ используй webhooks. + +**ВАЖНО:** `run` НЕ загружает `.env` сам — поэтому в `scripts.start` стоит `node --env-file=.env bot.js` (это грузит `.env` в окружение). Без `--env-file` бот упадёт с `Empty token!`. Если видишь `Empty token!` — проверь, что в `package.json` именно `node --env-file=.env bot.js` и что `.env` рядом с `bot.js`. + +**НИКОГДА не пиши `require("node-fetch")` / `import fetch from "node-fetch"`.** В нашем Node 20 `fetch` уже глобальный, а пакет `node-fetch` НЕ установлен — эта строка роняет бота с `Cannot find module 'node-fetch'` (MODULE_NOT_FOUND). Вызывай просто `fetch(...)` без импорта. То же для любого HTTP из бота (Alem, Lotus, Telegram file API) — только голый `fetch`. + +### Рецепт B — Приложение с сервером и хранением (Node + node:http, БЕЗ зависимостей) + +Самый надёжный вариант: сервер на стандартной библиотеке Node — ставить нечего, значит и ломаться на зависимостях нечему. Данные хранятся в `data.json`. Фронт — статичный `index.html`, который ходит в API через `fetch`. + +**`package.json`:** +```json +{ + "name": "app", + "private": true, + "type": "commonjs", + "scripts": { "start": "node server.js" } +} +``` + +**`server.js`:** +```js +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({}); } }); + }); +} + +http.createServer(async (req, res) => { + // API: список записей + if (req.method === "GET" && req.url === "/api/items") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(readItems())); + } + // API: добавить запись + if (req.method === "POST" && req.url === "/api/items") { + const item = await body(req); + const items = readItems(); + items.push({ id: Date.now(), ...item }); + writeItems(items); + 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" : req.url.split("?")[0]; + 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)); +``` + +**`index.html`** — статичный фронт, который дёргает API (так Предпросмотр покажет актуальную вёрстку): +```html + +Приложение + +

Записи

+
+ + + +``` + +`.gitignore`: `data.json`, `node_modules/`, `.vibe42-run.log`, `.vibe42-run.pid`. Затем `run` → дай юзеру ссылку из вывода ЦЕЛИКОМ (с `?t=...`). + +### Блок «ИИ» — умный ответ БЕЗ ключей и БЕЗ SDK + +Нужен ИИ в боте/приложении? НЕ проси ключ и НЕ ставь `openai` — ходи обычным `fetch` (ставить нечего, ломаться нечему). `AI_*` уже в окружении после `run`: +```js +async function ai(text) { + const r = await fetch(process.env.AI_BASE_URL + "/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.AI_API_KEY }, + body: JSON.stringify({ model: process.env.AI_MODEL, messages: [{ role: "user", content: text }] }) + }); + if (!r.ok) throw new Error("ИИ недоступен, код " + r.status); + const j = await r.json(); + const out = j.choices?.[0]?.message?.content; + if (!out) throw new Error("ИИ вернул пустой ответ"); + return out; +} +``` +В боте: `bot.on("message:text", async (ctx) => ctx.reply(await ai(ctx.message.text)));`. Ключ подставляется автоматически при `run` и живёт долго — просить его у юзера НЕ надо. + +**⚠️ ЖЕЛЕЗНОЕ ПРАВИЛО: сбой ИИ показывай честно.** Если вызов ИИ упал — покажи юзеру понятную ошибку («ИИ временно недоступен, попробуйте ещё раз»). **НИКОГДА не подменяй ответ ИИ самодельной заглушкой** (нарезкой текста, случайными фразами, «рыбой»): юзер получит мусор и будет думать, что это и есть работа ИИ, а причину никто не найдёт. Лучше явная ошибка, чем тихая подделка. + +--- + +## 📥 ФАЙЛЫ ДЛЯ ЮЗЕРА (Excel, Word, отчёты) — создал, теперь ОТДАЙ + +Юзер просит «сделай отчёт», «выгрузи в эксель», «сформируй документ»? Файл мало создать — юзер должен его забрать. + +**Нужен Excel — бери команду `makexlsx`.** Она в твоём шелле, как `readdoc`, и делает настоящую книгу `.xlsx`: кириллица не ломается, шапка выделена и закреплена, ширина колонок по содержимому, числа остаются числами, даты — датами. + +```bash +# из CSV, который ты сам собрал +makexlsx Отчёт_июль.xlsx --from data.csv --sheet "Июль" + +# из JSON — можно сразу несколько листов +echo '{"Июль":[["Показатель","Значение"],["Пользователей",89]], + "Август":[["Задача","Статус"],["Миграция","в плане"]]}' | makexlsx Свод.xlsx +``` + +Принимает три вида JSON: строки `[[...],[...]]`, массив объектов `[{"Дата":"07.07"}, ...]` (ключи становятся шапкой) и словарь листов `{"Имя листа": [[...]]}`. + +**Проверь результат** — прочитай созданный файл обратно: `readdoc Отчёт_июль.xlsx`. + +**Правило имени: НЕ подделывай формат.** +- **НИКОГДА не клей двойное расширение** (`Otchet.xlsx.csv`, `report.docx.txt`). Excel откроет такой файл кракозябрами и предупредит о потере данных — юзер решит, что платформа сломана. +- Не используешь `makexlsx` — тогда честный `.csv` и **так и назови**, с BOM (`\uFEFF` в начале), иначе кириллица поедет. +- Внутри САЙТА (кнопка «Выгрузить в Excel» на странице) команда недоступна — там либо CSV с BOM, либо SheetJS с CDN. + +**Правило выдачи: ВСЕГДА скажи, где скачать.** Создал файл — следующей же фразой напиши юзеру: + +> Готово, файл `Otchet.xlsx` лежит в проекте. Открой вкладку **Файлы** справа и нажми **«Скачать»** напротив него. + +Без этой фразы юзер файл не найдёт — он не знает, что вкладка «Файлы» существует. + +**Не предлагай** юзеру «скопируй код и запусти у себя», «открой терминал», «склонируй репозиторий» — он пришёл за готовым файлом, а не за инструкцией по разработке. + +--- + +## 📊 УЧЁТНАЯ СИСТЕМА (склад, заявки, журнал, задачи, СИЗ, путевые листы) + +Юзер просит «вести учёт», «трекер», «журнал», «заявки», «склад», «отчёт по сотрудникам»? Это Рецепт B, но с несколькими сущностями и статусами. Не изобретай схему — бери эту. + +**Модель данных — один файл `data.json`, внутри списки:** +```js +// { "items": [...], "log": [...] } // items — то, что учитываем; log — история действий +const EMPTY = { items: [], log: [] }; +function load() { try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch { return EMPTY; } } +function save(d) { fs.writeFileSync(DATA, JSON.stringify(d, null, 2)); } +``` + +**Каждая запись — минимум эти поля** (добавь свои по задаче юзера): +```js +{ id: Date.now(), title: "Каска защитная", qty: 12, person: "Иванов И.", + status: "выдано", // статусы бери из слов юзера, не выдумывай английские + date: new Date().toISOString().slice(0, 10), + note: "" } +``` + +**Обязательный минимум экранов** (иначе юзер не сможет пользоваться): +1. **Таблица** всех записей + строка поиска (фильтр по подстроке на клиенте). +2. **Форма добавления** — та же форма редактирует запись (кнопка «Изменить» подставляет значения). +3. **Фильтр по статусу** — кнопки-чипсы над таблицей. +4. **Счётчики сверху**: всего / по каждому статусу. +5. **Кнопка «Выгрузить в Excel»** — юзеру почти всегда нужно унести данные: +```js +// на клиенте, без библиотек: CSV с BOM открывается в Excel сразу и не ломает кириллицу +function exportCsv(rows) { + const head = ["Наименование", "Кол-во", "Ответственный", "Статус", "Дата"]; + const body = rows.map(r => [r.title, r.qty, r.person, r.status, r.date] + .map(v => '"' + String(v ?? "").replace(/"/g, '""') + '"').join(";")); + const csv = "\uFEFF" + [head.join(";"), ...body].join("\n"); + const a = document.createElement("a"); + a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" })); + a.download = "vygruzka.csv"; a.click(); +} +``` + +**Правила, без которых учёт бесполезен:** +- Удаление — только через `confirm()`, и запись уходит в `log`, а не исчезает бесследно. +- Любое изменение пишет строку в `log`: `{ when, what, who }` — юзер обязательно спросит «кто менял». +- Даты показывай как `ДД.ММ.ГГГГ`, хранить можно как есть. +- Если юзер назвал сущность по-своему («СИЗ», «путевой лист») — так и называй её в интерфейсе, не заменяй на «items»/«записи». + +--- + +## 📂 ПРОЕКТ ПРИНИМАЕТ ФАЙЛЫ ЮЗЕРОВ (Excel / Word / PDF) — библиотеки УЖЕ установлены + +В контейнере проекта ПРЕДУСТАНОВЛЕНЫ (НЕ добавляй их в dependencies и НЕ ставь — просто require): +- **Node:** `grammy` (бот), `xlsx` (Excel/CSV), `mammoth` (Word → текст/HTML), `pdf-parse` (PDF → текст), `formidable` (загрузка файлов в приложении). +- **Python:** `openpyxl`, `python-docx`, `pypdf`, `requests`. + +Всё, чего в списке нет, — добавляй в `dependencies`, поставит `run`. + +**Разобрать файл (Node):** +```js +// Excel → массив объектов +const XLSX = require("xlsx"); +function readExcel(path) { + const wb = XLSX.readFile(path); + return XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); +} +// Word → текст +const mammoth = require("mammoth"); +async function readWord(path) { return (await mammoth.extractRawText({ path })).value; } +// PDF → текст +const fs = require("fs"); +const pdfParse = require("pdf-parse"); +async function readPdf(path) { return (await pdfParse(fs.readFileSync(path))).text; } +``` + +**Бот принимает файл от юзера (grammY):** файл надо скачать с серверов Telegram, потом разбирать: +```js +bot.on("message:document", async (ctx) => { + const file = await ctx.getFile(); + const url = "https://api.telegram.org/file/bot" + process.env.BOT_TOKEN + "/" + file.file_path; + const buf = Buffer.from(await (await fetch(url)).arrayBuffer()); + const name = ctx.message.document.file_name || "file"; + if (name.endsWith(".xlsx") || name.endsWith(".xls")) { + const wb = require("xlsx").read(buf); + const rows = require("xlsx").utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); + return ctx.reply("Строк в таблице: " + rows.length); + } + if (name.endsWith(".docx")) { + const t = (await require("mammoth").extractRawText({ buffer: buf })).value; + return ctx.reply("Текст из Word (начало): " + t.slice(0, 300)); + } + if (name.endsWith(".pdf")) { + const t = (await require("pdf-parse")(buf)).text; + return ctx.reply("Текст из PDF (начало): " + t.slice(0, 300)); + } + ctx.reply("Пока умею: .xlsx, .docx, .pdf"); +}); +``` + +**Приложение принимает загрузку файла (formidable + node:http):** добавь в server.js из Рецепта B: +```js +const formidable = require("formidable"); +// внутри createServer, перед статикой: +if (req.method === "POST" && req.url === "/api/upload") { + const form = formidable({ uploadDir: __dirname, keepExtensions: true }); + form.parse(req, (err, fields, files) => { + const f = files.file && files.file[0]; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: !err && !!f, path: f ? f.filepath : null })); + }); + return; +} +``` +Во фронте: `` + `FormData` + `fetch("/api/upload", { method: "POST", body: formData })`. Дальше разбирай файл рецептами выше и отвечай юзеру данными. + +--- + +## 🚑 НЕ ЗАПУСКАЕТСЯ? ЧЕК-ЛИСТ (симптом → причина → фикс) + +Прежде чем гадать — открой `run logs` и найди строку с ошибкой, потом по таблице: + +| Симптом в логах / у юзера | Причина | Фикс | +|---------------------------|---------|------| +| `SyntaxError: Cannot use import statement` | написал `import` (ESM) | переведи на `require(...)` (CommonJS). У нас по умолчанию CommonJS. | +| `run` пишет «не нашёл, что запускать» | нет `package.json` со `scripts.start` и нет `bot.js`/`server.js` | добавь `package.json` с `"scripts": {"start": "node bot.js"}` (или `server.js`) | +| `Error: Cannot find module 'X'` | зависимости `X` нет в `package.json` | добавь `X` в `dependencies`, запусти `run` заново (он поставит). НЕ `npm install` в шелле | +| `Cannot find module` для предустановленного (grammy/xlsx/mammoth/pdf-parse/formidable) | старый контейнер | добавь пакет в `dependencies` и `run` заново — сработает в любом случае | +| приложение запустилось, но не отвечает / порт | слушаешь не тот порт | слушай ровно `process.env.PORT || 3000` | +| `Empty token!` / бот не видит токен | `.env` не загружается | в `package.json` → `"start": "node --env-file=.env bot.js"`; `.env` рядом с `bot.js` | +| бот не отвечает в Telegram | используешь webhook | только `bot.start()` (long-polling), НЕ webhooks; токен в `.env` → `process.env.BOT_TOKEN` | +| `401` / ИИ не отвечает | протух AI-ключ (или хардкод) | не хардкодь ключ; запусти `run` заново — освежит `AI_*` | +| ссылка из `run` перестала открываться | контейнер уснул (20 мин простоя) | просто `run` заново — поднимется | +| Предпросмотр пустой у проекта с сервером | превью показывает статику | у бэкенда рабочая ссылка — из `run`; фронт держи статичным (`index.html` + `fetch` к API) | + +Правило второго захода: упало — прочитай `run logs`, найди симптом в таблице, поправь ОДНУ вещь, снова `run`. Не переписывай всё с нуля. + +--- + +## 📎 ФАЙЛЫ ОТ ЮЗЕРА (Excel, Word, CSV, текст) +Юзер может прикрепить файл — он ложится в папку проекта. **Читать Excel/Word напрямую (`cat`) нельзя** — они бинарные (zip+xml). Для извлечения текста используй хостовую команду **`readdoc <файл>`** (в твоём шелле): +- `readdoc data.xlsx` — все листы Excel как таблицы (значения через табуляцию); +- `readdoc doc.docx` — текст Word по абзацам; +- `readdoc doc.pdf` — текст из PDF (сканы без текстового слоя не прочтёт); +- `readdoc table.csv` / `.tsv` / `.txt` — как есть (можно и `cat`); +- большой файл — ограничь: `readdoc big.xlsx --max 100`. +Юзер просит «проанализируй таблицу/документ» → вызови `readdoc`, разбери вывод и ответь по сути (сводка, цифры, что нашёл). Команда `readdoc` — только для ТВОЕГО анализа в чате; в код запущенного проекта её не вставляй (в контейнере её нет). + +**Не нашёл файл в текущей папке?** Загруженный файл мог лечь в КОРЕНЬ воркспейса (на уровень выше cwd). Проверь `ls ..` и читай/копируй оттуда: `readdoc "../имя файла.xlsx"` или `cp "../имя.xlsx" .`. Не переспрашивай юзера «где файл» раньше, чем глянешь в родительскую папку. + +**Используй ВСЕ данные, не «первые N».** Если строишь из файла таблицу/список/каталог — выведи ВСЕ записи, а не 5-6. **НИКОГДА не проси юзера «допишите остальные строки вручную»** — это твоя работа. Данных много (десятки+ строк)? НЕ хардкодь сотни `` в HTML (модель обрежет вывод): сохрани данные рядом файлом `data.json` (массив объектов) и отрисуй в `index.html` через JS — `fetch('data.json').then(r=>r.json()).then(rows => …)` + `map`/`innerHTML`. Так отрисуются все строки, и появится место для поиска/фильтра. `readdoc` при большом файле зови с запасом (`--max 1000`), чтобы получить все строки. + +--- + +## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет) + +1. Поздоровайся коротко: «Привет! Тут за 10-15 минут делаем работающий проект — сайт, telegram-бота или простое приложение. Что хочешь сделать?» +2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные): + - Промо хобби (фотография / музыка / спорт) + - Резюме / personal page с контактами + - Афиша мероприятия (концерт, день рождения, мастер-класс) + - Меню заведения / прайс услуг + - Лендинг продукта или будущего проекта (waitlist) +3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы). +4. Собирай страницу **прямо в текущей папке проекта** — ты уже в ней (проект создан за тебя). Создавай `index.html`/`style.css`/`script.js` тут же. **НЕ запускай `./new-project`** и не делай `cd` в другие папки. Не спрашивай разрешения на каждый шаг. + +--- + +## 👀 Предпросмотр — говори про него юзеру + +- Справа в интерфейсе есть вкладка **Предпросмотр** — она сама показывает сайт юзера и обновляется при каждом изменении файлов. Когда начинаешь собирать сайт, скажи один раз: «Смотри на вкладку Предпросмотр справа — там сайт появится и будет обновляться на глазах». +- Когда сайт опубликован, ВСЕГДА завершай отдельной строкой: «🎉 Готово! Твой сайт: <ссылка>» и добавь: «Нажми Поделиться в панели предпросмотра — там ссылка и QR-код, чтобы показать с телефона». + +--- + +## 🧠 Память о юзере — веди её сам + +Файл памяти: `/srv/opencode/workspaces/users/dauren_yesserkepov/.mimo/memory.md` (dot-папка `.mimo` не видна в Предпросмотре — так и задумано). Папка `.mimo/` уже создана, тебе нужно только читать/писать `memory.md`. + +**В НАЧАЛЕ каждой сессии** — прочитай `/srv/opencode/workspaces/users/dauren_yesserkepov/.mimo/memory.md` (если есть) ПЕРЕД первым ответом. Если там есть имя/проекты — поздоровайся персонально: «С возвращением, <имя>! Продолжим <последний проект> или сделаем новый?». Если файла нет — это новый юзер, работай по обычному сценарию. + +**ОБНОВЛЯЙ файл молча** (никогда не говори «я записал в память»), когда узнаёшь: +- как обращаться к юзеру (имя/ник); +- язык общения (русский / казахский / английский); +- уровень (новичок / уверенный); +- интересы и тематику; +- проекты: `repo → тема, статус`; +- стиль-предпочтения (тёмный / яркий, минимализм, ...); +- незакрытые хвосты («хотел добавить фото», «вернёмся к форме»). + +**Формат:** короткий markdown-список, максимум ~30 строк. Старое неактуальное — удаляй, не копи. + +**ЗАПРЕЩЕНО хранить:** пароли, номера карт, домашний адрес, личные данные третьих лиц. + +**Лотус ≠ владелец аккаунта.** Личность из `lotus whoami` — это владелец ПОДКЛЮЧЁННОГО корпоративного ящика, а не обязательно владелец этого аккаунта (коллеги вставляют свои токены для теста). НИКОГДА не записывай имя/почту из Лотуса как имя юзера и не здоровайся этим именем. Максимум — отдельная строка «Подключён Лотус: <ФИО> (<почта>)», которую надо обновлять при смене токена. Обращайся к юзеру только по имени, которым он сам представился в чате. + +**Говори языком юзера.** Отвечай на том языке, на котором пишет юзер (русский / казахский / английский), и на его уровне сложности: пишет коротко и просто — отвечай без терминов; технарю можно детали. Юзер переключил язык — переключись следом. + +**Это касается ВСЕГО видимого текста, а не только финального ответа.** Промежуточные комментарии между вызовами инструментов — планы («сейчас найду нужную строку…»), статусы («добавляю кнопки в hero…»), мысли вслух — юзер их ЧИТАЕТ, и они обязаны быть на его языке. Писать «We'll edit line 139» русскоязычному юзеру — ошибка. По-английски остаются только код, команды и имена файлов. + +--- + +## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО + +Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**. + +Скажи: «Давай определимся, я задам 3 коротких вопроса: +1. Это для тебя лично, для проекта/бизнеса, или для события? +2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио? +3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?» + +После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать. + +--- + +## 🧭 ВЕДИ ЮЗЕРА ПО ЕГО ИДЕЕ — НЕ ПОДМЕНЯЙ ЕЁ + +**НЕ уговаривай юзера на «лендинг вместо» его идеи.** Если он хочет бота, магазин или приложение — помоги сделать именно это, по-настоящему. Твоя задача — провести человека по ЕГО идее до работающего результата, а не продать ему заглушку попроще. + +Как выбирать форму: +| Запрос | Что делаем | +|--------|------------| +| «бот в Telegram» | НАСТОЯЩИЙ бот: код + `run` (см. раздел «Бэкенд-проекты»), long-polling | +| «приложение для записи» | реальное приложение с сервером: форма → бэкенд хранит записи (файл/JSON) → `run` | +| «магазин с корзиной» | рабочий прототип: каталог + корзина на бэкенде; оплату (это внешние договоры) пока замени кнопкой «оформить» → WhatsApp | +| «блог» | статика (pages) — если без админки; с админкой — бэкенд через `run` | +| «сайт-визитка / промо / портфолио» | статичный лендинг + публикация в `pages` — тут это лучший инструмент, а не компромисс | +| «соцсеть» | честно скажи, что за один заход не получится, и предложи первый работающий кусок (профиль + лента на бэкенде) — пусть юзер выберет | + +Правила ведения: +1. Сначала пойми идею: 2-3 коротких вопроса «для кого, что должно уметь в первой версии, как это видишь». +2. Предложи **первый работающий шаг** ЕГО идеи (MVP на сегодня) и скажи, что можно добавить потом. Не ужимай идею молча. +3. Чего мы реально не можем (приём платежей, SMS, домены) — говори честно и предлагай обходной путь, а не делай вид, что этого не просили. +4. **Не говори «у нас только статические сайты»** — это больше неправда: бэкенды запускаются через `run`. + +--- + +## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера) + +### A — Промо продукта/услуги +**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер). + +### B — Personal / резюме +**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки). + +### C — Афиша мероприятия +**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт). + +### D — Меню / прайс +**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка). + +### E — Waitlist для будущего проекта +**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма). + +Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического. + +--- + +## 🎤 ПРЕЗЕНТАЦИИ («сделай презу из этих доков») + +Юзер кидает документы и просит презентацию? Флоу: +1. Прочитай доки командой `readdoc` (умеет .xlsx, .docx, .pdf, .csv/.txt). Файлы могут лежать в корне воркспейса — проверь `ls ..`. +2. Выдели суть: 5-9 слайдов, на слайде заголовок + 3-5 коротких пунктов. Не переноси абзацы целиком — сжимай до тезисов. +3. Возьми скелет ниже (это ОДИН файл `index.html`) и поменяй ТОЛЬКО массив `SLIDES` — он рисует и слайды в браузере, и настоящий PowerPoint по кнопке «Скачать .pptx». Остальной код не трогай. +4. Покажи через Предпросмотр (стрелки/клавиши листают). Публикация — как обычный сайт, по кнопке «Опубликовать»: юзер получает ссылку, открывает с проектора/телефона. +5. Скажи юзеру: «Кнопка Скачать .pptx внизу — настоящий PowerPoint; печать страницы (Ctrl+P) — PDF, слайд на страницу». + +Скелет (файл `index.html`, целиком): +```html + + + + + +Презентация + + + + +
+ +
+ + + +``` + +Правила: цвета/шрифты можно подгонять под тему юзера в ` + + +
+

Заголовок

+

Подзаголовок — пара предложений о чём это.

+ Начать +
+
+

Секция

+
Контент карточки.
+
Контент карточки.
+
+ + +``` + +## Что НЕ юзать + +- Bootstrap / Material UI / Chakra / Ant Design — слишком тяжело +- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG +- jQuery — vanilla JS достаточно + +## Что МОЖНО (если очень надо) + +- **Tailwind через CDN**: `` +- **Placeholder картинки**: `https://picsum.photos/800/600` +- **Google Fonts через ``** в head + +## 📦 Готовые рецепты (используй их, не изобретай) + +### Фото без ключей +- Детерминированные (одно и то же слово → одна и та же картинка): `https://picsum.photos/seed/coffee/800/600`. Меняй `coffee` на любое слово-seed. +- Тематические (по тегу): `https://loremflickr.com/800/600/coffee` — тег прямо в URL (`/coffee`, `/mountains`, `/food`). Тема не гарантируется на 100%, но обычно в кассу. + +### Кнопка WhatsApp +```html +Написать в WhatsApp +``` +`7XXXXXXXXXX` — номер в межд. формате без `+`. `text=` — предзаполненное сообщение. + +### Форма заявки без бэкенда +```html +
+ + + +
+``` +Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует, письмо-подтверждение придёт один раз). + +### Google Fonts (максимум 2 шрифта: один display + один text) +```html + + +``` +В CSS: `font-family:'Unbounded',sans-serif` для заголовков, `'Manrope',sans-serif` для текста. + +### Favicon-эмодзи (inline SVG data-URI, без файла) +```html + +``` +Меняй эмодзи `☕` на подходящее теме. + +### Карта (Google Maps embed, без ключа) +На maps.google.com найди место → «Поделиться» → «Встроить карту» → скопируй iframe. Вставь как есть: +```html + +``` + +### Плавные появления при скролле (IntersectionObserver) +```html + + +``` +Добавь класс `reveal` любой секции/карточке — появится плавно при прокрутке. diff --git a/index.html b/index.html new file mode 100644 index 0000000..f847730 --- /dev/null +++ b/index.html @@ -0,0 +1,112 @@ + + + + + +День рождения Даурена — 20.07.2027 + + + + + + + + +
+ +
+
🐉 Фэнтези-вечер · врата откроются в 19:00
+

Приглашение на день рождения Даурена

+

Драконы, эльфы и магия ждут тебя. Надень фэнтези-образ и стань частью нашего королевства — 20 июля 2027 года в 19:00.

+
+
0дней
+
0часов
+
0минут
+
0секунд
+
+ + +
+ +
+ +
+
+

Пророчество гласит

+
+
📅

Дата

20 июля 2027 года

+
⏰

Время

Врата откроются в 19:00

+
📍

Место

Замок уточняется 🏰
скажи мне адрес — впишу

+
🛡️

Дресс-код

Фэнтези-образы приветствуются: мантии, короны, крылья — всё можно!

+
+
+
+ +
+
+

Клятва участия

+

Подтверди, что присягнёшь на верность королевству — оставь свои данные, чтобы мы знали, кого ждать.

+
+ + + + + +

+
+
+
+ +
+
+

Королевская сокровищница

+

Выбери подарок и «забери» его — так никто не подарит два одинаковых дара. Помеченные звёздами ✦ уже обрели хозяина.

+
+

+
+
+ +
+
+
+ 🗝️ Панель хозяина замка +
+ + +

+ +
+
+
+
+ +
+
+

🐉

+

С любовью и магией, Даурен · 20.07.2027

+
+
+ + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..789abd4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,10 @@ +{ + "name": "birthday-invite", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "birthday-invite" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..65e0f7d --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "name": "birthday-invite", + "private": true, + "type": "commonjs", + "scripts": { "start": "node server.js" } +} diff --git a/script.js b/script.js new file mode 100644 index 0000000..a2a0573 --- /dev/null +++ b/script.js @@ -0,0 +1,169 @@ +function api(url, opts) { + return fetch(url, opts).then(function (r) { + if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || "Ошибка сервера"); }); + return r.json(); + }); +} + +/* ==== Обратный отсчёт до 20.07.2027 19:00 ==== */ +var TARGET = new Date("2027-07-20T19:00:00").getTime(); +function tick() { + var diff = Math.max(0, TARGET - Date.now()); + document.getElementById("cd-d").textContent = Math.floor(diff / 86400000); + document.getElementById("cd-h").textContent = Math.floor(diff / 3600000) % 24; + document.getElementById("cd-m").textContent = Math.floor(diff / 60000) % 60; + document.getElementById("cd-s").textContent = Math.floor(diff / 1000) % 60; +} +setInterval(tick, 1000); +tick(); + +/* ==== Мок-данные для Предпросмотра (когда сервер не запущен) ==== */ +var MOCK = [ + { id: 101, title: "Книга «Властелин колец» (подарочное издание)", emoji: "📚", reservedBy: "" }, + { id: 102, title: "Шахматы в фэнтези-стиле (драконы против эльфов)", emoji: "♟️", reservedBy: "" }, + { id: 103, title: "Настольная игра «Война кольца»", emoji: "🎲", reservedBy: "Айгуль" } +]; +/* ==== Регистрация ==== */ +var regForm = document.getElementById("regForm"); +regForm.addEventListener("submit", function (e) { + e.preventDefault(); + var msg = document.getElementById("regMsg"); + var data = { + name: document.getElementById("r-name").value.trim(), + phone: document.getElementById("r-phone").value.trim(), + guests: document.getElementById("r-guests").value, + note: document.getElementById("r-note").value.trim() + }; + msg.className = "msg"; msg.textContent = "Отправляем клятву в королевство..."; + api("/api/regs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }) + .then(function (j) { + msg.className = "msg ok"; + msg.textContent = "🎉 Ты в списке героев! Врата откроются 20.07.2027 в 19:00 — до встречи!"; + var c = document.getElementById("confirmedCount"); + c.hidden = false; c.querySelector("b").textContent = j.total; + regForm.reset(); + }) + .catch(function (err) { + if (err.message === "Failed to fetch" || /fetch/i.test(err.message)) { + msg.className = "msg ok"; + msg.textContent = "🎉 Ты в списке героев! (демо-режим: сервер не запущен, на живой ссылке заявка сохранится)"; + } else { + msg.className = "msg err"; + msg.textContent = "Не получилось: " + err.message; + } + }); +}); + +/* ==== Вишлист ==== */ +function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); +} +function renderWishes(wishes) { + var grid = document.getElementById("wishGrid"); + var note = document.getElementById("wishNote"); + if (!wishes.length) { + grid.innerHTML = ""; + note.textContent = "Сокровищница пока пуста — скоро наполним ✨"; + return; + } + grid.innerHTML = wishes.map(function (w) { + var taken = !!w.reservedBy; + return '
' + + '
' + esc(w.emoji) + '
' + + '
' + esc(w.title) + '
' + + '
' + (taken ? "✦ Забрал(а): " + esc(w.reservedBy) : "Ещё свободен") + '
' + + (taken + ? '' + : '') + + '
'; + }).join(""); + note.textContent = wishes.filter(function (w) { return w.reservedBy; }).length + " из " + wishes.length + " даров уже обрели хозяина"; + grid.querySelectorAll(".g-btn:not(.done)").forEach(function (btn) { + btn.addEventListener("click", function () { + var guest = prompt("Напиши своё имя, чтобы мы знали — кто несёт этот дар:"); + if (!guest) return; + api("/api/wish/reserve", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: Number(btn.dataset.id), guest: guest.trim() }) }) + .then(function (j) { renderWishes(j.data); }) + .catch(function (err) { alert(err.message); }); + }); + }); +} +function loadWishes() { + api("/api/wish").then(function (j) { renderWishes(j.data); }) + .catch(function () { renderWishes(MOCK); }); +} +loadWishes(); + +/* ==== Панель хозяина ==== */ +var pin = sessionStorage.getItem("bday_pin") || ""; +var aLogin = document.getElementById("a-login"); +var aPanel = document.getElementById("a-panel"); +var aMsg = document.getElementById("a-msg"); + +function renderAdminWishes(wishes) { + document.getElementById("a-wishes").innerHTML = wishes.map(function (w) { + return '
' + esc(w.emoji) + '' + + '' + esc(w.title) + (w.reservedBy ? ' · ' + esc(w.reservedBy) + '' : "") + '' + + '
'; + }).join(""); + document.querySelectorAll("#a-wishes .a-del").forEach(function (b) { + b.addEventListener("click", function () { + api("/api/admin/wish/del", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pin: pin, id: Number(b.dataset.id) }) }) + .then(function (j) { renderAdminWishes(j.data); renderWishes(j.data); }) + .catch(function (err) { aMsg.className = "msg err"; aMsg.textContent = err.message; }); + }); + }); +} +function loadAdmin() { + api("/api/admin/regs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pin: pin }) }) + .then(function (j) { + aMsg.className = "msg ok"; aMsg.textContent = "Добро пожаловать, хозяин замка 🏰"; + aPanel.hidden = false; + var regs = j.data; + document.getElementById("a-regs").innerHTML = regs.length + ? regs.map(function (r) { + return '
' + esc(r.name) + '
' + esc(r.phone) + " · гостей: " + r.guests + (r.note ? " · «" + esc(r.note) + "»" : "") + " · " + r.date + '
'; + }).join("") + : '
Пока никто не подтвердил участие.
'; + document.getElementById("confirmedCount").hidden = false; + document.getElementById("confirmedCount").querySelector("b").textContent = j.total; + return api("/api/wish"); + }) + .then(function (j) { renderAdminWishes(j.data); }) + .catch(function (err) { + aMsg.className = "msg err"; aMsg.textContent = "Неверный код или ошибка: " + err.message; + }); +} +aLogin.addEventListener("click", function () { + pin = document.getElementById("a-pin").value.trim(); + if (!pin) return; + sessionStorage.setItem("bday_pin", pin); + loadAdmin(); +}); +document.getElementById("a-pin").addEventListener("keydown", function (e) { if (e.key === "Enter") aLogin.click(); }); + +document.getElementById("a-wadd").addEventListener("click", function () { + var title = document.getElementById("a-wtitle").value.trim(); + var emoji = document.getElementById("a-wemoji").value.trim() || "🎁"; + if (!title) return; + api("/api/admin/wish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pin: pin, title: title, emoji: emoji }) }) + .then(function (j) { + renderAdminWishes(j.data); renderWishes(j.data); + document.getElementById("a-wtitle").value = ""; + document.getElementById("a-wemoji").value = ""; + }) + .catch(function (err) { aMsg.className = "msg err"; aMsg.textContent = err.message; }); +}); + +if (pin) { + document.getElementById("a-pin").value = pin; + loadAdmin(); +} + +/* ==== Плавные появления ==== */ +var io = new IntersectionObserver(function (entries) { + entries.forEach(function (e) { if (e.isIntersecting) e.target.classList.add("in"); }); +}, { threshold: .15 }); +document.querySelectorAll(".reveal").forEach(function (el) { io.observe(el); }); diff --git a/server.js b/server.js new file mode 100644 index 0000000..65b2170 --- /dev/null +++ b/server.js @@ -0,0 +1,135 @@ +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"); +const OWNER_PIN = process.env.OWNER_PIN || "drakon2027"; + +function load() { + try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } + catch (_) { return { regs: [], wishes: [], log: [] }; } +} +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({}); } }); + }); +} +function json(res, code, obj) { + res.writeHead(code, { "Content-Type": "application/json" }); + res.end(JSON.stringify(obj)); +} +function totalGuests(d) { + return d.regs.reduce((s, r) => s + (parseInt(r.guests) || 1), 0); +} +function pinOk(b) { return b && b.pin === OWNER_PIN; } + +http.createServer(async (req, res) => { + const url = req.url.split("?")[0]; + + // Публичный: зарегистрироваться на праздник + if (req.method === "POST" && url === "/api/regs") { + const b = await body(req); + const d = load(); + if (!b.name || !b.phone) return json(res, 400, { ok: false, error: "Укажи имя и телефон" }); + const rec = { + id: Date.now(), + name: String(b.name).slice(0, 100), + phone: String(b.phone).slice(0, 30), + guests: Math.max(1, parseInt(b.guests) || 1), + note: String(b.note || "").slice(0, 300), + date: new Date().toISOString().slice(0, 10) + }; + d.regs.push(rec); + d.log.push({ when: new Date().toISOString(), what: "Регистрация: " + rec.name }); + save(d); + return json(res, 200, { ok: true, total: totalGuests(d) }); + } + + // Публичный: список подарков + if (req.method === "GET" && url === "/api/wish") { + return json(res, 200, { ok: true, data: load().wishes }); + } + + // Публичный: забронировать подарок + if (req.method === "POST" && url === "/api/wish/reserve") { + const b = await body(req); + const d = load(); + const w = d.wishes.find((x) => x.id === b.id); + if (!w) return json(res, 404, { ok: false, error: "Подарок не найден" }); + if (w.reservedBy) return json(res, 400, { ok: false, error: "Подарок уже забронирован" }); + if (!b.guest) return json(res, 400, { ok: false, error: "Напиши, кто дарит" }); + w.reservedBy = String(b.guest).slice(0, 100); + d.log.push({ when: new Date().toISOString(), what: "Подарок «" + w.title + "» забронирован: " + w.reservedBy }); + save(d); + return json(res, 200, { ok: true, data: d.wishes }); + } + + // Публичный: снять бронь (на случай ошибки гостя) + if (req.method === "POST" && url === "/api/wish/unreserve") { + const b = await body(req); + const d = load(); + const w = d.wishes.find((x) => x.id === b.id); + if (!w) return json(res, 404, { ok: false, error: "Подарок не найден" }); + if (!w.reservedBy) return json(res, 400, { ok: false, error: "Брони нет" }); + const who = w.reservedBy; + w.reservedBy = ""; + d.log.push({ when: new Date().toISOString(), what: "Бронь снята с «" + w.title + "» (" + who + ")" }); + save(d); + return json(res, 200, { ok: true, data: d.wishes }); + } + + // Админ: список гостей + if (req.method === "POST" && url === "/api/admin/regs") { + const b = await body(req); + if (!pinOk(b)) return json(res, 403, { ok: false, error: "Неверный код" }); + const d = load(); + return json(res, 200, { ok: true, data: d.regs, total: totalGuests(d) }); + } + + // Админ: добавить подарок + if (req.method === "POST" && url === "/api/admin/wish") { + const b = await body(req); + if (!pinOk(b)) return json(res, 403, { ok: false, error: "Неверный код" }); + const d = load(); + if (!b.title) return json(res, 400, { ok: false, error: "Название обязательно" }); + d.wishes.push({ + id: Date.now(), + title: String(b.title).slice(0, 100), + emoji: String(b.emoji || "🎁").slice(0, 4), + reservedBy: "" + }); + d.log.push({ when: new Date().toISOString(), what: "Добавлен подарок: " + b.title }); + save(d); + return json(res, 200, { ok: true, data: d.wishes }); + } + + // Админ: удалить подарок + if (req.method === "POST" && url === "/api/admin/wish/del") { + const b = await body(req); + if (!pinOk(b)) return json(res, 403, { ok: false, error: "Неверный код" }); + const d = load(); + const w = d.wishes.find((x) => x.id === b.id); + d.wishes = d.wishes.filter((x) => x.id !== b.id); + if (w) d.log.push({ when: new Date().toISOString(), what: "Удалён подарок: " + w.title }); + save(d); + return json(res, 200, { ok: true, data: d.wishes }); + } + + // Статика: index.html / style.css / script.js / data.json + let file = 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" : ext === ".json" ? "application/json" : "text/html"; + res.writeHead(200, { "Content-Type": type + "; charset=utf-8" }); + return res.end(fs.readFileSync(full)); + } + + json(res, 404, { ok: false, error: "Not found" }); +}).listen(PORT, () => console.log("Сервер на порту " + PORT)); diff --git a/style.css b/style.css new file mode 100644 index 0000000..f66d3bc --- /dev/null +++ b/style.css @@ -0,0 +1,167 @@ +:root { + --ink: #0d081a; + --night: #150d2b; + --panel: #1d1336; + --gold: #f2c14e; + --gold-2: #ffdf8a; + --violet: #8b5cf6; + --text: #f0e9ff; + --muted: #a99bc4; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html { scroll-behavior: smooth; } +body { + font: 17px/1.6 "Manrope", -apple-system, "Segoe UI", sans-serif; + color: var(--text); + background: var(--ink); + overflow-x: hidden; +} +.container { max-width: 1140px; margin: 0 auto; padding: 80px 24px; } +.narrow { max-width: 720px; } +.center { text-align: center; } +.gold { color: var(--gold); } +.muted { color: var(--muted); } +h1, h2, h3, .badge { font-family: "Unbounded", sans-serif; } + +/* ==== HERO ==== */ +.hero { position: relative; background: radial-gradient(1200px 700px at 50% -10%, #2a1652 0%, var(--night) 55%, var(--ink) 100%); padding: 90px 0 120px; text-align: center; } +.stars { + position: absolute; inset: 0; pointer-events: none; + background-image: + radial-gradient(1.5px 1.5px at 20% 30%, #fff, transparent), + radial-gradient(1px 1px at 40% 70%, #ffe9b0, transparent), + radial-gradient(2px 2px at 60% 20%, #fff, transparent), + radial-gradient(1px 1px at 75% 55%, #d8c7ff, transparent), + radial-gradient(1.5px 1.5px at 85% 25%, #fff, transparent), + radial-gradient(1px 1px at 90% 75%, #ffe9b0, transparent), + radial-gradient(1px 1px at 10% 60%, #d8c7ff, transparent), + radial-gradient(1.5px 1.5px at 30% 85%, #fff, transparent); +} +.hero-inner { position: relative; z-index: 1; } +.badge { + display: inline-block; background: rgba(139, 92, 246, .18); + border: 1px solid rgba(242, 193, 78, .4); color: var(--gold-2); + border-radius: 999px; padding: 8px 18px; font-size: 13px; letter-spacing: .4px; + margin-bottom: 28px; +} +.title { font-size: clamp(34px, 6vw, 58px); font-weight: 700; line-height: 1.12; margin-bottom: 22px; } +.subtitle { font-size: 19px; color: var(--muted); max-width: 640px; margin: 0 auto 38px; } +.countdown { display: flex; gap: 14px; justify-content: center; margin-bottom: 22px; flex-wrap: wrap; } +.cell { + background: rgba(29, 19, 54, .85); border: 1px solid rgba(242, 193, 78, .35); + border-radius: 14px; min-width: 86px; padding: 14px 10px; + box-shadow: 0 0 24px rgba(139, 92, 246, .25); +} +.cell b { display: block; font-family: "Unbounded", sans-serif; font-size: 30px; color: var(--gold-2); line-height: 1.1; } +.cell span { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; } +.confirmed { color: var(--muted); margin-bottom: 26px; } +.confirmed b { color: var(--gold-2); } +.cta { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; } +.btn { + display: inline-block; padding: 15px 30px; border-radius: 12px; + font-weight: 800; font-size: 16px; text-decoration: none; cursor: pointer; + border: 1px solid transparent; transition: transform .15s ease, box-shadow .15s ease; + font-family: "Manrope", sans-serif; +} +.btn:hover { transform: translateY(-2px); } +.btn-gold { background: linear-gradient(135deg, var(--gold), var(--gold-2)); color: #2a1802; box-shadow: 0 6px 24px rgba(242, 193, 78, .4); } +.btn-ghost { background: transparent; color: var(--text); border-color: rgba(242, 193, 78, .5); } +.btn-block { width: 100%; } +.wave { position: absolute; bottom: -1px; left: 0; width: 100%; height: 70px; display: block; } + +/* ==== SECTIONS ==== */ +.details { background: #0d081a; } +.h2 { font-size: clamp(26px, 4vw, 36px); margin-bottom: 16px; } +.lead { color: var(--muted); max-width: 640px; margin: 0 auto 40px; font-size: 18px; } +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 18px; } +.card { + background: var(--panel); border: 1px solid rgba(139, 92, 246, .25); + border-radius: 16px; padding: 30px 26px; + box-shadow: 0 10px 30px rgba(0, 0, 0, .35); +} +.card .ico { font-size: 40px; margin-bottom: 14px; } +.card h3 { font-size: 18px; margin-bottom: 8px; color: var(--gold-2); } +.card p { color: var(--muted); } + +.reg { background: linear-gradient(180deg, #0d081a 0%, #150d2b 100%); } +.form { max-width: 560px; margin: 0 auto; } +.form label { display: block; font-weight: 600; font-size: 14px; color: var(--muted); margin-bottom: 16px; } +.form input, .form select, .form textarea { + width: 100%; margin-top: 6px; padding: 12px 14px; border-radius: 10px; + border: 1px solid rgba(139, 92, 246, .35); background: #120a26; color: var(--text); + font: 15px "Manrope", sans-serif; outline: none; +} +.form input:focus, .form select:focus, .form textarea:focus { border-color: var(--gold); } +.msg { margin-top: 14px; font-weight: 700; min-height: 24px; } +.msg.ok { color: #7cf0a3; } +.msg.err { color: #ff8f8f; } + +.wish { background: var(--ink); } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; } +.gift { + background: var(--panel); border: 1px solid rgba(139, 92, 246, .25); + border-radius: 16px; padding: 24px 20px; display: flex; flex-direction: column; + gap: 12px; box-shadow: 0 10px 26px rgba(0, 0, 0, .35); +} +.gift.taken { opacity: .62; border-color: rgba(242, 193, 78, .45); } +.gift .g-emoji { font-size: 42px; } +.gift .g-title { font-weight: 700; font-size: 17px; flex: 1; } +.gift .g-status { font-size: 13px; color: var(--muted); min-height: 20px; } +.gift .g-status.taken { color: var(--gold-2); font-weight: 700; } +.gift .g-btn { + padding: 10px 16px; border-radius: 10px; font-weight: 800; cursor: pointer; + background: linear-gradient(135deg, var(--gold), var(--gold-2)); color: #2a1802; border: none; font-size: 14px; +} +.gift .g-btn.done { background: rgba(139, 92, 246, .2); color: var(--muted); cursor: default; } + +/* ==== ADMIN ==== */ +.admin { padding-bottom: 20px; } +.admin-box { background: #140c26; } +.admin-box summary { + cursor: pointer; font-family: "Unbounded", sans-serif; font-weight: 600; + font-size: 16px; color: var(--gold-2); padding: 6px 0; +} +.admin-inner { margin-top: 18px; } +.admin-inner h3 { font-size: 15px; margin: 22px 0 10px; color: var(--gold-2); } +.admin-inner label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 10px; } +.admin-inner input { + width: 100%; padding: 10px 12px; border-radius: 10px; + border: 1px solid rgba(139, 92, 246, .35); background: #0f081f; color: var(--text); + font: 14px "Manrope", sans-serif; outline: none; +} +.admin-inner .btn { padding: 10px 18px; font-size: 14px; margin-top: 8px; } +.a-list { display: flex; flex-direction: column; gap: 8px; } +.a-row { + display: flex; align-items: center; gap: 10px; padding: 10px 12px; + background: #120a26; border-radius: 10px; font-size: 14px; +} +.a-row .grow { flex: 1; min-width: 0; } +.a-row .guest-name { font-weight: 700; color: var(--gold-2); } +.a-row .guest-info { color: var(--muted); font-size: 13px; } +.a-del { + background: transparent; border: 1px solid rgba(255, 143, 143, .5); color: #ff8f8f; + border-radius: 8px; padding: 4px 10px; cursor: pointer; font-size: 12px; flex-shrink: 0; +} +.a-add { display: flex; gap: 8px; margin-bottom: 14px; } +.a-add .emoji-in { width: 60px; flex-shrink: 0; } +.a-add .grow { flex: 1; } +.a-add .btn { margin-top: 0; } + +/* ==== FOOTER ==== */ +.foot { border-top: 1px solid rgba(139, 92, 246, .2); background: #0a0614; } +.foot .container { padding: 40px 24px; } +.foot p { color: var(--muted); font-size: 14px; } +.foot .big { font-size: 28px; margin-bottom: 8px; } + +/* ==== REVEAL ==== */ +.reveal { opacity: 0; transform: translateY(20px); transition: opacity .6s ease, transform .6s ease; } +.reveal.in { opacity: 1; transform: none; } + +@media (max-width: 640px) { + .container { padding: 48px 20px; } + .hero { padding: 60px 0 90px; } + .cell { min-width: 70px; padding: 10px 6px; } + .cell b { font-size: 22px; } + .a-add { flex-wrap: wrap; } + .a-add .grow { flex-basis: 100%; } +}