v1: семейный таск-менеджер

This commit is contained in:
ashirova_gulnur 2026-06-26 05:49:02 +00:00
parent 36f4bcc89b
commit a94526f5c0
5 changed files with 1064 additions and 0 deletions

173
AGENTS.md Normal file
View File

@ -0,0 +1,173 @@
<!-- vibe42-agents-version: v3-guided-2026-06-01 -->
# Vibe42 — учебная песочница для лендингов
Workspace юзера `ashirova_gulnur`. Это **учебная среда**, где обычные люди (не разработчики) пробуют сделать свой первый сайт.
---
## 🎯 ТВОЯ РОЛЬ
Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с:
1. **рабочим лендингом**, опубликованным по адресу `https://pages.git.vibe42.kz/ashirova_gulnur/<repo>/`,
2. ощущением «это было легко» — без серверов, БД, токенов, конфигов.
Юзер не разработчик. Ему важен **результат, который видно в браузере**, а не код.
---
## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет)
1. Поздоровайся коротко: «Привет! Тут за 10 минут собираем лендинг и публикуем его в интернете. О чём хочешь сделать?»
2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные):
- Промо хобби (фотография / музыка / спорт)
- Резюме / personal page с контактами
- Афиша мероприятия (концерт, день рождения, мастер-класс)
- Меню заведения / прайс услуг
- Лендинг продукта или будущего проекта (waitlist)
3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы).
4. Сразу делай `./new-project <name>` и собирай страницу. Не спрашивай разрешения на каждый шаг.
---
## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО
Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**.
Скажи: «Давай определимся, я задам 3 коротких вопроса:
1. Это для тебя лично, для проекта/бизнеса, или для события?
2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио?
3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?»
После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать.
---
## 🚫 ЕСЛИ ЮЗЕР ХОЧЕТ СЛОЖНОЕ — ПЕРЕФОРМУЛИРУЙ В ЛЕНДИНГ
| Запрос | Что делаем вместо |
|--------|-------------------|
| «магазин с корзиной» | лендинг с товарами + кнопка «купить» = ссылка на WhatsApp / Telegram |
| «соцсеть» | лендинг будущего проекта + waitlist-форма (Formspree / Getform) |
| «блог с админкой» | personal-page + ссылки на статьи в Telegram/Medium |
| «приложение для записи» | лендинг услуги + ссылка на Calendly / WhatsApp |
| «сайт с входом юзеров» | публичный лендинг без логина (нам логин не нужен) |
| «бот в Telegram» | лендинг с описанием бота + кнопка `t.me/...` |
**Не говори «это невозможно».** Скажи: «У нас песочница только для статических сайтов. Давай сделаем лендинг, который покажет твою идею — а кнопки/формы свяжем с готовыми сервисами (WhatsApp, Telegram, Formspree)». Юзер счастлив, результат за 15 минут.
---
## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера)
### 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. **Сразу запушь:**
```bash
git add -A
git commit -m "v1"
git push origin HEAD:pages
```
2. **ОБЯЗАТЕЛЬНО** дай юзеру ссылку **жирно**:
> 🎉 Готово! Твой лендинг здесь: **https://pages.git.vibe42.kz/ashirova_gulnur/<repo>/**
3. Скажи: «Открой в новой вкладке, посмотри. Что хочешь поменять?»
4. Дальше короткие итерации: правка → push → новый URL-показ. Каждые 2-3 правки — push.
---
## ⚠️ ЖЕЛЕЗНЫЕ ПРАВИЛА (НЕ нарушать никогда)
1. **Только статика — HTML + CSS + JS в браузере.**
2. **Никакого бэкенда.** Никаких Node/Express/FastAPI/Django/PHP/Go-серверов. Никаких БД. Никакого Redis.
3. **Никакой аутентификации / OAuth / JWT.**
4. **Никакого Docker, nginx, sudo, системных настроек.**
5. **Никаких тяжёлых сборщиков** (`npm install` дерево на 500МБ). Tailwind — только через CDN.
6. **НИКОГДА `git init` в workspace root (`/workspaces/ashirova_gulnur`)** — это папка-контейнер юзера, не репозиторий.
---
## ✅ ВСЕГДА работай через `./new-project`
Если юзер сказал «сделай сайт NAME» / «создай проект NAME»:
```bash
cd /workspaces/ashirova_gulnur
./new-project NAME # создаёт repo в Gitea + клонит локально в ./NAME/
cd NAME
# теперь создавай index.html / style.css / script.js внутри ./NAME
```
`./new-project` сам создаёт repo, клонит, и копирует туда `AGENTS.md` + `design.md`.
---
## 🌐 Git и публикация
**НЕТ GitHub.** Self-hosted git: **https://git.vibe42.kz**
- Профиль юзера: https://git.vibe42.kz/ashirova_gulnur
- Pages (живые лендинги): https://pages.git.vibe42.kz/ashirova_gulnur/<repo>/
- Креды уже в `/workspaces/ashirova_gulnur/.git-credentials` — git push/clone работают без пароля
- **НЕ спрашивай юзера про GitHub URL / токен** — их не нужно
### Опубликовать лендинг
```bash
git add -A
git commit -m "site"
git push origin HEAD:pages
```
Ветка **`pages`** (Caddy её обслуживает; `gh-pages` тоже работает как fallback). Push → лендинг доступен мгновенно.
---
## 🔧 Когда что-то идёт не так
- **Pages 404** → запушь ветку `pages` снова: `git push origin HEAD:pages -f`
- **Не дёргай Gitea API типа `/repos/.../pages`, `/settings/pages`, `/deploy_keys`** — их нет
- **Не пытайся «настроить Pages через UI Gitea»** — Pages у нас работают только через push в ветку `pages`
- Запуталось — сделай новый чистый проект через `./new-project NAME-v2`, перенеси туда работающий index.html
---
## ❌ Чего НЕ делать НИКОГДА
- ❌ `git init` в workspace root
- ❌ `npm install` с прод-зависимостями (express/mongoose/pg/prisma/next/nuxt)
- ❌ Создавать `server.js` / `app.py` / `main.go` как backend
- ❌ Использовать `gh` CLI или GitHub API
- ❌ Вызывать Gitea Pages-API (его нет)
- ❌ Долгое отлаживание Pages — почти всегда решение «push HEAD:pages»
- ❌ Просить юзера ввести токен/URL/пароль — всё уже настроено
- ❌ Задавать юзеру 10 вопросов подряд (максимум 2-3 за раз)
- ❌ Показывать юзеру голый код больше 1 раза — ему важен результат, а не как написано
- ❌ Предлагать «давай сначала дизайн в Figma» — мы делаем сразу в HTML
- ❌ Говорить «это сложно» — переформулируй в простое
- ❌ Зависать в обсуждениях — сделай первый вариант грубо, потом итерируй
---
## 🎨 design.md
Рядом лежит `design.md` с готовой палитрой, типографикой и стартер-шаблоном `index.html`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие.

