Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
673a4ccb15 | ||
|
|
a6a639d75d | ||
|
|
c9ef8a852b | ||
|
|
1d2281a0ec | ||
|
|
9b48e8ca22 | ||
|
|
a94526f5c0 |
173
AGENTS.md
Normal file
173
AGENTS.md
Normal 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`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие.
|
||||
357
PRD.md
Normal file
357
PRD.md
Normal file
@ -0,0 +1,357 @@
|
||||
# PRD — Семейный таск-менеджер «Family Tasks»
|
||||
|
||||
**Версия:** 1.2
|
||||
**Дата:** 2026-06-26
|
||||
**Статус:** реализовано (MVP), итеративно дорабатывается
|
||||
**Платформа:** статический сайт (HTML + CSS + ванильный JS), без бэкенда
|
||||
|
||||
---
|
||||
|
||||
## 1. Краткое описание
|
||||
|
||||
Веб-приложение для совместного управления задачами и расписанием внутри семьи. Каждый член семьи заходит под своим именем, видит порученные ему задачи, может ставить задачи другим, планировать личное расписание дня (школа, работа, тренировки) и отслеживать выполнение.
|
||||
|
||||
## 2. Проблема и мотивация
|
||||
|
||||
В семье задачи и договорённости часто теряются: кто за чем должен сходить, кто забрал ребёнка, когда бассейн или школа. Нужен простой общий «список дел» + календарь, где видно занятость каждого. Существующие решения либо перегружены, либо требуют регистрации и серверов. Цель — лёгкий семейный инструмент, который открывается по ссылке и сразу работает.
|
||||
|
||||
## 3. Целевая аудитория
|
||||
|
||||
- Семьи (родители + дети), пользующиеся одним общим устройством (домашний ПК / планшет).
|
||||
- Любой человек, кому важен результат, а не технические детали.
|
||||
|
||||
**Ключевое ограничение среды:** данные хранятся локально в браузере (localStorage). Приложение оптимально для одного общего семейного устройства, где члены семьи по очереди выбирают своё имя.
|
||||
|
||||
## 4. Цели продукта
|
||||
|
||||
1. Единый семейный аккаунт с перечнем членов семьи.
|
||||
2. Вход «кто я сейчас» — выбор текущего пользователя без паролей.
|
||||
3. Постановка задач друг другу с указанием исполнителя, срока, времени, длительности, важности и вида.
|
||||
4. Жизненный цикл задачи: выполнить / перенести / отменить / вернуть в работу.
|
||||
5. Личное расписание: свои события-блоки по времени на календаре.
|
||||
6. Общий обзор: «Мои задачи», «Я поручил», «Все задачи», «Расписание», «Семья».
|
||||
|
||||
## 5. Платформа и ограничения (железные)
|
||||
|
||||
- **Только статика:** HTML + CSS + JS в браузере. Никакого серверного кода.
|
||||
- **Нет бэкенда, БД, аутентификации, Docker, сборщиков.**
|
||||
- Хранение: `localStorage` (пер-браузер). Синхронизация между устройствами **не поддерживается** — это сознательное ограничение песочницы.
|
||||
- «Вход» — это выбор имени из списка членов семьи (локальная идентификация), **не** настоящая аутентификация.
|
||||
- Публикация: push в ветку `pages` Git-репозитория → сайт доступен мгновенно.
|
||||
|
||||
## 6. Сущности и роли
|
||||
|
||||
- **Семья (family):** название + список членов + задачи + события.
|
||||
- **Член семьи (member):** id, имя, emoji-аватар, цвет.
|
||||
- **Текущий пользователь (me):** выбранный член семьи (хранится в sessionStorage-подобном ключе).
|
||||
- **Задача (task):** см. модель данных.
|
||||
- **Событие расписания (event):** личный временной блок.
|
||||
|
||||
## 7. Функциональные требования
|
||||
|
||||
### 7.1. Онбординг (первый запуск)
|
||||
- Если данных нет → экран создания семьи: название + добавление членов (имя, аватар-emoji, цвет). Минимум 1 член.
|
||||
- Можно добавлять/удалять строки членов до создания.
|
||||
- После создания — вход под первым членом.
|
||||
|
||||
### 7.2. Вход / смена пользователя
|
||||
- В шапке аватар + имя текущего пользователя; клик открывает список членов семьи.
|
||||
- Выбор = «вход» под этим именем; запоминается в браузере.
|
||||
- Доступен быстрый переход в управление семьёй.
|
||||
|
||||
### 7.3. Управление семьёй
|
||||
- Переименование семьи.
|
||||
- Добавление / переименование / удаление членов.
|
||||
- Смена аватара (emoji + цвет) по клику.
|
||||
- При удалении члена его задачи остаются «без исполнителя».
|
||||
|
||||
### 7.4. Задачи — поля
|
||||
| Поле | Описание | Обязательное |
|
||||
|------|----------|--------------|
|
||||
| title | что сделать | да |
|
||||
| note | подробности | нет |
|
||||
| assigneeId | кому поручено | да |
|
||||
| assignerId | от кого (автоматически = текущий пользователь) | авто |
|
||||
| dueDate | срок (дата) | нет |
|
||||
| time | время срока | нет |
|
||||
| duration | длительность (15/30/45/60/90/120/180/240/360 мин) | нет |
|
||||
| category | вид задачи (см. 7.5) | да (по умолч. «Другое») |
|
||||
| priority | низкая / средняя / высокая | да |
|
||||
| status | todo / done / postponed / cancelled | авто |
|
||||
| history | журнал изменений | авто |
|
||||
|
||||
### 7.5. Виды задач (категории)
|
||||
8 предустановленных категорий с цветом:
|
||||
🏠 Дом · 💼 Работа · 📚 Учёба · 🛒 Покупки · 👶 Дети · 💰 Финансы · ❤️ Здоровье · 🎉 Другое.
|
||||
Категория отображается цветным бейджем на карточке и доступна как фильтр в «Все задачи».
|
||||
|
||||
### 7.6. Действия с задачей
|
||||
- **Выполнить** (✓) → статус done.
|
||||
- **Вернуть в работу** (↩) → статус todo.
|
||||
- **Перенести** (📅) → выбор новой даты, статус postponed.
|
||||
- **Отменить** (✕) → статус cancelled.
|
||||
- **Редактировать** (✎) → модалка со всеми полями.
|
||||
- **Удалить** (в модалке) — безвозвратно.
|
||||
- Каждое действие фиксируется в истории (кто, когда, что).
|
||||
|
||||
### 7.7. Представления (вкладки)
|
||||
1. **Мои задачи** — активные (todo/postponed), порученные текущему пользователю; сортировка по сроку; счётчик «выполнено за всё время».
|
||||
2. **Я поручил** — задачи, поставленные текущим пользователем другим.
|
||||
3. **Все задачи** — общий список с фильтрами: по члену, по статусу, по виду.
|
||||
4. **Расписание** — календарь месяца + личные события (см. 7.8).
|
||||
5. **Семья** — карточки членов со статистикой: активных / готово / просрочено / поручено другим.
|
||||
|
||||
### 7.8. Расписание и личные события
|
||||
- Календарь месяца с ячейками по дням; в ячейках — цветные чипы событий и задач.
|
||||
- Переключатель **Мои / Вся семья** (фильтрует события и задачи по принадлежности).
|
||||
- Кнопка **+ Событие** — создание личного временного блока.
|
||||
- **Событие:** название, дата, время начала, время конца, чьё расписание (владелец), вид/цвет.
|
||||
- Валидация: конец позже начала; дата и время обязательны.
|
||||
- Клик по дню → панель дня: таймлайн событий и задач этого дня, отсортированных по времени; кнопка «+ событие в этот день».
|
||||
- Клик по чипу/строке → редактирование (события или задачи).
|
||||
- Легенда категорий под календарём.
|
||||
|
||||
### 7.9. Навигация и UX
|
||||
- Залипающая шапка с навигацией-вкладками, адаптивная (мобильный/десктоп).
|
||||
- Кнопка «+ Новая задача» всегда доступна.
|
||||
- Тосты-уведомления о действиях.
|
||||
- Пустые состояния с подсказками.
|
||||
- Подсветка просроченных задач.
|
||||
|
||||
## 8. Модель данных (localStorage, ключ `family_tasks_v1`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"familyName": "Семья Ивановых",
|
||||
"members": [{ "id": "m1", "name": "Мама", "color": "#ec4899", "emoji": "👩" }],
|
||||
"tasks": [{
|
||||
"id": "t1", "title": "Купить молоко", "note": "",
|
||||
"assigneeId": "m1", "assignerId": "m2",
|
||||
"dueDate": "2026-06-27", "time": "18:00",
|
||||
"duration": 30, "category": "shop", "priority": "medium",
|
||||
"status": "todo", "createdAt": 0, "updatedAt": 0, "completedAt": null,
|
||||
"history": [{ "at": 0, "byId": "m2", "action": "created", "detail": "➕ Создано" }]
|
||||
}],
|
||||
"events": [{
|
||||
"id": "e1", "title": "Школа", "date": "2026-06-28",
|
||||
"start": "08:00", "end": "12:00", "ownerId": "m3", "category": "study"
|
||||
}]
|
||||
}
|
||||
```
|
||||
Текущий пользователь хранится отдельно в ключе `family_tasks_user`.
|
||||
|
||||
## 9. Нефункциональные требования
|
||||
|
||||
- **Производительность:** мгновенный отклик, рендер по изменению состояния.
|
||||
- **Надёжность:** отсутствие внешних runtime-зависимостей (стили — локальный CSS, без CDN); обработчик ошибок показывает красную плашку вместо белого экрана.
|
||||
- **Адаптивность:** корректная работа на телефоне и десктопе.
|
||||
- **Локализация:** интерфейс на русском; даты/время в формате ru-RU.
|
||||
- **Приватность:** данные не покидают браузер пользователя.
|
||||
|
||||
## 10. Не входят в скоуп (non-goals)
|
||||
|
||||
- Синхронизация между устройствами / облако.
|
||||
- Настоящая аутентификация, пароли, роли с правами.
|
||||
- Уведомления (push/e-mail), интеграции с календарями (Google/Apple).
|
||||
- Повторяющиеся задачи, подзадачи, вложения, комментарии.
|
||||
- Мультисемейность (несколько семей в одном браузере).
|
||||
|
||||
## 11. Будущие улучшения (бэклог)
|
||||
|
||||
- Экспорт/импорт данных семьи (JSON-файл) для бэкапа и переноса.
|
||||
- Повторы задач (ежедневно/еженедельно).
|
||||
- Напоминания о просрочке на основе времени.
|
||||
- Тёмная тема.
|
||||
- Статистика и графики продуктивности по членам/категориям.
|
||||
- Поиск по всем задачам.
|
||||
- Перетаскивание задач между днями в календаре.
|
||||
|
||||
## 12. Статус реализации
|
||||
|
||||
| Блок | Статус |
|
||||
|------|--------|
|
||||
| Онбординг, создание семьи | ✅ |
|
||||
| Вход сменой пользователя | ✅ |
|
||||
| Управление семьёй | ✅ |
|
||||
| Задачи: CRUD, статусы, действия | ✅ |
|
||||
| Категории (виды) | ✅ |
|
||||
| Длительность задачи | ✅ |
|
||||
| Вкладки: Мои / Я поручил / Все / Семья | ✅ |
|
||||
| Расписание: календарь + личные события | ✅ |
|
||||
| Безлимит задач | ✅ |
|
||||
| Синхронизация между устройствами | ❌ (не планируется в песочнице) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Пользовательские сценарии (use cases)
|
||||
|
||||
### UC-1. Первый запуск и создание семьи
|
||||
**Актор:** новый пользователь
|
||||
**Предусловие:** данных в браузере нет.
|
||||
1. Открывает ссылку → экран «Создайте семейный аккаунт».
|
||||
2. Вводит название семьи (напр. «Семья Ивановых»).
|
||||
3. Добавляет членов: вводит имена «Мама», «Папа», «Аня» (+ кнопкой добавляет строки).
|
||||
4. Нажимает «Создать и войти».
|
||||
**Результат:** семья создана, вход выполнен под первым членом, открыт раздел «Мои задачи».
|
||||
|
||||
### UC-2. Постановка задачи другому члену
|
||||
**Актор:** текущий пользователь (Мама)
|
||||
1. Нажимает «+ Новая задача».
|
||||
2. Заполняет: «Отвезти Аню на танцы», кому = Папа, срок = 2026-06-27, время = 17:00, вид = 👶 Дети, важность = высокая, длительность = 1 ч.
|
||||
3. Нажимает «Сохранить».
|
||||
**Результат:** задача создана, видна Папе в «Мои задачи» и у Мамы в «Я поручил». В истории — «Создано» от Мамы.
|
||||
|
||||
### UC-3. Выполнение / перенос / отмена
|
||||
**Актор:** Папа (исполнитель)
|
||||
1. В «Мои задачи» видит «Отвезти Аню на танцы».
|
||||
- Вариант A: нажимает «✓ Выполнить» → статус «Готово», задача уходит из активных.
|
||||
- Вариант B: нажимает «📅 Перенести» → выбирает новую дату → статус «Перенесено».
|
||||
- Вариант C: нажимает «✕ Отменить» → статус «Отменено».
|
||||
**Результат:** статус обновлён, действие записано в историю, Мама видит изменение в «Все задачи».
|
||||
|
||||
### UC-4. Планирование личного дня
|
||||
**Актор:** Аня (или родитель за неё)
|
||||
1. Открывает «📅 Расписание», переключатель «Мои».
|
||||
2. Нажимает «+ Событие».
|
||||
3. Заполняет: «Школа», дата = 2026-06-28, 08:00–12:00, вид = 📚 Учёба.
|
||||
4. Сохраняет. Добавляет ещё «Бассейн» 14:00–15:00 (❤️ Здоровье).
|
||||
**Результат:** на календаре в этот день — два цветных блока; в панели дня — таймлайн 08:00 Школа, 14:00 Бассейн.
|
||||
|
||||
### UC-5. Просмотр занятости всей семьи
|
||||
**Актор:** любой член
|
||||
1. Открывает «Расписание», переключатель «Вся семья».
|
||||
2. Видит события и задачи всех членов на календаре, цветом по владельцу/категории.
|
||||
3. Кликает по дню → панель дня показывает общий таймлайн семьи.
|
||||
**Результат:** видно, кто когда занят, чтобы ставить задачи без конфликтов.
|
||||
|
||||
### UC-6. Смена пользователя (вход под другим именем)
|
||||
**Актор:** любой член за общим устройством
|
||||
1. Кликает по аватару в шапке.
|
||||
2. Выбирает другого члена из списка.
|
||||
**Результат:** интерфейс переключается на контекст этого пользователя (его «Мои задачи», его расписание по умолчанию).
|
||||
|
||||
### UC-7. Фильтрация и поиск задач
|
||||
**Актор:** любой член
|
||||
1. Открывает «Все задачи».
|
||||
2. Выбирает фильтры: член = Папа, статус = В работе, вид = 💼 Работа.
|
||||
**Результат:** список отфильтрован; можно открыть любую задачу на редактирование.
|
||||
|
||||
### UC-8. Управление составом семьи
|
||||
**Актор:** любой член
|
||||
1. Шапка → «⚙️ Управление семьёй».
|
||||
2. Добавляет нового члена «Бабушка», переименовывает, меняет emoji/цвет, сохраняет.
|
||||
**Результат:** новый член доступен для входа и назначения задач.
|
||||
|
||||
## 14. Макеты экранов (wireframes)
|
||||
|
||||
### 14.1. Экран онбординга
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 👨👩👧👦 │
|
||||
│ Создайте семейный аккаунт │
|
||||
│ Добавьте членов семьи — каждый сможет… │
|
||||
│ │
|
||||
│ Название семьи │
|
||||
│ [ Семья Ивановых ] │
|
||||
│ │
|
||||
│ Члены семьи │
|
||||
│ 👩 [ Мама ] [🗑] │
|
||||
│ 👨 [ Папа ] [🗑] │
|
||||
│ + добавить члена семьи │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ Данные хранятся в этом браузере… │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ [ Создать и войти ] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 14.2. Главный экран (шапка + вкладки + список)
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ 📋 Семья Ивановых [+ Новая] (👩 Мама ▾) │
|
||||
│ Семейные задачи и расписание │
|
||||
│ [✅ Мои] [📤 Я поручил] [🗂 Все] [📅 Распис.] [👨👩 Семья] │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ Мои задачи │
|
||||
│ 👩 Мама · выполнено за всё время: 3 │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ ✓ [🛒 Покупки] Купить молоко [В работе] │ │
|
||||
│ │ 👩 Мама от Папа ⏰ 27 июн · сегодня ⏱ 30 мин │ │
|
||||
│ │ 🟡 Средняя │ │
|
||||
│ │ ────────────────────────────────────────────────── │ │
|
||||
│ │ [✓ Выполнить] [📅 Перенести] [✕ Отменить] [✎] │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 14.3. Модалка задачи
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Новая задача × │
|
||||
├──────────────────────────────────────────┤
|
||||
│ Что нужно сделать * │
|
||||
│ [ Купить молоко ] │
|
||||
│ Подробности │
|
||||
│ [ ] │
|
||||
│ Кому * │ Срок │
|
||||
│ [Мама ▾] │ [2026-06-27] │
|
||||
│ Время │ Длительность │
|
||||
│ [18:00] │ [30 мин ▾] │
|
||||
│ Вид │ Важность │
|
||||
│ [🛒 Покупки ▾] │ [🟡 Средняя ▾] │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ От кого: Папа │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
├──────────────────────────────────────────┤
|
||||
│ [Удалить] [ Сохранить ] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 14.4. Расписание (календарь + панель дня)
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ июнь 2026 ( Мои | Вся семья ) ‹ [Сегодня] › [+Событ]│
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ Пн Вт Ср Чт Пт Сб Вс │
|
||||
│ ┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐ │
|
||||
│ │ 1││ 2││ 3││ 4││ 5││ 6││ 7│ │
|
||||
│ │ ││ ││ ││ ││ ││ ││ │ │
|
||||
│ └──┘└──┘└──┘└──┘└──┘└──┘└──┘ │
|
||||
│ ┌──┐┌──┐┌──┐┌════════┐┌──┐┌──┐ │
|
||||
│ │ 8││ 9││10││11 сел. ││12││13│ ─ день выбран кликом │
|
||||
│ │ ││ ││ ││08 Школа││ ││ │ │
|
||||
│ │ ││ ││ ││14 Бассн││ ││ │ │
|
||||
│ └──┘└──┘└──┘└────────┘└──┘└──┘ │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ четверг, 11 июня [+ событие в этот день] │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ 08:00 │●│ Школа 👩 Аня · 📚 Учёба ✎ │ │
|
||||
│ │ 12:00 │ │ │ │ │ │
|
||||
│ ├──────────────────────────────────────────────────────┤ │
|
||||
│ │ 14:00 │●│ Бассейн 👩 Аня · ❤️ Здоровье ✎ │ │
|
||||
│ │ 15:00 │ │ │ │ │ │
|
||||
│ ├──────────────────────────────────────────────────────┤ │
|
||||
│ │ 18:00 │●│ Купить молоко 👩 Мама · 🛒 Покупки ✎ │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ 🏠 Дом 💼 Работа 📚 Учёба 🛒 Покупки 👶 Дети … │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 14.5. Семья (статистика)
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Семья Ивановых [⚙️ Управление] │
|
||||
│ Членов: 3 · задач всего: 12 │
|
||||
│ ┌────────────────────────────┐ ┌────────────────────────┐│
|
||||
│ │ 👩 Мама поручил: 5 │ │ 👨 Папа поручил: 4 ││
|
||||
│ │ [3] активных [6] готово │ │ [2] активных [1]гот. ││
|
||||
│ │ [1] просроч. │ │ [0] просроч. ││
|
||||
│ └────────────────────────────┘ └────────────────────────┘│
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Документ описывает текущее состояние MVP и направления развития. Приложения «Family Tasks» — учебный проект в статической песочнице Vibe42.*
|
||||
676
app.js
Normal file
676
app.js
Normal file
@ -0,0 +1,676 @@
|
||||
/* ===== Семейный таск-менеджер (чистая статика, 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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'},
|
||||
};
|
||||
|
||||
const CATEGORIES = [
|
||||
{key:'home', label:'🏠 Дом', color:'#10b981'},
|
||||
{key:'work', label:'💼 Работа', color:'#3b82f6'},
|
||||
{key:'study', label:'📚 Учёба', color:'#8b5cf6'},
|
||||
{key:'shop', label:'🛒 Покупки', color:'#f59e0b'},
|
||||
{key:'kids', label:'👶 Дети', color:'#ec4899'},
|
||||
{key:'money', label:'💰 Финансы', color:'#06b6d4'},
|
||||
{key:'health', label:'❤️ Здоровье', color:'#ef4444'},
|
||||
{key:'other', label:'🎉 Другое', color:'#6366f1'},
|
||||
];
|
||||
function catByKey(k){ return CATEGORIES.find(c=>c.key===k) || CATEGORIES[CATEGORIES.length-1]; }
|
||||
function catOptions(sel){ return CATEGORIES.map(c=>`<option value="${c.key}">${c.label}</option>`).join(''); }
|
||||
function formatDuration(min){
|
||||
min = Number(min); if(!min) return '';
|
||||
if(min<60) return min+' мин';
|
||||
const h=Math.floor(min/60), m=min%60;
|
||||
return m? `${h} ч ${m} мин` : `${h} ч`;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ИНИЦИАЛИЗАЦИЯ
|
||||
============================================================ */
|
||||
function init(){
|
||||
data = load();
|
||||
if(data && data.members && data.members.length){
|
||||
// миграции
|
||||
data.events = data.events || [];
|
||||
(data.tasks||[]).forEach(t=>{
|
||||
if(!t.category) t.category = 'other';
|
||||
if(t.duration===undefined) t.duration = null;
|
||||
});
|
||||
}
|
||||
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-members').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:[], events:[] };
|
||||
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;
|
||||
const cat = catByKey(t.category);
|
||||
const dur = formatDuration(t.duration);
|
||||
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">
|
||||
<span class="chip" style="background:${cat.color}22;color:${cat.color}">${cat.label}</span>
|
||||
<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>`:''}
|
||||
${dur?`<span class="badge bg-gray-100 text-gray-600">⏱ ${dur}</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>
|
||||
<select id="flt-cat" class="field !w-auto !py-2"><option value="">Все виды</option>${CATEGORIES.map(c=>`<option value="${c.key}">${c.label}</option>`).join('')}</select>
|
||||
</div>
|
||||
<div id="list" class="space-y-3"></div>`;
|
||||
const apply = ()=>{
|
||||
const fm = $('#flt-member').value, fs = $('#flt-status').value, fc = $('#flt-cat').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);
|
||||
if(fc) tasks = tasks.filter(t=>t.category===fc);
|
||||
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; $('#flt-cat').onchange = apply;
|
||||
apply();
|
||||
}
|
||||
|
||||
/* ---------- Расписание (календарь + личные события) ---------- */
|
||||
let calRef = new Date();
|
||||
let selDay = todayStr();
|
||||
let schedScope = 'mine'; // 'mine' | 'all'
|
||||
|
||||
function dayItems(ds, scope){
|
||||
let evs = (data.events||[]).filter(e=>e.date===ds);
|
||||
let tks = data.tasks.filter(t=>t.dueDate===ds && t.status!=='cancelled');
|
||||
if(scope==='mine'){
|
||||
evs = evs.filter(e=>e.ownerId===me);
|
||||
tks = tks.filter(t=>t.assigneeId===me);
|
||||
}
|
||||
return [
|
||||
...evs.map(e=>({type:'event', sort:e.start||'00:00', obj:e})),
|
||||
...tks.map(t=>({type:'task', sort:t.time||'zz', obj:t})),
|
||||
].sort((a,b)=>a.sort.localeCompare(b.sort));
|
||||
}
|
||||
|
||||
function renderSchedule(){
|
||||
if(!selDay) selDay = todayStr();
|
||||
const y = calRef.getFullYear(), m = calRef.getMonth();
|
||||
const first = new Date(y,m,1);
|
||||
const startDow = (first.getDay()+6)%7;
|
||||
const daysInMonth = new Date(y,m+1,0).getDate();
|
||||
const monthName = first.toLocaleDateString('ru-RU',{month:'long', year:'numeric'});
|
||||
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 items = dayItems(ds, schedScope);
|
||||
cells += `<div class="cal-day ${ds===today?'today':''} ${ds===selDay?'selected':''}" data-day="${ds}">
|
||||
<div class="cal-dnum">${d}</div>
|
||||
${items.slice(0,3).map(it=>chipHtml(it)).join('')}
|
||||
${items.length>3?`<div class="text-xs text-muted">+${items.length-3}</div>`:''}
|
||||
</div>`;
|
||||
}
|
||||
$('#main').innerHTML = `
|
||||
<div class="flex items-center justify-between mb-4 gap-2 flex-wrap">
|
||||
<h2 class="text-2xl font-extrabold capitalize">${monthName}</h2>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex bg-line rounded-lg p-1">
|
||||
<button id="sc-mine" class="seg ${schedScope==='mine'?'active':''}">Мои</button>
|
||||
<button id="sc-all" class="seg ${schedScope==='all'?'active':''}">Вся семья</button>
|
||||
</div>
|
||||
<button id="cal-prev" class="iconbtn">‹</button>
|
||||
<button id="cal-today" class="btn-ghost text-sm">Сегодня</button>
|
||||
<button id="cal-next" class="iconbtn">›</button>
|
||||
<button id="cal-addev" class="btn-primary text-sm">+ Событие</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cal-grid">${cells}</div>
|
||||
<div id="day-panel" class="mt-5"></div>
|
||||
<div class="flex flex-wrap gap-3 mt-4 text-xs text-muted">
|
||||
${CATEGORIES.map(c=>`<span class="flex items-center gap-1"><span class="dot" style="background:${c.color}"></span>${c.label}</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(); selDay=todayStr(); renderSchedule(); };
|
||||
$('#cal-addev').onclick=()=>openEventModal(null, selDay);
|
||||
$('#sc-mine').onclick=()=>{ schedScope='mine'; renderSchedule(); };
|
||||
$('#sc-all').onclick=()=>{ schedScope='all'; renderSchedule(); };
|
||||
$$('#main .cal-day[data-day]').forEach(el=>el.onclick=e=>{ if(e.target.closest('.cal-task')) return; selDay=el.dataset.day; renderSchedule(); });
|
||||
$$('#main .cal-task').forEach(el=>el.onclick=e=>{ e.stopPropagation(); el.dataset.type==='event'?openEventModal(el.dataset.id):openTaskModal(el.dataset.id); });
|
||||
renderDayPanel();
|
||||
}
|
||||
|
||||
function chipHtml(it){
|
||||
if(it.type==='event'){
|
||||
const c=catByKey(it.obj.category);
|
||||
return `<div class="cal-task" style="background:${c.color}22;color:${c.color}" data-type="event" data-id="${it.obj.id}">${escapeHtml(it.obj.start)} ${escapeHtml(it.obj.title)}</div>`;
|
||||
}
|
||||
const t=it.obj;
|
||||
return `<div class="cal-task" style="background:${colorFor(t.assigneeId)}22;color:${colorFor(t.assigneeId)};${t.status==='done'?'text-decoration:line-through;opacity:.6':''}" data-type="task" data-id="${t.id}">✓ ${escapeHtml(t.title)}</div>`;
|
||||
}
|
||||
|
||||
function renderDayPanel(){
|
||||
const panel = $('#day-panel'); if(!panel) return;
|
||||
const items = dayItems(selDay, schedScope);
|
||||
const d = parseDate(selDay);
|
||||
const dname = d ? d.toLocaleDateString('ru-RU',{weekday:'long',day:'numeric',month:'long'}) : selDay;
|
||||
const isToday = selDay===todayStr();
|
||||
panel.innerHTML = `
|
||||
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<h3 class="font-bold text-lg capitalize">${dname} ${isToday?'<span class="badge bg-accent-50 text-ink">сегодня</span>':''}</h3>
|
||||
<button id="dp-addev" class="btn-ghost text-sm">+ событие в этот день</button>
|
||||
</div>
|
||||
${items.length? `<div class="space-y-2">${items.map(it=>dayRowHtml(it)).join('')}</div>` : emptyHtml('📅','На этот день ничего не запланировано.')}`;
|
||||
$('#dp-addev')?.addEventListener('click', ()=>openEventModal(null, selDay));
|
||||
panel.querySelectorAll('.day-row').forEach(r=>{
|
||||
r.onclick=()=>{ r.dataset.type==='event'?openEventModal(r.dataset.id):openTaskModal(r.dataset.id); };
|
||||
});
|
||||
}
|
||||
|
||||
function dayRowHtml(it){
|
||||
if(it.type==='event'){
|
||||
const e=it.obj, c=catByKey(e.category), owner=member(e.ownerId);
|
||||
return `<div class="day-row stat-card flex items-center gap-3" data-type="event" data-id="${e.id}" style="cursor:pointer">
|
||||
<div class="time-badge" style="background:${c.color}1a;color:${c.color}">${escapeHtml(e.start)}<br><span class="opacity-60">${escapeHtml(e.end)}</span></div>
|
||||
<div class="dot" style="background:${c.color}"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold">${escapeHtml(e.title)}</div>
|
||||
<div class="text-xs text-muted">${avatarHtml(owner,14)} ${escapeHtml(owner?owner.name:'—')} · ${c.label}</div>
|
||||
</div>
|
||||
<span class="text-muted">✎</span>
|
||||
</div>`;
|
||||
}
|
||||
const t=it.obj, cat=catByKey(t.category), a=member(t.assigneeId);
|
||||
return `<div class="day-row stat-card flex items-center gap-3" data-type="task" data-id="${t.id}" style="cursor:pointer">
|
||||
<div class="time-badge" style="background:${colorFor(t.assigneeId)}1a;color:${colorFor(t.assigneeId)}">${escapeHtml(t.time||'—')}</div>
|
||||
<div class="dot" style="background:${colorFor(t.assigneeId)}"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold ${t.status==='done'?'done':''}">${escapeHtml(t.title)}</div>
|
||||
<div class="text-xs text-muted">${avatarHtml(a,14)} ${escapeHtml(a?a.name:'—')} · ${cat.label}${t.duration?' · ⏱ '+formatDuration(t.duration):''}</div>
|
||||
</div>
|
||||
<span class="badge ${STATUS[t.status].cls}">${STATUS[t.status].label}</span>
|
||||
<span class="text-muted">✎</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ---------- модалка события ---------- */
|
||||
let editingEventId = null;
|
||||
function openEventModal(id, dateStr){
|
||||
editingEventId = id;
|
||||
const e = id ? data.events.find(x=>x.id===id) : null;
|
||||
$('#ev-title').textContent = e ? 'Событие' : 'Новое событие';
|
||||
$('#ev-name').value = e?.title||'';
|
||||
$('#ev-date').value = e?.date || dateStr || selDay || todayStr();
|
||||
$('#ev-start').value = e?.start||'';
|
||||
$('#ev-end').value = e?.end||'';
|
||||
$('#ev-owner').innerHTML = data.members.map(m=>`<option value="${m.id}" ${(e?e.ownerId===m.id:m.id===me)?'selected':''}>${escapeHtml(m.name)}</option>`).join('');
|
||||
$('#ev-cat').innerHTML = catOptions();
|
||||
$('#ev-cat').value = e?.category||'other';
|
||||
$('#ev-delete').classList.toggle('hidden', !e);
|
||||
$('#event-modal').classList.remove('hidden');
|
||||
setTimeout(()=>$('#ev-name').focus(),50);
|
||||
}
|
||||
$('#ev-close').onclick = ()=>$('#event-modal').classList.add('hidden');
|
||||
$('#event-modal').addEventListener('click', e=>{ if(e.target.id==='event-modal') $('#event-modal').classList.add('hidden'); });
|
||||
$('#ev-save').onclick = ()=>{
|
||||
const title=$('#ev-name').value.trim();
|
||||
const date=$('#ev-date').value;
|
||||
const start=$('#ev-start').value, end=$('#ev-end').value;
|
||||
if(!title){ toast('Введите название'); $('#ev-name').focus(); return; }
|
||||
if(!date){ toast('Выберите дату'); return; }
|
||||
if(!start||!end){ toast('Укажите время начала и конца'); return; }
|
||||
if(start>=end){ toast('Конец должен быть позже начала'); return; }
|
||||
const payload={ title, date, start, end, ownerId:$('#ev-owner').value, category:$('#ev-cat').value };
|
||||
if(editingEventId){ Object.assign(data.events.find(x=>x.id===editingEventId), payload); toast('Сохранено'); }
|
||||
else { data.events.push(Object.assign({id:uid()}, payload)); toast('Событие добавлено 📅'); }
|
||||
selDay = date;
|
||||
save(); $('#event-modal').classList.add('hidden'); render();
|
||||
};
|
||||
$('#ev-delete').onclick = ()=>{
|
||||
if(!editingEventId) return;
|
||||
if(!confirm('Удалить событие?')) return;
|
||||
data.events = data.events.filter(x=>x.id!==editingEventId);
|
||||
save(); $('#event-modal').classList.add('hidden'); render();
|
||||
toast('Событие удалено');
|
||||
};
|
||||
|
||||
/* ---------- Семья ---------- */
|
||||
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-duration').value = t?.duration||'';
|
||||
$('#f-category').innerHTML = catOptions();
|
||||
$('#f-category').value = t?.category||'other';
|
||||
$('#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,
|
||||
duration: $('#f-duration').value ? Number($('#f-duration').value) : null,
|
||||
priority: $('#f-priority').value,
|
||||
category: $('#f-category').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
110
design.md
Normal 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
|
||||
261
index.html
Normal file
261
index.html
Normal file
@ -0,0 +1,261 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Семейные задачи</title>
|
||||
<script>
|
||||
window.addEventListener('error',function(e){
|
||||
var d=document.createElement('div');
|
||||
d.style.cssText='position:fixed;top:0;left:0;right:0;background:#b91c1c;color:#fff;padding:12px;font:14px sans-serif;z-index:9999;white-space:pre-wrap';
|
||||
d.textContent='Ошибка JS: '+(e&&e.message||e)+' — обновите страницу; если не поможет, сообщите этот текст.';
|
||||
document.body.appendChild(d);
|
||||
});
|
||||
</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-duration" class="field">
|
||||
<option value="">— не указана —</option>
|
||||
<option value="15">15 мин</option>
|
||||
<option value="30">30 мин</option>
|
||||
<option value="45">45 мин</option>
|
||||
<option value="60">1 ч</option>
|
||||
<option value="90">1.5 ч</option>
|
||||
<option value="120">2 ч</option>
|
||||
<option value="180">3 ч</option>
|
||||
<option value="240">4 ч</option>
|
||||
<option value="360">6 ч</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold mb-1">Вид</label>
|
||||
<select id="f-category" class="field"></select>
|
||||
</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="event-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="ev-title" class="font-bold text-lg">Событие в расписании</h3>
|
||||
<button id="ev-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="ev-name" class="field" placeholder="Напр. Школа, Бассейн, Работа">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold mb-1">Дата *</label>
|
||||
<input id="ev-date" type="date" class="field">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold mb-1">Чьё расписание</label>
|
||||
<select id="ev-owner" class="field"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold mb-1">С *</label>
|
||||
<input id="ev-start" type="time" class="field">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold mb-1">До *</label>
|
||||
<input id="ev-end" type="time" class="field">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold mb-1">Вид / цвет</label>
|
||||
<select id="ev-cat" class="field"></select>
|
||||
</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 flex gap-2">
|
||||
<button id="ev-delete" class="hidden btn-danger">Удалить</button>
|
||||
<button id="ev-save" class="btn-primary flex-1">Сохранить</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>
|
||||
186
style.css
Normal file
186
style.css
Normal file
@ -0,0 +1,186 @@
|
||||
/* ===== Семейный таск-менеджер — стили (без Tailwind CDN) ===== */
|
||||
:root{
|
||||
--ink:#0F1218; --accent:#00E5FF; --accent-50:#E8FCFF;
|
||||
--muted:#5B6573; --line:#F2F4F7; --white:#fff;
|
||||
--amber-50:#fffbeb; --amber-100:#fef3c7; --amber-600:#d97706; --amber-700:#b45309;
|
||||
--emerald-100:#d1fae5; --emerald-600:#059669; --emerald-700:#047857;
|
||||
--sky-100:#e0f2fe; --sky-700:#0369a1;
|
||||
--gray-100:#f3f4f6; --gray-200:#e5e7eb; --gray-300:#d1d5db; --gray-600:#4b5563;
|
||||
--red-50:#fef2f2; --red-100:#fee2e2; --red-600:#dc2626; --red-700:#b91c1c;
|
||||
}
|
||||
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--ink);background:var(--line)}
|
||||
|
||||
/* ---------- компоненты ---------- */
|
||||
.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;border:none;cursor:pointer;transition:filter .15s;display:inline-block;text-align:center}
|
||||
.btn-primary:hover{filter:brightness(1.06)}
|
||||
.btn-primary:active{filter:brightness(.94)}
|
||||
.btn-primary:disabled{opacity:.5;cursor:not-allowed}
|
||||
.btn-ghost{background:transparent;color:var(--ink);border:2px solid var(--line);padding:9px 18px;border-radius:8px;font-weight:600;cursor:pointer}
|
||||
.btn-ghost:hover{background:var(--line)}
|
||||
.btn-danger{background:#fee2e2;color:#b91c1c;padding:11px 18px;border-radius:8px;font-weight:700;border:none;cursor:pointer}
|
||||
.btn-danger:hover{background:#fecaca}
|
||||
|
||||
.navtab{white-space:nowrap;padding:10px 14px;font-size:14px;font-weight:600;color:rgba(255,255,255,.5);border:none;background:none;border-bottom:3px solid transparent;cursor:pointer;transition:.15s;font-family:inherit}
|
||||
.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;line-height:1}
|
||||
|
||||
.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;display:inline-block}
|
||||
|
||||
.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:15px;cursor:pointer;transition:.15s;font-family:inherit;color:var(--ink)}
|
||||
.iconbtn:hover{background:var(--line)}
|
||||
.iconbtn.primary{background:var(--accent);border-color:var(--accent)}
|
||||
.iconbtn:disabled{opacity:.5;cursor:not-allowed}
|
||||
|
||||
.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-day.selected{outline:2px solid var(--accent);outline-offset:-2px}
|
||||
.cal-day[data-day]{cursor:pointer}
|
||||
.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}
|
||||
|
||||
/* сегмент-переключатель */
|
||||
.seg{border:none;background:none;padding:6px 12px;font-size:13px;font-weight:600;color:var(--muted);border-radius:7px;cursor:pointer;font-family:inherit}
|
||||
.seg.active{background:#fff;color:var(--ink);box-shadow:0 1px 3px rgba(0,0,0,.1)}
|
||||
|
||||
/* элементы расписания */
|
||||
.dot{width:10px;height:10px;border-radius:999px;flex-shrink:0}
|
||||
.time-badge{font-size:11px;font-weight:700;text-align:center;line-height:1.2;min-width:52px;padding:6px 4px;border-radius:8px;white-space:nowrap}
|
||||
.time-badge span{display:block;font-size:10px;font-weight:600}
|
||||
.day-row{transition:box-shadow .15s}
|
||||
.day-row:hover{box-shadow:0 4px 14px rgba(15,18,24,.08)}
|
||||
.day-row .done{text-decoration:line-through;opacity:.6}
|
||||
|
||||
.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}
|
||||
|
||||
/* ---------- утилиты (вместо Tailwind) ---------- */
|
||||
.hidden{display:none!important}
|
||||
.block{display:block}
|
||||
.flex{display:flex}
|
||||
.inline-flex{display:inline-flex}
|
||||
.grid{display:grid}
|
||||
.flex-1{flex:1 1 0%}
|
||||
.flex-col{flex-direction:column}
|
||||
.flex-wrap{flex-wrap:wrap}
|
||||
.items-center{align-items:center}
|
||||
.items-start{align-items:flex-start}
|
||||
.items-end{align-items:flex-end}
|
||||
.justify-center{justify-content:center}
|
||||
.justify-between{justify-content:space-between}
|
||||
.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}
|
||||
.grid-cols-2{grid-template-columns:repeat(2,1fr)}
|
||||
.grid-cols-3{grid-template-columns:repeat(3,1fr)}
|
||||
.space-y-1>*+*{margin-top:.25rem}
|
||||
.space-y-2>*+*{margin-top:.5rem}
|
||||
.space-y-3>*+*{margin-top:.75rem}
|
||||
.space-y-4>*+*{margin-top:1rem}
|
||||
.min-h-screen{min-height:100vh}
|
||||
.min-w-0{min-width:0}
|
||||
.w-full{width:100%}.w-60{width:15rem}.w-8{width:2rem}.w-9{width:2.25rem}
|
||||
.h-8{height:2rem}.h-9{height:2.25rem}
|
||||
.max-w-6xl{max-width:72rem}.max-w-lg{max-width:32rem}.max-w-xs{max-width:20rem}
|
||||
.max-w-\[90px\]{max-width:90px}
|
||||
.max-h-72{max-height:18rem}.max-h-\[92vh\]{max-height:92vh}
|
||||
.mx-auto{margin-left:auto;margin-right:auto}
|
||||
.ml-auto{margin-left:auto}
|
||||
.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}
|
||||
.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}
|
||||
.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-7{padding:1.75rem}
|
||||
.px-2{padding-left:.5rem;padding-right:.5rem}
|
||||
.px-3{padding-left:.75rem;padding-right:.75rem}
|
||||
.px-4{padding-left:1rem;padding-right:1rem}
|
||||
.px-5{padding-left:1.25rem;padding-right:1.25rem}
|
||||
.py-1{padding-top:.25rem;padding-bottom:.25rem}
|
||||
.py-2{padding-top:.5rem;padding-bottom:.5rem}
|
||||
.py-3{padding-top:.75rem;padding-bottom:.75rem}
|
||||
.py-5{padding-top:1.25rem;padding-bottom:1.25rem}
|
||||
.py-6{padding-top:1.5rem;padding-bottom:1.5rem}
|
||||
.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}
|
||||
.pl-1{padding-left:.25rem}.pr-3{padding-right:.75rem}
|
||||
.relative{position:relative}.absolute{position:absolute}.fixed{position:fixed}.sticky{position:sticky}
|
||||
.top-0{top:0}.bottom-0{bottom:0}.bottom-5{bottom:1.25rem}.right-0{right:0}.left-1\/2{left:50%}
|
||||
.inset-0{top:0;right:0;bottom:0;left:0}
|
||||
.-translate-x-1\/2{transform:translateX(-50%)}
|
||||
.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}
|
||||
.overflow-auto{overflow:auto}.overflow-x-auto{overflow-x:auto}
|
||||
.rounded-lg{border-radius:8px}.rounded-xl{border-radius:12px}.rounded-2xl{border-radius:16px}
|
||||
.rounded-t-2xl{border-radius:16px 16px 0 0}.rounded-full{border-radius:9999px}
|
||||
.shadow-xl{box-shadow:0 10px 30px rgba(15,18,24,.12)}
|
||||
.shadow-2xl{box-shadow:0 20px 50px rgba(15,18,24,.25)}
|
||||
.border{border:1px solid var(--line)}.border-t{border-top:1px solid var(--line)}.border-b{border-bottom:1px solid var(--line)}.border-line{border-color:var(--line)}
|
||||
.text-2xl{font-size:24px}.text-5xl{font-size:48px}.text-xl{font-size:20px}.text-lg{font-size:18px}
|
||||
.text-base{font-size:16px}.text-sm{font-size:14px}.text-xs{font-size:12px}.text-\[11px\]{font-size:11px}
|
||||
.font-sans{font-family:inherit}
|
||||
.font-semibold{font-weight:600}.font-bold{font-weight:700}.font-extrabold{font-weight:800}
|
||||
.leading-tight{line-height:1.1}.leading-none{line-height:1}
|
||||
.text-center{text-align:center}.text-left{text-align:left}
|
||||
.capitalize{text-transform:capitalize}
|
||||
.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.underline{text-decoration:underline}
|
||||
.opacity-60{opacity:.6}
|
||||
.transition{transition:.15s}
|
||||
|
||||
/* цвета текста */
|
||||
.text-ink{color:var(--ink)}.text-muted{color:var(--muted)}.text-white{color:#fff}
|
||||
.text-amber-600{color:var(--amber-600)}.text-emerald-600{color:var(--emerald-600)}
|
||||
.text-red-600{color:var(--red-600)}.text-gray-300{color:var(--gray-300)}.text-gray-600{color:var(--gray-600)}
|
||||
.text-amber-700{color:var(--amber-700)}.text-emerald-700{color:var(--emerald-700)}.text-sky-700{color:var(--sky-700)}.text-red-700{color:var(--red-700)}
|
||||
.text-white\/50{color:rgba(255,255,255,.5)}.text-white\/60{color:rgba(255,255,255,.6)}.text-white\/80{color:rgba(255,255,255,.8)}
|
||||
|
||||
/* цвета фона */
|
||||
.bg-ink{background:var(--ink)}.bg-white{background:#fff}.bg-line{background:var(--line)}.bg-accent-50{background:var(--accent-50)}
|
||||
.bg-black\/50{background:rgba(0,0,0,.5)}.bg-white\/10{background:rgba(255,255,255,.1)}.bg-white\/20{background:rgba(255,255,255,.2)}
|
||||
.bg-amber-50{background:var(--amber-50)}.bg-amber-100{background:var(--amber-100)}
|
||||
.bg-emerald-100{background:var(--emerald-100)}.bg-sky-100{background:var(--sky-100)}
|
||||
.bg-gray-100{background:var(--gray-100)}.bg-gray-200{background:var(--gray-200)}
|
||||
.bg-red-50{background:var(--red-50)}.bg-red-100{background:var(--red-100)}
|
||||
|
||||
/* hover */
|
||||
.hover\:bg-line:hover{background:var(--line)}
|
||||
.hover\:bg-white\/20:hover{background:rgba(255,255,255,.2)}
|
||||
.hover\:text-ink:hover{color:var(--ink)}
|
||||
|
||||
/* important-override */
|
||||
.\!w-auto{width:auto!important}
|
||||
.\!py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}
|
||||
.\!px-3{padding-left:.75rem!important;padding-right:.75rem!important}
|
||||
|
||||
/* адаптив (sm: 640px+) */
|
||||
@media(min-width:640px){
|
||||
.sm\:grid-cols-2{grid-template-columns:repeat(2,1fr)}
|
||||
.sm\:hidden{display:none!important}
|
||||
.sm\:inline-flex{display:inline-flex}
|
||||
.sm\:items-center{align-items:center}
|
||||
.sm\:max-w-lg{max-width:32rem}
|
||||
.sm\:p-4{padding:1rem}
|
||||
.sm\:rounded-2xl{border-radius:16px}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user