520
app.js Normal file
View File

@ -0,0 +1,520 @@
/* ===== Семейный таск-менеджер (чистая статика, localStorage) ===== */
const KEY = 'family_tasks_v1';
const SESSION = 'family_tasks_user';
const AVATAR_COLORS = ['#ec4899','#3b82f6','#10b981','#f59e0b','#8b5cf6','#ef4444','#06b6d4','#84cc16','#f97316','#6366f1'];
const AVATAR_EMOJIS = ['👩','👨','👧','👦','🧑','👵','👴','🧒','👶','🧔','👩‍🦰','👨‍🦱'];
let data = null; // {familyName, members:[], tasks:[]}
let me = null; // текущий пользователь (member id)
let view = 'mine';
let editingId = null; // id задачи в модалке (null = создание)
let postponeId = null; // id задачи для переноса
/* ---------- storage ---------- */
function load(){ try{ return JSON.parse(localStorage.getItem(KEY)); }catch(e){ return null; } }
function save(){ localStorage.setItem(KEY, JSON.stringify(data)); }
function uid(){ return 'id'+Date.now().toString(36)+Math.random().toString(36).slice(2,6); }
/* ---------- helpers ---------- */
const $ = s => document.querySelector(s);
const $$ = s => [...document.querySelectorAll(s)];
function member(id){ return (data.members||[]).find(m=>m.id===id) || null; }
function escapeHtml(s){ return (s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function todayStr(){ const d=new Date(); return fmt(d); }
function fmt(d){ return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); }
function parseDate(s){ if(!s) return null; const [y,m,d]=s.split('-').map(Number); return new Date(y,m-1,d); }
function prettyDate(s){ const d=parseDate(s); if(!d) return '—'; return d.toLocaleDateString('ru-RU',{day:'numeric',month:'short}); }
function prettyDateTime(s,t){ let r=prettyDate(s); if(t) r+=' · '+t; return r; }
function relativeDue(s){ if(!s) return ''; const d=parseDate(s); const now=new Date(); now.setHours(0,0,0,0); const diff=Math.round((d-now)/86400000); if(diff<0) return 'просрочено'; if(diff===0) return 'сегодня'; if(diff===1) return 'завтра'; if(diff<7) return `через ${diff} дн.`; return prettyDate(s); }
function initials(name){ return (name||'?').trim().slice(0,1).toUpperCase(); }
function colorFor(id){ const m=member(id); return m?m.color:'#94a3b8'; }
function avatarHtml(m, size){
if(!m) return `<span class="avatar" style="background:#94a3b8;width:${size}px;height:${size}px;font-size:${size*0.45}px">?</span>`;
const content = m.emoji || initials(m.name);
return `<span class="avatar" style="background:${m.color};width:${size}px;height:${size}px;font-size:${size*0.42}px" title="${escapeHtml(m.name)}">${content}</span>`;
}
function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.remove('hidden'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.add('hidden'),2200); }
const STATUS = {
todo: {label:'В работе', cls:'bg-amber-100 text-amber-700'},
done: {label:'Готово', cls:'bg-emerald-100 text-emerald-700'},
postponed: {label:'Перенесено',cls:'bg-sky-100 text-sky-700'},
cancelled: {label:'Отменено', cls:'bg-gray-200 text-gray-600'},
};
const PRIORITY = {
low: {label:'🟢 Низкая', cls:'bg-gray-100 text-gray-600'},
medium: {label:'🟡 Средняя', cls:'bg-amber-50 text-amber-600'},
high: {label:'🔴 Высокая', cls:'bg-red-50 text-red-600'},
};
/* ============================================================
ИНИЦИАЛИЗАЦИЯ
============================================================ */
function init(){
data = load();
if(!data || !data.members || !data.members.length){ showOnboard(); return; }
me = localStorage.getItem(SESSION);
if(!me || !member(me)) me = data.members[0].id;
enterApp();
}
/* ---------- онбординг ---------- */
function showOnboard(){
$('#onboard').classList.remove('hidden');
$('#app').classList.add('hidden');
const ob = $('#ob-members');
ob.innerHTML='';
addObMember(); addObMember();
}
function addObMember(){
const idx = $('#ob-members').children.length;
const color = AVATAR_COLORS[idx % AVATAR_COLORS.length];
const emoji = AVATAR_EMOJIS[idx % AVATAR_EMOJIS.length];
const row = document.createElement('div');
row.className='flex items-center gap-2';
row.innerHTML = `
<span class="avatar w-9 h-9" style="background:${color};font-size:16px">${emoji}</span>
<input class="field ob-name" placeholder="Имя (Мама, Папа, Аня…)" value="">
<button class="iconbtn ob-del" title="убрать">🗑</button>`;
row.querySelector('.ob-del').onclick=()=>{ if($('#ob-members').children.length>1) row.remove(); };
ob.appendChild(row);
}
$('#ob-add-member').onclick = addObMember;
$('#ob-create').onclick = ()=>{
const familyName = $('#ob-family').value.trim() || 'Наша семья';
const names = $$('#ob-members .ob-name').map(i=>i.value.trim()).filter(Boolean);
if(names.length<1){ toast('Добавьте хотя бы одного члена семьи'); return; }
const members = names.map((n,i)=>({ id:uid(), name:n, color:AVATAR_COLORS[i%AVATAR_COLORS.length], emoji:AVATAR_EMOJIS[i%AVATAR_EMOJIS.length] }));
data = { familyName, members, tasks:[] };
me = members[0].id;
localStorage.setItem(SESSION, me);
save();
$('#onboard').classList.add('hidden');
enterApp();
toast('Семья создана! 🎉');
};
/* ============================================================
ВХОД В ПРИЛОЖЕНИЕ
============================================================ */
function enterApp(){
$('#onboard').classList.add('hidden');
$('#app').classList.remove('hidden');
$('#hd-family').textContent = data.familyName;
renderUserMenu();
setCurrentUser(me);
setView('mine');
}
/* ---------- переключение пользователя («вход») ---------- */
function renderUserMenu(){
const list = $('#hd-user-list');
list.innerHTML = data.members.map(m=>`
<div class="menu-row ${m.id===me?'active':''}" data-uid="${m.id}">
${avatarHtml(m,32)}
<span class="font-semibold text-sm">${escapeHtml(m.name)}</span>
${m.id===me?'<span class="ml-auto text-xs text-muted">вы тут</span>':''}
</div>`).join('');
list.querySelectorAll('.menu-row').forEach(r=>r.onclick=()=>{
setCurrentUser(r.dataset.uid);
$('#hd-menu').classList.add('hidden');
setView(view);
});
}
function setCurrentUser(id){
me = id;
localStorage.setItem(SESSION, id);
const m = member(id);
$('#hd-avatar').outerHTML = `<span id="hd-avatar" class="avatar w-8 h-8 text-base">${m.emoji||initials(m.name)}</span>`;
$('#hd-avatar').style.background = m.color;
$('#hd-name').textContent = m.name;
$('#f-from').textContent = m.name;
}
/* выпадающее меню пользователя */
$('#hd-user').onclick = e=>{ e.stopPropagation(); $('#hd-menu').classList.toggle('hidden'); };
document.addEventListener('click', e=>{ if(!e.target.closest('#hd-user') && !e.target.closest('#hd-menu')) $('#hd-menu').classList.add('hidden'); });
$('#hd-manage').onclick = ()=>{ $('#hd-menu').classList.add('hidden'); openFamilyModal(); };
/* ---------- навигация ---------- */
$$('.navtab').forEach(b=>b.onclick=()=>setView(b.dataset.view));
function setView(v){
view = v;
$$('.navtab').forEach(b=>b.classList.toggle('active', b.dataset.view===v));
render();
}
/* ---------- кнопка новая задача ---------- */
$('#hd-new').onclick = $('#hd-new-m').onclick = ()=>openTaskModal(null);
/* ============================================================
ГЛАВНЫЙ РЕНДЕР
============================================================ */
function render(){
$('#hd-family').textContent = data.familyName;
if(view==='mine') return renderMine();
if(view==='assigned') return renderAssigned();
if(view==='all') return renderAll();
if(view==='schedule') return renderSchedule();
if(view==='family') return renderFamily();
}
function taskRow(t){
const a = member(t.assigneeId);
const from = member(t.assignerId);
const overdue = t.status==='todo' && t.dueDate && parseDate(t.dueDate) < new Date(todayStr());
const st = STATUS[t.status] || STATUS.todo;
return `
<div class="task ${t.status==='done'?'done':''} ${t.status==='cancelled'?'cancelled':''} ${overdue?'overdue':''}" data-id="${t.id}">
<div class="flex items-start gap-3">
<button class="iconbtn primary t-done" title="Отметить выполненной" ${t.status==='done'?'disabled':''}></button>
<div class="flex-1 min-w-0">
<div class="flex items-start gap-2 flex-wrap">
<div class="t-title font-semibold flex-1 min-w-0">${escapeHtml(t.title)}</div>
<span class="badge ${st.cls}">${st.label}</span>
</div>
${t.note?`<div class="text-sm text-muted mt-1">${escapeHtml(t.note)}</div>`:''}
<div class="flex items-center gap-2 flex-wrap mt-2 text-xs">
<span class="chip" style="background:${(a&&a.color)||'#94a3b8'}22;color:${(a&&a.color)||'#94a3b8'}">${avatarHtml(a,16)} ${escapeHtml(a?a.name:'—')}</span>
${from?`<span class="text-muted">от ${escapeHtml(from.name)}</span>`:''}
${t.dueDate?`<span class="badge ${overdue?'bg-red-100 text-red-700':'bg-line text-muted'}">⏰ ${prettyDateTime(t.dueDate,t.time)} · ${relativeDue(t.dueDate)}</span>`:''}
<span class="badge ${PRIORITY[t.priority].cls}">${PRIORITY[t.priority].label}</span>
</div>
</div>
</div>
<div class="flex gap-2 mt-3 pt-3 border-t border-line">
${t.status!=='done'?`<button class="iconbtn t-done" title="Выполнено">✓ Выполнить</button>`:`<button class="iconbtn t-reopen" title="Вернуть">↩ Вернуть</button>`}
${t.status!=='cancelled' && t.status!=='done'?`<button class="iconbtn t-postpone" title="Перенести">📅 Перенести</button>`:''}
${t.status!=='cancelled' && t.status!=='done'?`<button class="iconbtn t-cancel" title="Отменить">✕ Отменить</button>`:''}
<button class="iconbtn t-edit ml-auto" title="Изменить"></button>
</div>
</div>`;
}
function bindTaskRow(el){
const id = el.dataset.id;
const t = data.tasks.find(x=>x.id===id); if(!t) return;
const on = (sel, fn)=>el.querySelectorAll(sel).forEach(b=>{ if(!b.disabled) b.addEventListener('click', fn); });
on('.t-done', ()=>setStatus(id,'done'));
on('.t-reopen', ()=>setStatus(id,'todo'));
on('.t-postpone', ()=>openPostpone(id));
on('.t-cancel', ()=>setStatus(id,'cancelled'));
on('.t-edit', ()=>openTaskModal(id));
}
/* ---------- Мои задачи ---------- */
function renderMine(){
const me2 = member(me);
const tasks = data.tasks.filter(t=>t.assigneeId===me && (t.status==='todo'||t.status==='postponed'))
.sort((a,b)=> (a.dueDate||'9999').localeCompare(b.dueDate||'9999'));
const doneCount = data.tasks.filter(t=>t.assigneeId===me && t.status==='done').length;
const main = $('#main');
main.innerHTML = `
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-2xl font-extrabold">Мои задачи</h2>
<p class="text-muted text-sm">${avatarHtml(me2,20)} ${escapeHtml(me2.name)} · выполнено за всё время: ${doneCount}</p>
</div>
</div>
<div id="list" class="space-y-3"></div>`;
const list = $('#list');
if(!tasks.length){ list.innerHTML = emptyHtml('🎉','Активных задач нет. Можно отдохнуть!'); return; }
list.innerHTML = tasks.map(taskRow).join('');
list.querySelectorAll('.task').forEach(bindTaskRow);
}
/* ---------- Я поручил ---------- */
function renderAssigned(){
const tasks = data.tasks.filter(t=>t.assignerId===me)
.sort((a,b)=> (a.dueDate||'9999').localeCompare(b.dueDate||'9999'));
const active = tasks.filter(t=>t.status==='todo'||t.status==='postponed').length;
$('#main').innerHTML = `
<h2 class="text-2xl font-extrabold mb-1">Я поручил</h2>
<p class="text-muted text-sm mb-4">Задачи, которые вы поставили другим. Активных: ${active}</p>
<div id="list" class="space-y-3"></div>`;
const list = $('#list');
if(!tasks.length){ list.innerHTML = emptyHtml('📤','Вы ещё никому не поручали задач.','+ Новая задача','hd-new'); return; }
list.innerHTML = tasks.map(taskRow).join('');
list.querySelectorAll('.task').forEach(bindTaskRow);
}
/* ---------- Все задачи ---------- */
function renderAll(){
const statuses = ['todo','postponed','done','cancelled'];
$('#main').innerHTML = `
<h2 class="text-2xl font-extrabold mb-1">Все задачи семьи</h2>
<p class="text-muted text-sm mb-4">Всего: ${data.tasks.length}</p>
<div class="flex flex-wrap gap-2 mb-4" id="filters">
<select id="flt-member" class="field !w-auto !py-2"><option value="">Все члены</option>${data.members.map(m=>`<option value="${m.id}">${escapeHtml(m.name)}</option>`).join('')}</select>
<select id="flt-status" class="field !w-auto !py-2"><option value="">Любой статус</option>${statuses.map(s=>`<option value="${s}">${STATUS[s].label}</option>`).join('')}</select>
</div>
<div id="list" class="space-y-3"></div>`;
const apply = ()=>{
const fm = $('#flt-member').value, fs = $('#flt-status').value;
let tasks = data.tasks.slice().sort((a,b)=>(b.createdAt||0)-(a.createdAt||0));
if(fm) tasks = tasks.filter(t=>t.assigneeId===fm||t.assignerId===fm);
if(fs) tasks = tasks.filter(t=>t.status===fs);
const list = $('#list');
if(!tasks.length){ list.innerHTML = emptyHtml('🗂','Задач не найдено по фильтру.'); return; }
list.innerHTML = tasks.map(taskRow).join('');
list.querySelectorAll('.task').forEach(bindTaskRow);
};
$('#flt-member').onchange = apply; $('#flt-status').onchange = apply;
apply();
}
/* ---------- Расписание (календарь) ---------- */
let calRef = new Date();
function renderSchedule(){
const y = calRef.getFullYear(), m = calRef.getMonth();
const first = new Date(y,m,1);
const startDow = (first.getDay()+6)%7; // понедельник = 0
const daysInMonth = new Date(y,m+1,0).getDate();
const monthName = first.toLocaleDateString('ru-RU',{month:'long', year:'numeric'});
const tasksByDate = {};
data.tasks.filter(t=>t.dueDate && t.status!=='cancelled').forEach(t=>{
(tasksByDate[t.dueDate] = tasksByDate[t.dueDate]||[]).push(t);
});
let cells = '';
const dows = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
cells += dows.map(d=>`<div class="cal-dow">${d}</div>`).join('');
for(let i=0;i<startDow;i++) cells += `<div class="cal-day off"></div>`;
const today = todayStr();
for(let d=1; d<=daysInMonth; d++){
const ds = fmt(new Date(y,m,d));
const tlist = tasksByDate[ds]||[];
cells += `<div class="cal-day ${ds===today?'today':''}">
<div class="cal-dnum">${d}</div>
${tlist.slice(0,3).map(t=>`<div class="cal-task" style="background:${colorFor(t.assigneeId)}22;color:${colorFor(t.assigneeId)};${t.status==='done'?'text-decoration:line-through;opacity:.6':''}" data-id="${t.id}">${escapeHtml(t.title)}</div>`).join('')}
${tlist.length>3?`<div class="text-xs text-muted">+${tlist.length-3} ещё</div>`:''}
</div>`;
}
$('#main').innerHTML = `
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-extrabold capitalize">${monthName}</h2>
<div class="flex gap-2">
<button id="cal-prev" class="iconbtn"></button>
<button id="cal-today" class="btn-ghost text-sm">Сегодня</button>
<button id="cal-next" class="iconbtn"></button>
</div>
</div>
<div class="cal-grid">${cells}</div>
<div class="flex flex-wrap gap-3 mt-4 text-xs text-muted">
${data.members.map(m=>`<span class="flex items-center gap-1">${avatarHtml(m,14)} ${escapeHtml(m.name)}</span>`).join('')}
</div>`;
$('#cal-prev').onclick=()=>{ calRef=new Date(y,m-1,1); renderSchedule(); };
$('#cal-next').onclick=()=>{ calRef=new Date(y,m+1,1); renderSchedule(); };
$('#cal-today').onclick=()=>{ calRef=new Date(); renderSchedule(); };
$$('#main .cal-task').forEach(el=>el.onclick=()=>openTaskModal(el.dataset.id));
}
/* ---------- Семья ---------- */
function renderFamily(){
const cards = data.members.map(m=>{
const assigned = data.tasks.filter(t=>t.assigneeId===m.id && (t.status==='todo'||t.status==='postponed')).length;
const done = data.tasks.filter(t=>t.assigneeId===m.id && t.status==='done').length;
const gave = data.tasks.filter(t=>t.assignerId===m.id).length;
const overdue = data.tasks.filter(t=>t.assigneeId===m.id && t.status==='todo' && t.dueDate && parseDate(t.dueDate)<new Date(todayStr())).length;
return `
<div class="stat-card flex items-center gap-4">
${avatarHtml(m,52)}
<div class="flex-1 min-w-0">
<div class="font-bold text-lg">${escapeHtml(m.name)}</div>
<div class="text-xs text-muted">поручил другим: ${gave}</div>
</div>
<div class="grid grid-cols-3 gap-3 text-center">
<div><div class="text-xl font-extrabold text-amber-600">${assigned}</div><div class="text-[11px] text-muted">активных</div></div>
<div><div class="text-xl font-extrabold text-emerald-600">${done}</div><div class="text-[11px] text-muted">готово</div></div>
<div><div class="text-xl font-extrabold ${overdue?'text-red-600':'text-gray-300'}">${overdue}</div><div class="text-[11px] text-muted">просроч.</div></div>
</div>
</div>`;
}).join('');
$('#main').innerHTML = `
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-2xl font-extrabold">${escapeHtml(data.familyName)}</h2>
<p class="text-muted text-sm">Членов: ${data.members.length} · задач всего: ${data.tasks.length}</p>
</div>
<button id="fam-manage" class="btn-ghost text-sm"> Управление</button>
</div>
<div class="grid sm:grid-cols-2 gap-3">${cards}</div>`;
$('#fam-manage').onclick = openFamilyModal;
}
function emptyHtml(emoji, text, btnLabel, btnId){
return `<div class="empty"><div class="big">${emoji}</div><p>${text}</p>${btnLabel?`<button class="btn-primary mt-4" onclick="document.getElementById('${btnId}').click()">${btnLabel}</button>`:''}</div>`;
}
/* ============================================================
ДЕЙСТВИЯ С ЗАДАЧАМИ
============================================================ */
function setStatus(id, status){
const t = data.tasks.find(x=>x.id===id); if(!t) return;
const prev = t.status;
t.status = status;
t.updatedAt = Date.now();
if(status==='done') t.completedAt = Date.now();
pushHistory(t, status);
save(); render();
toast(STATUS[status].label+': '+t.title);
}
function pushHistory(t, action, detail){
t.history = t.history||[];
const labels = {done:'✅ Выполнено', todo:'↩ Возвращено в работу', postponed:'📅 Перенесено', cancelled:'✕ Отменено', created:' Создано', edited:'✎ Изменено', assigned:'🔁 Назначено'};
t.history.push({ at:Date.now(), byId:me, action, detail:detail||labels[action]||action });
}
function openPostpone(id){
postponeId = id;
const t = data.tasks.find(x=>x.id===id);
$('#pp-date').value = t.dueDate || todayStr();
$('#postpone-modal').classList.remove('hidden');
}
$('#pp-cancel').onclick = ()=>$('#postpone-modal').classList.add('hidden');
$('#pp-ok').onclick = ()=>{
const t = data.tasks.find(x=>x.id===postponeId); if(!t) return;
const newDate = $('#pp-date').value;
t.dueDate = newDate;
t.status = 'postponed';
t.updatedAt = Date.now();
pushHistory(t,'postponed','на '+prettyDate(newDate));
save();
$('#postpone-modal').classList.add('hidden');
render();
toast('Перенесено на '+prettyDate(newDate));
};
/* ============================================================
МОДАЛКА ЗАДАЧИ (создание/редактирование)
============================================================ */
function openTaskModal(id){
editingId = id;
const t = id ? data.tasks.find(x=>x.id===id) : null;
$('#modal-title').textContent = t ? 'Редактировать задачу' : 'Новая задача';
$('#f-title').value = t?.title||'';
$('#f-note').value = t?.note||'';
$('#f-date').value = t?.dueDate||'';
$('#f-time').value = t?.time||'';
$('#f-priority').value = t?.priority||'medium';
$('#f-from').textContent = member(me).name;
// список исполнителей
$('#f-assignee').innerHTML = data.members.map(m=>`<option value="${m.id}" ${t&&t.assigneeId===m.id?'selected':m.id===me&&!t?'selected':''}>${escapeHtml(m.name)}</option>`).join('');
// история
if(t && t.history && t.history.length){
$('#f-history').classList.remove('hidden');
$('#f-history-list').innerHTML = t.history.slice().reverse().map(h=>{
const by = member(h.byId);
return `<div>${new Date(h.at).toLocaleString('ru-RU',{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})} · ${by?escapeHtml(by.name):'?'}${escapeHtml(h.detail)}</div>`;
}).join('');
} else { $('#f-history').classList.add('hidden'); }
$('#modal-delete').classList.toggle('hidden', !t);
$('#modal').classList.remove('hidden');
setTimeout(()=>$('#f-title').focus(),50);
}
$('#modal-close').onclick = ()=>$('#modal').classList.add('hidden');
$('#modal').addEventListener('click', e=>{ if(e.target.id==='modal') $('#modal').classList.add('hidden'); });
$('#modal-save').onclick = ()=>{
const title = $('#f-title').value.trim();
if(!title){ toast('Введите название задачи'); $('#f-title').focus(); return; }
const assigneeId = $('#f-assignee').value;
const payload = {
title, note: $('#f-note').value.trim(),
assigneeId,
dueDate: $('#f-date').value || null,
time: $('#f-time').value || null,
priority: $('#f-priority').value,
};
if(editingId){
const t = data.tasks.find(x=>x.id===editingId);
const reassigned = t.assigneeId !== assigneeId;
Object.assign(t, payload, {updatedAt:Date.now()});
if(reassigned) pushHistory(t,'assigned','→ '+member(assigneeId).name);
pushHistory(t,'edited');
toast('Изменено');
} else {
const t = Object.assign({ id:uid(), assignerId:me, status:'todo', createdAt:Date.now(), updatedAt:Date.now(), history:[] }, payload);
pushHistory(t,'created');
data.tasks.push(t);
toast('Задача создана ✅');
}
save();
$('#modal').classList.add('hidden');
render();
};
$('#modal-delete').onclick = ()=>{
if(!editingId) return;
if(!confirm('Удалить задачу безвозвратно?')) return;
data.tasks = data.tasks.filter(t=>t.id!==editingId);
save();
$('#modal').classList.add('hidden');
render();
toast('Задача удалена');
};
/* ============================================================
УПРАВЛЕНИЕ СЕМЬЁЙ
============================================================ */
function openFamilyModal(){
$('#fm-name').value = data.familyName;
renderFmList();
$('#family-modal').classList.remove('hidden');
}
function renderFmList(){
$('#fm-list').innerHTML = data.members.map((m,i)=>`
<div class="flex items-center gap-2" data-mid="${m.id}">
<span class="avatar w-9 h-9 fm-emoji" style="background:${m.color};font-size:16px;cursor:pointer" title="сменить аватар">${m.emoji||initials(m.name)}</span>
<input class="field fm-name" value="${escapeHtml(m.name)}">
<button class="iconbtn fm-pick" title="цвет/эмодзи">🎨</button>
<button class="iconbtn fm-del" title="удалить">🗑</button>
</div>`).join('');
$('#fm-list').children.forEach(row=>{
const mid = row.dataset.mid;
row.querySelector('.fm-pick').onclick = ()=>cycleAvatar(row, mid);
row.querySelector('.fm-emoji').onclick = ()=>cycleAvatar(row, mid);
row.querySelector('.fm-del').onclick = ()=>{
const m = member(mid);
if(!confirm(`Удалить «${m.name}»? Его задачи останутся, но без исполнителя.`)) return;
data.members = data.members.filter(x=>x.id!==mid);
data.tasks.forEach(t=>{ if(t.assigneeId===mid) t.assigneeId=null; if(t.assignerId===mid) t.assignerId=null; });
if(me===mid) me = data.members[0]?.id;
renderFmList();
};
});
}
function cycleAvatar(row, mid){
const m = member(mid); if(!m) return;
const ci = AVATAR_COLORS.indexOf(m.color);
const ei = AVATAR_EMOJIS.indexOf(m.emoji);
m.color = AVATAR_COLORS[(ci+1)%AVATAR_COLORS.length];
m.emoji = AVATAR_EMOJIS[(ei+1)%AVATAR_EMOJIS.length];
const av = row.querySelector('.fm-emoji');
av.style.background = m.color; av.textContent = m.emoji||initials(m.name);
}
$('#fm-add').onclick = ()=>{
const i = data.members.length;
data.members.push({ id:uid(), name:'Новый член', color:AVATAR_COLORS[i%AVATAR_COLORS.length], emoji:AVATAR_EMOJIS[i%AVATAR_EMOJIS.length] });
renderFmList();
};
$('#fm-close').onclick = ()=>$('#family-modal').classList.add('hidden');
$('#family-modal').addEventListener('click', e=>{ if(e.target.id==='family-modal') $('#family-modal').classList.add('hidden'); });
$('#fm-save').onclick = ()=>{
data.familyName = $('#fm-name').value.trim() || data.familyName;
$$('#fm-list .fm-name').forEach((inp,i)=>{ if(data.members[i]) data.members[i].name = inp.value.trim() || data.members[i].name; });
if(!member(me)) me = data.members[0]?.id;
save();
$('#family-modal').classList.add('hidden');
renderUserMenu(); setCurrentUser(me); render();
toast('Семья обновлена');
};
/* ============================================================
ЗАПУСК
============================================================ */
init();

110
design.md Normal file
View File

@ -0,0 +1,110 @@
<!-- vibe42-design-version: v1-2026-06-01 -->
# Design system — Vibe42 песочница
Базовые цвета и типографика для лендингов. Можно отклоняться, но начинай с этого.
## Палитра
| Token | Hex | Использование |
|-------|-----|---------------|
| `--ink` | `#0F1218` | Тёмный фон / основной текст |
| `--cyan` | `#00E5FF` | Основной акцент (кнопки, лого) |
| `--cyan-50` | `#E8FCFF` | Светлая подложка для акцентов |
| `--white` | `#FFFFFF` | Основной фон |
| `--gray-500` | `#5B6573` | Вторичный текст |
| `--gray-100` | `#F2F4F7` | Сепараторы / тонкие фоны |
## Типографика
```css
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
```
| Уровень | Размер | Вес | line-height |
|---------|--------|-----|-------------|
| h1 (hero) | 56px | 800 | 1.05 |
| h2 (section) | 36px | 700 | 1.15 |
| h3 | 22px | 700 | 1.3 |
| body | 17px | 400 | 1.6 |
| small | 14px | 400 | 1.5 |
На мобиле — h1 уменьши до 36px, h2 до 28px.
## Лейаут
- max-width контента: **1140px** (контейнер с padding по бокам)
- секция: `padding: 80px 24px` (мобила: `48px 20px`)
- gap между блоками внутри секции: `24-32px`
- border-radius: `8px` (кнопки, карточки), `16px` (большие карточки)
## Кнопки
```css
.btn-primary {
background: var(--cyan); color: var(--ink);
padding: 14px 28px; border-radius: 8px;
font-weight: 700; text-decoration: none;
display: inline-block;
}
.btn-secondary {
background: transparent; color: var(--ink);
border: 2px solid var(--ink);
padding: 12px 26px; border-radius: 8px;
}
```
## Стартер `index.html`
```html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Мой проект</title>
<style>
: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(--white)}
.container{max-width:1140px;margin:0 auto;padding:80px 24px}
.hero{background:var(--ink);color:var(--white)}
.hero h1{font-size:56px;font-weight:800;line-height:1.05;margin-bottom:24px}
.hero p{font-size:20px;color:#9aa3b2;max-width:600px;margin-bottom:32px}
.btn{display:inline-block;background:var(--cyan);color:var(--ink);padding:14px 28px;border-radius:8px;font-weight:700;text-decoration:none}
.btn:hover{background:#1be5ff}
.section h2{font-size:36px;font-weight:700;margin-bottom:24px}
.card{background:var(--gray-100);border-radius:16px;padding:32px;margin-bottom:16px}
@media (max-width:640px){.hero h1{font-size:36px}.section h2{font-size:28px}.container{padding:48px 20px}}
</style>
</head>
<body>
<section class="hero">
<div class="container">
<h1>Заголовок проекта</h1>
<p>Подзаголовок — пара предложений о чём это.</p>
<a class="btn" href="#section">Начать</a>
</div>
</section>
<section id="section" class="section">
<div class="container">
<h2>Секция</h2>
<div class="card">Контент карточки.</div>
<div class="card">Контент карточки.</div>
</div>
</section>
</body>
</html>
```
## Чем НЕ пользоваться
- Bootstrap, Material UI, Chakra, Ant Design — слишком тяжело и не нужно для лендинга
- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG
- jQuery — vanilla JS более чем достаточно
## Чем МОЖНО (если очень надо)
- **Tailwind через CDN**: `<script src="https://cdn.tailwindcss.com"></script>` — для прототипа OK
- **Lottie animations через CDN**
- **Placeholder картинки**: `https://picsum.photos/800/600`, `https://placehold.co/600x400`
- **Шрифты Google Fonts через `<link>`** в head

204
index.html Normal file
View File

@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Семейные задачи</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
ink: '#0F1218',
accent: '#00E5FF',
'accent-50': '#E8FCFF',
muted: '#5B6573',
line: '#F2F4F7',
},
fontFamily: {
sans: ['-apple-system','BlinkMacSystemFont','Segoe UI','Inter','system-ui','sans-serif'],
}
}
}
}
</script>
<link rel="stylesheet" href="style.css">
</head>
<body class="bg-line text-ink font-sans min-h-screen">
<!-- Экран онбординга (первый запуск) -->
<div id="onboard" class="hidden min-h-screen flex items-center justify-center p-5">
<div class="w-full max-w-lg bg-white rounded-2xl shadow-xl p-7">
<div class="text-center mb-6">
<div class="text-5xl mb-3">👨‍👩‍👧‍👦</div>
<h1 class="text-2xl font-extrabold">Создайте семейный аккаунт</h1>
<p class="text-muted mt-2 text-sm">Добавьте членов семьи — каждый сможет заходить под своим именем и ставить задачи друг другу.</p>
</div>
<label class="block text-sm font-semibold mb-1">Название семьи</label>
<input id="ob-family" class="field mb-5" placeholder="Напр. Семья Ивановых">
<label class="block text-sm font-semibold mb-1">Члены семьи</label>
<div id="ob-members" class="space-y-2 mb-3"></div>
<button id="ob-add-member" class="text-sm font-semibold text-ink underline">+ добавить члена семьи</button>
<div class="bg-accent-50 rounded-xl p-3 text-xs text-muted mt-5">
Данные хранятся в этом браузере. Лучше всего пользоваться с одного общего семейного устройства — тогда все увидят одни и те же задачи.
</div>
<button id="ob-create" class="btn-primary w-full mt-5">Создать и войти</button>
</div>
</div>
<!-- Основное приложение -->
<div id="app" class="hidden min-h-screen flex flex-col">
<!-- Шапка -->
<header class="bg-ink text-white sticky top-0 z-20">
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center gap-3">
<div class="text-2xl">📋</div>
<div class="flex-1 min-w-0">
<div id="hd-family" class="font-bold leading-tight truncate">Семья</div>
<div class="text-xs text-white/50 leading-tight">Семейные задачи и расписание</div>
</div>
<button id="hd-new" class="btn-primary text-sm hidden sm:inline-flex">+ Новая задача</button>
<button id="hd-new-m" class="btn-primary text-sm sm:hidden !px-3">+</button>
<!-- Кто я (вход) -->
<div class="relative">
<button id="hd-user" class="flex items-center gap-2 bg-white/10 hover:bg-white/20 rounded-full pl-1 pr-3 py-1 transition">
<span id="hd-avatar" class="avatar w-8 h-8 text-base">?</span>
<span id="hd-name" class="text-sm font-semibold max-w-[90px] truncate"></span>
<svg width="12" height="12" viewBox="0 0 24 24" class="opacity-60"><path fill="currentColor" d="M7 10l5 5 5-5z"/></svg>
</button>
<div id="hd-menu" class="hidden absolute right-0 mt-2 w-60 bg-white text-ink rounded-xl shadow-2xl p-2 z-30">
<div class="px-3 py-2 text-xs text-muted font-semibold">Войти как…</div>
<div id="hd-user-list" class="max-h-72 overflow-auto"></div>
<div class="border-t border-line mt-1 pt-1">
<button id="hd-manage" class="w-full text-left px-3 py-2 rounded-lg hover:bg-line text-sm font-semibold">⚙️ Управление семьёй</button>
</div>
</div>
</div>
</div>
<!-- Навигация -->
<nav class="max-w-6xl mx-auto px-2 flex gap-1 overflow-x-auto">
<button data-view="mine" class="navtab">✅ Мои задачи</button>
<button data-view="assigned" class="navtab">📤 Я поручил</button>
<button data-view="all" class="navtab">🗂 Все задачи</button>
<button data-view="schedule" class="navtab">📅 Расписание</button>
<button data-view="family" class="navtab">👨‍👩‍👧 Семья</button>
</nav>
</header>
<!-- Контент -->
<main id="main" class="flex-1 max-w-6xl w-full mx-auto px-4 py-6"></main>
<footer class="text-center text-xs text-muted py-5">
Данные хранятся локально в этом браузере · семейный таск-менеджер
</footer>
</div>
<!-- Модалка задачи -->
<div id="modal" class="hidden fixed inset-0 z-40 bg-black/50 flex items-end sm:items-center justify-center p-0 sm:p-4">
<div class="bg-white w-full sm:max-w-lg sm:rounded-2xl rounded-t-2xl shadow-2xl max-h-[92vh] overflow-auto">
<div class="sticky top-0 bg-white border-b border-line px-5 py-3 flex items-center justify-between">
<h3 id="modal-title" class="font-bold text-lg">Новая задача</h3>
<button id="modal-close" class="text-2xl leading-none text-muted hover:text-ink px-2">×</button>
</div>
<div class="p-5 space-y-4">
<div>
<label class="block text-sm font-semibold mb-1">Что нужно сделать *</label>
<input id="f-title" class="field" placeholder="Напр. Купить молоко">
</div>
<div>
<label class="block text-sm font-semibold mb-1">Подробности</label>
<textarea id="f-note" class="field" rows="2" placeholder="Комментарий (необязательно)"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-semibold mb-1">Кому *</label>
<select id="f-assignee" class="field"></select>
</div>
<div>
<label class="block text-sm font-semibold mb-1">Срок</label>
<input id="f-date" type="date" class="field">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-semibold mb-1">Время</label>
<input id="f-time" type="time" class="field">
</div>
<div>
<label class="block text-sm font-semibold mb-1">Важность</label>
<select id="f-priority" class="field">
<option value="low">🟢 Низкая</option>
<option value="medium" selected>🟡 Средняя</option>
<option value="high">🔴 Высокая</option>
</select>
</div>
</div>
<div class="text-xs text-muted bg-line rounded-lg p-3">
От кого: <span id="f-from" class="font-semibold text-ink"></span>
</div>
<div id="f-history" class="hidden text-xs text-muted border-t border-line pt-3">
<div class="font-semibold mb-1">История:</div>
<div id="f-history-list" class="space-y-1"></div>
</div>
</div>
<div class="sticky bottom-0 bg-white border-t border-line px-5 py-3 flex gap-2">
<button id="modal-delete" class="hidden btn-danger">Удалить</button>
<button id="modal-save" class="btn-primary flex-1">Сохранить</button>
</div>
</div>
</div>
<!-- Модалка "перенести на дату" -->
<div id="postpone-modal" class="hidden fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl p-5 w-full max-w-xs">
<h3 class="font-bold text-lg mb-3">Перенести на</h3>
<input id="pp-date" type="date" class="field mb-4">
<div class="flex gap-2">
<button id="pp-cancel" class="btn-ghost flex-1">Отмена</button>
<button id="pp-ok" class="btn-primary flex-1">Перенести</button>
</div>
</div>
</div>
<!-- Модалка управления семьёй -->
<div id="family-modal" class="hidden fixed inset-0 z-40 bg-black/50 flex items-end sm:items-center justify-center p-0 sm:p-4">
<div class="bg-white w-full sm:max-w-lg sm:rounded-2xl rounded-t-2xl shadow-2xl max-h-[92vh] overflow-auto">
<div class="sticky top-0 bg-white border-b border-line px-5 py-3 flex items-center justify-between">
<h3 class="font-bold text-lg">⚙️ Управление семьёй</h3>
<button id="fm-close" class="text-2xl leading-none text-muted hover:text-ink px-2">×</button>
</div>
<div class="p-5 space-y-4">
<div>
<label class="block text-sm font-semibold mb-1">Название семьи</label>
<input id="fm-name" class="field">
</div>
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-sm font-semibold">Члены семьи</label>
<button id="fm-add" class="text-sm font-semibold text-ink underline">+ добавить</button>
</div>
<div id="fm-list" class="space-y-2"></div>
</div>
<div class="bg-line rounded-lg p-3 text-xs text-muted">
Если удалить члена семьи, его задачи станут «без исполнителя». Имя при входе можно сменить в любой момент.
</div>
</div>
<div class="sticky bottom-0 bg-white border-t border-line px-5 py-3">
<button id="fm-save" class="btn-primary w-full">Сохранить</button>
</div>
</div>
</div>
<!-- Тост -->
<div id="toast" class="hidden fixed bottom-5 left-1/2 -translate-x-1/2 z-50 bg-ink text-white px-5 py-3 rounded-full shadow-2xl text-sm font-semibold"></div>
<script src="app.js"></script>
</body>
</html>

57
style.css Normal file
View File

@ -0,0 +1,57 @@
:root{--ink:#0F1218;--accent:#00E5FF;--accent-50:#E8FCFF;--muted:#5B6573;--line:#F2F4F7}
*{box-sizing:border-box}
.field{width:100%;border:2px solid var(--line);border-radius:8px;padding:10px 12px;font-size:15px;font-family:inherit;background:#fff;color:var(--ink);outline:none;transition:border .15s}
.field:focus{border-color:var(--accent)}
textarea.field{resize:vertical}
.btn-primary{background:var(--accent);color:var(--ink);padding:11px 20px;border-radius:8px;font-weight:700;transition:filter .15s}
.btn-primary:hover{filter:brightness(1.05)}
.btn-primary:active{filter:brightness(.95)}
.btn-ghost{background:transparent;color:var(--ink);border:2px solid var(--line);padding:9px 18px;border-radius:8px;font-weight:600}
.btn-ghost:hover{background:var(--line)}
.btn-danger{background:#fee2e2;color:#b91c1c;padding:11px 18px;border-radius:8px;font-weight:700}
.btn-danger:hover{background:#fecaca}
.navtab{white-space:nowrap;padding:10px 14px;font-size:14px;font-weight:600;color:#ffffff80;border-bottom:3px solid transparent;transition:.15s}
.navtab:hover{color:#fff}
.navtab.active{color:var(--accent);border-bottom-color:var(--accent)}
.avatar{display:inline-flex;align-items:center;justify-content:center;border-radius:999px;font-weight:800;color:#fff;user-select:none;flex-shrink:0}
/* карточка задачи */
.task{background:#fff;border:1px solid var(--line);border-radius:14px;padding:14px 16px;transition:box-shadow .15s,border-color .15s}
.task:hover{box-shadow:0 6px 20px rgba(15,18,24,.07)}
.task.done{opacity:.6}
.task.done .t-title{text-decoration:line-through}
.task.cancelled{opacity:.5}
.task.cancelled .t-title{text-decoration:line-through}
.task.overdue:not(.done):not(.cancelled){border-color:#fca5a5;box-shadow:0 0 0 1px #fca5a5 inset}
.chip{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;padding:2px 8px;border-radius:999px}
.badge{font-size:11px;font-weight:700;padding:2px 8px;border-radius:6px;text-transform:uppercase;letter-spacing:.02em}
.iconbtn{width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:1px solid var(--line);background:#fff;font-size:16px;transition:.15s}
.iconbtn:hover{background:var(--line)}
.iconbtn.primary{background:var(--accent);border-color:var(--accent)}
/* календарь */
.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:6px}
.cal-dow{text-align:center;font-size:12px;font-weight:700;color:var(--muted);padding:6px 0}
.cal-day{min-height:96px;background:#fff;border:1px solid var(--line);border-radius:10px;padding:6px;overflow:hidden}
.cal-day.off{background:#fafbfc;color:#c2c8d2}
.cal-day.today{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-50) inset}
.cal-dnum{font-size:12px;font-weight:700;margin-bottom:4px}
.cal-task{font-size:11px;font-weight:600;border-radius:5px;padding:2px 5px;margin-bottom:3px;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.empty{text-align:center;padding:48px 20px;color:var(--muted)}
.empty .big{font-size:48px;margin-bottom:8px}
.stat-card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:16px}
.menu-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:10px;cursor:pointer;transition:.12s}
.menu-row:hover{background:var(--line)}
.menu-row.active{background:var(--accent-50)}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:#d6dbe2;border-radius:10px}