Add family portal site, photos, updated PRD/TRD
- index.html: single-page family portal (auth, countdown to Batumi, checklist, Batumi guide, reminders, photo upload) - Family photos: zhanara.jpg, kids.jpg, WhatsApp Image - Updated PRD.md and TRD.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fa0c89ed69
commit
24136595b7
103
PRD.md
103
PRD.md
@ -0,0 +1,103 @@
|
|||||||
|
# PRD — Семейный сайт Жанар
|
||||||
|
|
||||||
|
**Автор:** Жанар, 37 лет, Астана
|
||||||
|
**Дата:** 22 мая 2026 года
|
||||||
|
**Статус:** Черновик
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Хочу автоматизировать получение информации про мою семью
|
||||||
|
|
||||||
|
Сейчас информация о нашей семье разбросана по разным местам: у мужа своя работа, у меня своя, дети растут, путешествия планируются отдельно. Хочу один закрытый сайт, где всё это собрано в одном месте — как личный семейный портал. Заходишь и сразу видишь всё: кто мы, чем занимаемся, когда следующий тур, что нужно взять с собой, напоминания на день.
|
||||||
|
|
||||||
|
Дополнительно: на сайте должна быть связь с моим Instagram (@zhanara2964) — ссылка и напоминание публиковать истории раз в неделю.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 Кто будет пользоваться
|
||||||
|
|
||||||
|
- **Жанар** (я) — главный пользователь, смотрю каждый день
|
||||||
|
- **Муж** — заходит, чтобы видеть семейные планы и чек-листы
|
||||||
|
- Доступ **закрытый, по паролю** — посторонние не видят ничего
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Что должно произойти на выходе
|
||||||
|
|
||||||
|
После создания сайта я хочу:
|
||||||
|
1. Открыть сайт и сразу увидеть всё о нашей семье — фото, работа, дети
|
||||||
|
2. Видеть счётчик дней до тура в Грузию (вылет 22 июня 2026)
|
||||||
|
3. Получать напоминания прямо на телефон (или видеть их на сайте) о шагах и Instagram
|
||||||
|
4. Иметь готовый чек-лист вещей для поездки по погоде Батуми
|
||||||
|
5. Открыть гид по Батуми прямо с сайта
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Список 5 главных функций
|
||||||
|
|
||||||
|
### 1. 🏠 Главная страница "Наша семья"
|
||||||
|
Краткая информация о семье: фото, имена, чем занимаемся (работа Жанар + работа мужа), дети. Закрыто паролем.
|
||||||
|
|
||||||
|
### 2. ⏳ Счётчик до тура в Грузию
|
||||||
|
Большой виджет на главной: "До вылета в Батуми осталось **XX дней**". Дата вылета — 22 июня 2026 года, возвращение — 29 июня 2026 года.
|
||||||
|
|
||||||
|
### 3. 🔔 Напоминания о шагах и Instagram
|
||||||
|
- **Будни:** напоминание в 12:00 — пройти 5 000 шагов (3 раза подряд)
|
||||||
|
- **Выходные:** напоминание в 12:00 — пройти 10 000 шагов (3 раза подряд)
|
||||||
|
- **Раз в неделю:** напоминание создать одну историю в Instagram (@zhanara2964)
|
||||||
|
- Уведомления приходят **на телефон**
|
||||||
|
|
||||||
|
### 4. 🧳 Чек-лист для поездки в Батуми
|
||||||
|
Интерактивный список вещей, которые нужно взять. Разделён на категории. Можно отмечать галочками по мере сборки.
|
||||||
|
|
||||||
|
**Погода Батуми 22–29 июня:**
|
||||||
|
Тепло, влажно. Днём +23–25°C, ночью +21°C. Высокая влажность 80–82%. Ветер западный, несильный. Возможны короткие дожди (июнь — дождливый месяц). Море +22–23°C, можно купаться.
|
||||||
|
|
||||||
|
**Список одежды по погоде:**
|
||||||
|
- Лёгкие летние платья / шорты / футболки (3–4 комплекта)
|
||||||
|
- Лёгкий кардиган или ветровка (на вечер, бывает прохладно)
|
||||||
|
- Дождевик или компактный зонт (дожди бывают часто)
|
||||||
|
- Купальник / плавки (2 штуки)
|
||||||
|
- Удобная обувь для прогулок (сандалии + кроссовки)
|
||||||
|
- Шлёпанцы для пляжа
|
||||||
|
- Солнцезащитные очки и шляпа/панама
|
||||||
|
- Один более нарядный образ для ресторана
|
||||||
|
|
||||||
|
### 5. 🗺️ Гид-лист Батуми
|
||||||
|
Список мест для посещения с кратким описанием:
|
||||||
|
|
||||||
|
| # | Место | Что это |
|
||||||
|
|---|-------|---------|
|
||||||
|
| 1 | **Приморский бульвар** | Главная набережная города, старейшая достопримечательность — гулять обязательно |
|
||||||
|
| 2 | **Скульптура "Али и Нино"** | Знаменитые движущиеся фигуры влюблённых на набережной |
|
||||||
|
| 3 | **Алфавитная башня** | Архитектурная икона Батуми, высокая башня в форме ДНК |
|
||||||
|
| 4 | **Колесо обозрения** | Вид на море с высоты, 10 лари, у порта |
|
||||||
|
| 5 | **Ботанический сад** | Огромный сад с видом на море, тропические растения |
|
||||||
|
| 6 | **Старый город** | Исторические улицы, мечеть, атмосфера |
|
||||||
|
| 7 | **Пещера Прометея** | Экскурсия ~1,5 ч от Батуми, сталактиты, подземная река |
|
||||||
|
| 8 | **Каньон Мартвили** | Природный каньон, лодки по воде, очень красиво |
|
||||||
|
| 9 | **Водопад Махунцети** | Природная достопримечательность за городом |
|
||||||
|
| 10 | **Фонтан Нептуна** | Театральная площадь, копия итальянского фонтана |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚫 Что НЕ входит в задачу
|
||||||
|
|
||||||
|
- Сайт не будет публичным — это личный семейный инструмент, не блог
|
||||||
|
- Не нужна автоматическая публикация в Instagram — только напоминание
|
||||||
|
- Не нужна интеграция с фитнес-трекером — только напоминание о шагах
|
||||||
|
- Не нужен интернет-магазин, реклама или монетизация
|
||||||
|
- Не нужны сложные анимации и спецэффекты — простота важнее красоты
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 Ближайшие шаги
|
||||||
|
|
||||||
|
1. Определиться с платформой для создания сайта (Notion, Tilda, или готовый код)
|
||||||
|
2. Собрать семейные фото и информацию для главной страницы
|
||||||
|
3. Настроить напоминания на телефон (через приложение или браузер)
|
||||||
|
4. Упаковать чек-лист и гид перед вылетом 22 июня
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Создано с помощью Claude · Май 2026*
|
||||||
233
TRD.md
233
TRD.md
@ -0,0 +1,233 @@
|
|||||||
|
# TRD — Семейный сайт Жанар (MVP)
|
||||||
|
|
||||||
|
**Автор:** Claude
|
||||||
|
**Дата:** 22 мая 2026
|
||||||
|
**Источник:** PRD.md
|
||||||
|
**Цель:** рабочий MVP за 1 час
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Стек
|
||||||
|
|
||||||
|
**Чистый статический сайт: HTML + CSS + vanilla JS.**
|
||||||
|
|
||||||
|
Почему:
|
||||||
|
- Нет бэкенда → нет сервера, БД, деплоя API
|
||||||
|
- Все данные хардкодятся в JSON-файле или прямо в HTML
|
||||||
|
- Запуск = открыть `index.html` в браузере (или `python3 -m http.server`)
|
||||||
|
- Уведомления — через Web Notifications API (нативно, без зависимостей)
|
||||||
|
- Состояние чек-листа — `localStorage`
|
||||||
|
- Пароль — простая JS-проверка (не безопасность, а «защита от случайного посетителя»; PRD явно: личный инструмент)
|
||||||
|
|
||||||
|
Никаких фреймворков, npm, сборки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Данные
|
||||||
|
|
||||||
|
Один файл `data.js` с константами:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const FAMILY = {
|
||||||
|
members: [
|
||||||
|
{ name: "Жанар", role: "мама", photo: "img/zhanara.jpg", about: "..." },
|
||||||
|
{ name: "Муж", role: "папа", photo: "img/husband.jpg", about: "..." },
|
||||||
|
{ name: "Ребёнок 1", role: "дочь/сын", photo: "img/kid1.jpg" }
|
||||||
|
],
|
||||||
|
instagram: "https://instagram.com/zhanara2964"
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRIP = {
|
||||||
|
destination: "Батуми, Грузия",
|
||||||
|
departure: "2026-06-22",
|
||||||
|
return: "2026-06-29",
|
||||||
|
weather: "Днём +23–25°C, ночью +21°C, влажность 80%, возможны дожди"
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHECKLIST = [
|
||||||
|
{ category: "Одежда", items: [
|
||||||
|
"Лёгкие платья / шорты / футболки (3–4 комплекта)",
|
||||||
|
"Лёгкий кардиган или ветровка",
|
||||||
|
"Дождевик или компактный зонт",
|
||||||
|
"Нарядный образ для ресторана"
|
||||||
|
]},
|
||||||
|
{ category: "Пляж", items: [
|
||||||
|
"Купальник / плавки (2 шт)",
|
||||||
|
"Шлёпанцы",
|
||||||
|
"Полотенце"
|
||||||
|
]},
|
||||||
|
{ category: "Обувь", items: [
|
||||||
|
"Сандалии",
|
||||||
|
"Кроссовки"
|
||||||
|
]},
|
||||||
|
{ category: "Аксессуары", items: [
|
||||||
|
"Солнцезащитные очки",
|
||||||
|
"Шляпа / панама"
|
||||||
|
]}
|
||||||
|
];
|
||||||
|
|
||||||
|
const GUIDE = [
|
||||||
|
{ name: "Приморский бульвар", desc: "Главная набережная" },
|
||||||
|
{ name: "Скульптура «Али и Нино»", desc: "Движущиеся фигуры" },
|
||||||
|
{ name: "Алфавитная башня", desc: "Башня в форме ДНК" },
|
||||||
|
{ name: "Колесо обозрения", desc: "Вид на море, 10 лари" },
|
||||||
|
{ name: "Ботанический сад", desc: "Тропические растения" },
|
||||||
|
{ name: "Старый город", desc: "Исторические улицы, мечеть" },
|
||||||
|
{ name: "Пещера Прометея", desc: "1.5ч от Батуми, сталактиты" },
|
||||||
|
{ name: "Каньон Мартвили", desc: "Лодки по воде" },
|
||||||
|
{ name: "Водопад Махунцети", desc: "За городом" },
|
||||||
|
{ name: "Фонтан Нептуна", desc: "Театральная площадь" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const REMINDERS = {
|
||||||
|
weekdaySteps: { time: "12:00", text: "Пройти 5 000 шагов" },
|
||||||
|
weekendSteps: { time: "12:00", text: "Пройти 10 000 шагов" },
|
||||||
|
instagram: { day: 0, time: "11:00", text: "Опубликовать историю в Instagram" } // 0=вс
|
||||||
|
};
|
||||||
|
|
||||||
|
const PASSWORD = "zhanar2026"; // меняется в data.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Структура папок
|
||||||
|
|
||||||
|
```
|
||||||
|
kuzbaeva/
|
||||||
|
├── PRD.md
|
||||||
|
├── TRD.md
|
||||||
|
├── index.html # одностраничник со всеми секциями
|
||||||
|
├── css/
|
||||||
|
│ └── style.css
|
||||||
|
├── js/
|
||||||
|
│ ├── data.js # все данные (см. выше)
|
||||||
|
│ ├── auth.js # парольный гейт
|
||||||
|
│ ├── countdown.js # счётчик дней до 22.06.2026
|
||||||
|
│ ├── checklist.js # рендер + localStorage
|
||||||
|
│ ├── reminders.js # Notification API + setInterval
|
||||||
|
│ └── app.js # точка входа, рендер семьи и гида
|
||||||
|
├── img/
|
||||||
|
│ ├── zhanara.jpg
|
||||||
|
│ ├── husband.jpg
|
||||||
|
│ └── kid1.jpg
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Разделы `index.html`
|
||||||
|
|
||||||
|
Одна страница, секции по якорям:
|
||||||
|
|
||||||
|
1. `#auth` — модалка с полем пароля; при совпадении с `PASSWORD` ставит `localStorage.auth=1` и показывает контент
|
||||||
|
2. `#family` — карточки семьи из `FAMILY.members` + ссылка на Instagram
|
||||||
|
3. `#countdown` — крупный блок: `«До Батуми осталось N дней»` (вычисляется из `TRIP.departure`)
|
||||||
|
4. `#checklist` — категории и чекбоксы; состояние пишется в `localStorage`
|
||||||
|
5. `#guide` — таблица из `GUIDE`
|
||||||
|
6. `#reminders` — кнопка «Включить уведомления» (просит `Notification.requestPermission()`), статус «включены/выключены»
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Логика уведомлений
|
||||||
|
|
||||||
|
`reminders.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function scheduleReminders() {
|
||||||
|
if (Notification.permission !== "granted") return;
|
||||||
|
setInterval(checkAndFire, 60_000); // раз в минуту
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAndFire() {
|
||||||
|
const now = new Date();
|
||||||
|
const hhmm = now.toTimeString().slice(0,5);
|
||||||
|
const isWeekend = [0,6].includes(now.getDay());
|
||||||
|
|
||||||
|
const target = isWeekend ? REMINDERS.weekendSteps : REMINDERS.weekdaySteps;
|
||||||
|
if (hhmm === target.time && !firedToday("steps")) {
|
||||||
|
new Notification("Шаги", { body: target.text });
|
||||||
|
markFired("steps");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now.getDay() === REMINDERS.instagram.day
|
||||||
|
&& hhmm === REMINDERS.instagram.time
|
||||||
|
&& !firedToday("ig")) {
|
||||||
|
new Notification("Instagram", { body: REMINDERS.instagram.text });
|
||||||
|
markFired("ig");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`firedToday/markFired` — через `localStorage` с ключом-датой, чтобы не дублировать.
|
||||||
|
|
||||||
|
Ограничение: уведомления работают только пока сайт открыт во вкладке. Для пуша когда вкладка закрыта нужен Service Worker + Push API — **вне MVP**. Документируем в README как «Шаг 2».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Пароль (упрощённо)
|
||||||
|
|
||||||
|
`auth.js`:
|
||||||
|
- При загрузке: если `localStorage.auth !== "1"` — скрыть `<main>`, показать `<dialog id="auth">`
|
||||||
|
- На submit: сравнить с `PASSWORD`, успех → `localStorage.auth="1"` + `location.reload()`
|
||||||
|
- Кнопка «Выйти» сбрасывает ключ
|
||||||
|
|
||||||
|
PRD прямо говорит: «защита от посторонних», не банковский уровень. JS-пароль ОК.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Как запускать
|
||||||
|
|
||||||
|
**Вариант А — двойной клик:**
|
||||||
|
```
|
||||||
|
open index.html
|
||||||
|
```
|
||||||
|
Работает почти всё, кроме Notification API в некоторых браузерах при `file://`.
|
||||||
|
|
||||||
|
**Вариант Б — локальный сервер (рекомендуется):**
|
||||||
|
```bash
|
||||||
|
cd kuzbaeva
|
||||||
|
python3 -m http.server 8000
|
||||||
|
# открыть http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Деплой (опционально, 5 минут):**
|
||||||
|
- GitHub Pages: `git push` в ветку `main`, включить Pages в Settings
|
||||||
|
- или Netlify drag-and-drop папки
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Чек-лист реализации (порядок на час)
|
||||||
|
|
||||||
|
1. `index.html` каркас + 6 секций — 10 мин
|
||||||
|
2. `style.css` минимальный (флекс, крупные шрифты, mobile-first) — 10 мин
|
||||||
|
3. `data.js` заполнить — 5 мин
|
||||||
|
4. `app.js` рендер семьи + гида — 10 мин
|
||||||
|
5. `countdown.js` — 5 мин
|
||||||
|
6. `checklist.js` + localStorage — 10 мин
|
||||||
|
7. `auth.js` парольная модалка — 5 мин
|
||||||
|
8. `reminders.js` Notification API — 10 мин
|
||||||
|
9. Тест в браузере — 5 мин
|
||||||
|
|
||||||
|
Итого ~70 мин. Фото подложить заглушками (`https://placehold.co/300x300`) если своих нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Что НЕ делаем в MVP
|
||||||
|
|
||||||
|
- Service Worker / Push (уведомления в закрытой вкладке)
|
||||||
|
- Бэкенд, БД, регистрация пользователей
|
||||||
|
- Реальная криптография пароля
|
||||||
|
- Загрузка фото через UI (только подкладывать файлы в `img/`)
|
||||||
|
- Адаптация под все браузеры — целимся в Chrome/Safari актуальных версий
|
||||||
|
- Тесты, линтеры, сборка
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Риски
|
||||||
|
|
||||||
|
| Риск | Митигация |
|
||||||
|
|------|-----------|
|
||||||
|
| Notification API не работает с `file://` | использовать `python3 -m http.server` |
|
||||||
|
| iOS Safari блокирует уведомления в обычной вкладке | документировать в README; для iOS нужен PWA + push (вне MVP) |
|
||||||
|
| Пароль виден в исходниках | принять — PRD допускает |
|
||||||
|
| Дата вылета пройдёт → счётчик уйдёт в минус | вывести «Поездка началась/завершилась» когда `days <= 0` |
|
||||||
BIN
WhatsApp Image 2026-05-22 at 09.31.52.jpeg
Normal file
BIN
WhatsApp Image 2026-05-22 at 09.31.52.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
825
index.html
Normal file
825
index.html
Normal file
@ -0,0 +1,825 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Семейный сайт Жанар</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-1: #f97316;
|
||||||
|
--bg-2: #14b8a6;
|
||||||
|
--bg-3: #e11d48;
|
||||||
|
--bg-base: #0c1733;
|
||||||
|
--fg: #f8fafc;
|
||||||
|
--muted: #a5b4cb;
|
||||||
|
--accent: #a5b4fc;
|
||||||
|
--accent-2: #f9a8d4;
|
||||||
|
--accent-3: #67e8f9;
|
||||||
|
--accent-soft: rgba(165, 180, 252, 0.16);
|
||||||
|
--card: rgba(255, 255, 255, 0.04);
|
||||||
|
--card-solid: rgba(255, 255, 255, 0.06);
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.16);
|
||||||
|
--radius: 20px;
|
||||||
|
--radius-sm: 12px;
|
||||||
|
--shadow: 0 1px 2px rgba(0,0,0,0.2), 0 12px 32px rgba(0,0,0,0.35);
|
||||||
|
--shadow-lg: 0 4px 12px rgba(0,0,0,0.25), 0 24px 60px rgba(0,0,0,0.45);
|
||||||
|
--grad: linear-gradient(135deg, #a78bfa 0%, #f472b6 35%, #fb923c 65%, #facc15 100%);
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
font-feature-settings: "cv11", "ss01";
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 16px; line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background:
|
||||||
|
radial-gradient(1000px 700px at 10% -10%, var(--bg-1) 0%, transparent 55%),
|
||||||
|
radial-gradient(900px 600px at 100% 10%, var(--bg-2) 0%, transparent 55%),
|
||||||
|
radial-gradient(1100px 800px at 50% 115%, var(--bg-3) 0%, transparent 60%),
|
||||||
|
radial-gradient(700px 500px at 75% 55%, rgba(250, 204, 21, 0.18) 0%, transparent 65%),
|
||||||
|
var(--bg-base);
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 780px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 96px;
|
||||||
|
}
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
padding: 4px 4px 0;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-family: 'Manrope', 'Inter', sans-serif;
|
||||||
|
font-size: 2rem; font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--grad);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
color: transparent;
|
||||||
|
animation: shimmer 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0%, 100% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-family: 'Manrope', 'Inter', sans-serif;
|
||||||
|
font-size: 1.15rem; font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--fg);
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
background: var(--card);
|
||||||
|
backdrop-filter: blur(20px) saturate(140%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: box-shadow .25s ease, transform .25s ease;
|
||||||
|
}
|
||||||
|
section:hover { box-shadow: var(--shadow-lg); }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: 0.82rem; }
|
||||||
|
.error { color: #dc2626; }
|
||||||
|
button {
|
||||||
|
background: var(--grad);
|
||||||
|
color: white; border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 11px 20px;
|
||||||
|
font-size: 0.95rem; font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.25);
|
||||||
|
transition: transform .15s ease, box-shadow .2s ease, opacity .15s;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
button:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(99, 102, 241, 0.35); }
|
||||||
|
button:active { transform: translateY(0); }
|
||||||
|
button.link {
|
||||||
|
background: none; color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
button.link:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; }
|
||||||
|
.card {
|
||||||
|
background: var(--card-solid);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 18px 14px;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform .2s ease, box-shadow .2s ease, border-color .2s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card::before {
|
||||||
|
content: ""; position: absolute; inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(99,102,241,0.05), rgba(236,72,153,0.04));
|
||||||
|
opacity: 0; transition: opacity .25s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 16px 36px rgba(0,0,0,0.4);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
.card:hover::before { opacity: 1; }
|
||||||
|
.card > * { position: relative; }
|
||||||
|
.card .photo-wrap { position: relative; display: inline-block; cursor: pointer; }
|
||||||
|
.card img {
|
||||||
|
width: 96px; height: 96px;
|
||||||
|
object-fit: cover; border-radius: 50%;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
display: block;
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.4), 0 0 0 2px rgba(255,255,255,0.08), 0 0 0 4px rgba(129,140,248,0.35);
|
||||||
|
}
|
||||||
|
.card .photo-wrap::after {
|
||||||
|
content: "📷 заменить";
|
||||||
|
position: absolute; left: 0; right: 0; bottom: 10px;
|
||||||
|
background: rgba(15,23,42,0.7);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem; font-weight: 500;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: 0 0 50% 50%;
|
||||||
|
opacity: 0; transition: opacity .2s;
|
||||||
|
}
|
||||||
|
.card .photo-wrap:hover::after { opacity: 1; }
|
||||||
|
.card.wide { grid-column: 1 / -1; }
|
||||||
|
.card.wide img {
|
||||||
|
width: 100%; height: 260px;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 12px 32px rgba(0,0,0,0.45);
|
||||||
|
}
|
||||||
|
.card.wide .photo-wrap { display: block; }
|
||||||
|
.card.wide .photo-wrap::after { border-radius: 0 0 14px 14px; bottom: 0; font-size: 0.85rem; padding: 8px 0; }
|
||||||
|
.card .file-input { display: none; }
|
||||||
|
.card .name { font-weight: 700; font-size: 1rem; letter-spacing: -0.01em; }
|
||||||
|
.card .role { color: var(--muted); font-size: 0.82rem; margin-top: 2px; }
|
||||||
|
.card .about { font-size: 0.85rem; margin-top: 8px; color: var(--fg); opacity: 0.85; }
|
||||||
|
.ig {
|
||||||
|
margin-top: 18px; padding: 14px 16px;
|
||||||
|
background: linear-gradient(135deg, rgba(244,114,182,0.14), rgba(129,140,248,0.12));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.ig a {
|
||||||
|
color: var(--accent-2); font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.ig a:hover { text-decoration: underline; }
|
||||||
|
.big-counter {
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 3.5rem; font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0 8px;
|
||||||
|
background: var(--grad);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
color: transparent;
|
||||||
|
animation: shimmer 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#trip-dates, #trip-weather { text-align: center; margin: 6px 0; font-size: 0.9rem; }
|
||||||
|
.cat {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
.cat h3 {
|
||||||
|
margin: 0 0 10px; font-size: 0.92rem;
|
||||||
|
font-weight: 600; color: var(--accent);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.cat ul { list-style: none; padding-left: 0; margin: 0; }
|
||||||
|
.cat li {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 0.93rem;
|
||||||
|
}
|
||||||
|
.cat input[type="checkbox"] {
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cat label { cursor: pointer; flex: 1; }
|
||||||
|
.cat label.done { text-decoration: line-through; color: var(--muted); opacity: 0.6; }
|
||||||
|
#guide-table {
|
||||||
|
width: 100%; border-collapse: separate; border-spacing: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
#guide-table th, #guide-table td {
|
||||||
|
text-align: left; padding: 12px 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
#guide-table th {
|
||||||
|
color: var(--muted); font-weight: 600;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
background: rgba(129,140,248,0.12);
|
||||||
|
position: sticky; top: 0;
|
||||||
|
}
|
||||||
|
#guide-table tbody tr { transition: background .15s; }
|
||||||
|
#guide-table tbody tr:hover { background: rgba(129,140,248,0.08); }
|
||||||
|
#guide-table a { color: var(--accent); text-decoration: none; }
|
||||||
|
#guide-table a:hover { text-decoration: underline; }
|
||||||
|
#auth-dialog {
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
max-width: 360px;
|
||||||
|
color: var(--fg);
|
||||||
|
background: rgba(20, 20, 35, 0.85);
|
||||||
|
backdrop-filter: blur(24px) saturate(140%);
|
||||||
|
box-shadow: 0 25px 80px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
#auth-dialog::backdrop {
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
#auth-dialog h2 { margin-top: 0; font-size: 1.3rem; }
|
||||||
|
#auth-dialog input {
|
||||||
|
width: 100%; padding: 12px 14px;
|
||||||
|
font-size: 1rem; font-family: inherit;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1.5px solid var(--border-strong);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 10px 0 16px;
|
||||||
|
transition: border-color .15s, box-shadow .15s, background .15s;
|
||||||
|
}
|
||||||
|
#auth-dialog input::placeholder { color: var(--muted); }
|
||||||
|
#auth-dialog input:focus {
|
||||||
|
outline: none;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 4px rgba(129,140,248,0.2);
|
||||||
|
}
|
||||||
|
#auth-dialog button { width: 100%; }
|
||||||
|
footer {
|
||||||
|
text-align: center; margin-top: 32px;
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
@media (max-width: 540px) {
|
||||||
|
main { padding: 20px 14px 80px; }
|
||||||
|
section { padding: 18px; border-radius: 18px; }
|
||||||
|
.big-counter { font-size: 2.6rem; }
|
||||||
|
h1 { font-size: 1.5rem; }
|
||||||
|
h2 { font-size: 1.05rem; }
|
||||||
|
#guide-table th, #guide-table td { padding: 10px 6px; font-size: 0.82rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<dialog id="auth-dialog">
|
||||||
|
<form id="auth-form" method="dialog">
|
||||||
|
<h2>Личный семейный портал</h2>
|
||||||
|
<p>Введите пароль:</p>
|
||||||
|
<input type="password" id="auth-input" autocomplete="current-password" required />
|
||||||
|
<button type="submit">Войти</button>
|
||||||
|
<p id="auth-error" class="error hidden">Неверный пароль</p>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<main id="main" class="hidden">
|
||||||
|
<header>
|
||||||
|
<h1>🏠 Наша семья</h1>
|
||||||
|
<button id="logout" class="link">Выйти</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="family">
|
||||||
|
<h2>Кто мы</h2>
|
||||||
|
<div id="family-list" class="cards"></div>
|
||||||
|
<p class="ig">
|
||||||
|
📷 Instagram:
|
||||||
|
<a id="ig-link" target="_blank" rel="noopener"></a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="countdown">
|
||||||
|
<h2>⏳ До поездки в Батуми</h2>
|
||||||
|
<div id="countdown-box" class="big-counter">…</div>
|
||||||
|
<p id="trip-dates" class="muted"></p>
|
||||||
|
<p id="trip-weather" class="muted"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="reminders">
|
||||||
|
<h2>🔔 Напоминания</h2>
|
||||||
|
<p>Шаги (будни 5 000, выходные 10 000) — в 12:00.<br/>Instagram — раз в неделю, воскресенье 11:00.</p>
|
||||||
|
<button id="enable-notifications">Включить уведомления</button>
|
||||||
|
<span id="notif-status" class="muted"></span>
|
||||||
|
<p class="muted small">⚠️ Уведомления приходят только пока вкладка открыта. Для пуша когда сайт закрыт нужен Service Worker — это следующий шаг.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="checklist">
|
||||||
|
<h2>🧳 Чек-лист поездки</h2>
|
||||||
|
<div id="checklist-list"></div>
|
||||||
|
<button id="reset-checklist" class="link">Сбросить все галочки</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="guide">
|
||||||
|
<h2>🗺️ Гид по Батуми</h2>
|
||||||
|
<p class="muted small">Отметь галочкой посещённые места.</p>
|
||||||
|
<table id="guide-table">
|
||||||
|
<thead><tr><th>✓</th><th>#</th><th>Место</th><th>Описание</th><th>Адрес</th><th>Время</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
<p class="small"><span id="guide-progress" class="muted"></span> · <button id="reset-guide" class="link">Сбросить отметки</button></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p class="muted small">Создано с помощью Claude · Май 2026</p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ===== DATA ===== */
|
||||||
|
const FAMILY = {
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: "Жанар",
|
||||||
|
role: "мама, 37 лет",
|
||||||
|
photo: "zhanara.jpg",
|
||||||
|
about: "Главный пользователь портала. Любит путешествия и заботится о семье."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Азамат",
|
||||||
|
role: "папа, 39 лет",
|
||||||
|
photo: "https://placehold.co/200x200/ffe8d6/d97b3a?text=%D0%90",
|
||||||
|
about: "Работает, помогает планировать семейные поездки."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Наши дети",
|
||||||
|
role: "трое — старшая, младшая и малыш",
|
||||||
|
photo: "WhatsApp Image 2026-05-22 at 09.31.52.jpeg",
|
||||||
|
about: "Любимые сердечки нашей семьи.",
|
||||||
|
wide: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
instagram: "https://instagram.com/zhanara2964",
|
||||||
|
instagramHandle: "@zhanara2964"
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRIP = {
|
||||||
|
destination: "Батуми, Грузия",
|
||||||
|
departure: "2026-06-22",
|
||||||
|
return: "2026-06-29",
|
||||||
|
weather: "☀️ Днём +23…+25°C, ночью +21°C · влажность 80% · возможны короткие дожди · море +22…+23°C"
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHECKLIST = [
|
||||||
|
{ category: "👕 Одежда", items: [
|
||||||
|
"Лёгкие платья / шорты / футболки (3–4 комплекта)",
|
||||||
|
"Лёгкий кардиган или ветровка",
|
||||||
|
"Дождевик или компактный зонт",
|
||||||
|
"Нарядный образ для ресторана"
|
||||||
|
]},
|
||||||
|
{ category: "🏖 Пляж", items: [
|
||||||
|
"Купальник / плавки (2 шт)",
|
||||||
|
"Шлёпанцы для пляжа",
|
||||||
|
"Полотенце"
|
||||||
|
]},
|
||||||
|
{ category: "👟 Обувь", items: [
|
||||||
|
"Сандалии",
|
||||||
|
"Удобные кроссовки для прогулок"
|
||||||
|
]},
|
||||||
|
{ category: "🕶 Аксессуары", items: [
|
||||||
|
"Солнцезащитные очки",
|
||||||
|
"Шляпа или панама",
|
||||||
|
"Солнцезащитный крем SPF 50"
|
||||||
|
]},
|
||||||
|
{ category: "📄 Документы и деньги", items: [
|
||||||
|
"Паспорта (все члены семьи)",
|
||||||
|
"Билеты / брони отелей (распечатать)",
|
||||||
|
"Банковские карты + немного налички",
|
||||||
|
"Медицинская страховка"
|
||||||
|
]}
|
||||||
|
];
|
||||||
|
|
||||||
|
const GUIDE = [
|
||||||
|
{ name: "Приморский бульвар", desc: "Главная набережная города, старейшая достопримечательность — гулять обязательно.",
|
||||||
|
addr: "Батуми, набережная Чёрного моря (центр города, 7 км вдоль берега)",
|
||||||
|
hours: "круглосуточно, лучше на закате (19:00–21:00)" },
|
||||||
|
{ name: "Скульптура «Али и Нино»", desc: "Знаменитые движущиеся фигуры влюблённых на набережной.",
|
||||||
|
addr: "Приморский бульвар, у площади Чудес, рядом с Алфавитной башней",
|
||||||
|
hours: "движение каждые 10 мин, 19:00 — лучший вид" },
|
||||||
|
{ name: "Алфавитная башня", desc: "Архитектурная икона Батуми, высокая башня в форме ДНК.",
|
||||||
|
addr: "ул. Шерифа Химшиашвили, 1, Приморский парк",
|
||||||
|
hours: "пн–вс 11:00–23:00, кафе наверху до 22:00" },
|
||||||
|
{ name: "Колесо обозрения", desc: "Вид на море с высоты, ~10 лари, у порта.",
|
||||||
|
addr: "Приморский бульвар, рядом с Морским портом",
|
||||||
|
hours: "пн–вс 12:00–24:00" },
|
||||||
|
{ name: "Ботанический сад", desc: "Огромный сад с видом на море, тропические растения.",
|
||||||
|
addr: "пос. Мцване Концхи (Зелёный мыс), 9 км к северу от Батуми",
|
||||||
|
hours: "пн–вс 09:00–20:00 (касса до 19:00), ~3 ч на осмотр" },
|
||||||
|
{ name: "Старый город", desc: "Исторические улицы, мечеть, атмосфера.",
|
||||||
|
addr: "район Старого Батуми, между ул. Мемед Абашидзе и набережной",
|
||||||
|
hours: "круглосуточно, оптимально 17:00–22:00" },
|
||||||
|
{ name: "Пещера Прометея", desc: "Экскурсия ~1,5 ч от Батуми, сталактиты, подземная река.",
|
||||||
|
addr: "с. Кумистави, Цхалтубский муниципалитет (~140 км от Батуми, трасса на Кутаиси)",
|
||||||
|
hours: "пн–вс 10:00–18:00 (вт выходной), экскурсии каждые 30 мин" },
|
||||||
|
{ name: "Каньон Мартвили", desc: "Природный каньон, лодки по воде, очень красиво.",
|
||||||
|
addr: "с. Гачедили, Мартвильский муниципалитет (~135 км от Батуми)",
|
||||||
|
hours: "пн–вс 10:00–18:00, заплыв на лодке 15 мин" },
|
||||||
|
{ name: "Водопад Махунцети", desc: "Природная достопримечательность за городом.",
|
||||||
|
addr: "с. Махунцети, Кеда (~30 км к востоку от Батуми, трасса Батуми–Ахалцихе)",
|
||||||
|
hours: "круглосуточно, лучше до 12:00 пока мало людей" },
|
||||||
|
{ name: "Фонтан Нептуна", desc: "Театральная площадь, копия итальянского фонтана.",
|
||||||
|
addr: "Театральная площадь, Старый город, у драмтеатра им. Чавчавадзе",
|
||||||
|
hours: "круглосуточно, подсветка после 20:00" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const REMINDERS = {
|
||||||
|
weekdaySteps: { time: "12:00", text: "Пройти 5 000 шагов сегодня" },
|
||||||
|
weekendSteps: { time: "12:00", text: "Пройти 10 000 шагов сегодня" },
|
||||||
|
instagram: { day: 0, time: "11:00", text: "Опубликовать одну историю в Instagram (@zhanara2964)" }
|
||||||
|
};
|
||||||
|
|
||||||
|
const PASSWORD = "zhanar2026";
|
||||||
|
|
||||||
|
/* ===== AUTH ===== */
|
||||||
|
(function () {
|
||||||
|
const dialog = document.getElementById("auth-dialog");
|
||||||
|
const form = document.getElementById("auth-form");
|
||||||
|
const input = document.getElementById("auth-input");
|
||||||
|
const error = document.getElementById("auth-error");
|
||||||
|
const main = document.getElementById("main");
|
||||||
|
const logout = document.getElementById("logout");
|
||||||
|
|
||||||
|
function unlock() {
|
||||||
|
main.classList.remove("hidden");
|
||||||
|
if (dialog.open) dialog.close();
|
||||||
|
}
|
||||||
|
function lock() {
|
||||||
|
main.classList.add("hidden");
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
input.value = "";
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem("auth") === "1") {
|
||||||
|
unlock();
|
||||||
|
} else {
|
||||||
|
lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (input.value === PASSWORD) {
|
||||||
|
localStorage.setItem("auth", "1");
|
||||||
|
error.classList.add("hidden");
|
||||||
|
unlock();
|
||||||
|
window.dispatchEvent(new Event("auth:unlocked"));
|
||||||
|
} else {
|
||||||
|
error.classList.remove("hidden");
|
||||||
|
input.value = "";
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logout.addEventListener("click", function () {
|
||||||
|
localStorage.removeItem("auth");
|
||||||
|
lock();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ===== COUNTDOWN ===== */
|
||||||
|
(function () {
|
||||||
|
const box = document.getElementById("countdown-box");
|
||||||
|
const dates = document.getElementById("trip-dates");
|
||||||
|
const wx = document.getElementById("trip-weather");
|
||||||
|
|
||||||
|
function daysBetween(from, to) {
|
||||||
|
const ms = to.setHours(0,0,0,0) - from.setHours(0,0,0,0);
|
||||||
|
return Math.round(ms / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
function fmt(dateStr) {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
function plural(n, one, few, many) {
|
||||||
|
const mod10 = n % 10;
|
||||||
|
const mod100 = n % 100;
|
||||||
|
if (mod10 === 1 && mod100 !== 11) return one;
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few;
|
||||||
|
return many;
|
||||||
|
}
|
||||||
|
window.renderCountdown = function () {
|
||||||
|
const today = new Date();
|
||||||
|
const dep = new Date(TRIP.departure);
|
||||||
|
const ret = new Date(TRIP.return);
|
||||||
|
const daysToDep = daysBetween(new Date(today), new Date(dep));
|
||||||
|
const daysToRet = daysBetween(new Date(today), new Date(ret));
|
||||||
|
|
||||||
|
if (daysToDep > 0) {
|
||||||
|
box.textContent = `${daysToDep} ${plural(daysToDep, "день","дня","дней")}`;
|
||||||
|
} else if (daysToRet >= 0) {
|
||||||
|
box.textContent = "🌴 Вы в Батуми!";
|
||||||
|
} else {
|
||||||
|
box.textContent = "✈️ Поездка завершена";
|
||||||
|
}
|
||||||
|
dates.textContent = `Вылет ${fmt(TRIP.departure)} → Возвращение ${fmt(TRIP.return)} · ${TRIP.destination}`;
|
||||||
|
wx.textContent = TRIP.weather;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ===== CHECKLIST ===== */
|
||||||
|
(function () {
|
||||||
|
const root = document.getElementById("checklist-list");
|
||||||
|
const reset = document.getElementById("reset-checklist");
|
||||||
|
const KEY = "checklist-state";
|
||||||
|
|
||||||
|
function loadState() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(KEY) || "{}"); }
|
||||||
|
catch (e) { return {}; }
|
||||||
|
}
|
||||||
|
function saveState(state) {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.renderChecklist = function () {
|
||||||
|
const state = loadState();
|
||||||
|
root.innerHTML = "";
|
||||||
|
CHECKLIST.forEach(function (cat, ci) {
|
||||||
|
const block = document.createElement("div");
|
||||||
|
block.className = "cat";
|
||||||
|
const title = document.createElement("h3");
|
||||||
|
title.textContent = cat.category;
|
||||||
|
block.appendChild(title);
|
||||||
|
const ul = document.createElement("ul");
|
||||||
|
cat.items.forEach(function (item, ii) {
|
||||||
|
const id = `c-${ci}-${ii}`;
|
||||||
|
const li = document.createElement("li");
|
||||||
|
const cb = document.createElement("input");
|
||||||
|
cb.type = "checkbox";
|
||||||
|
cb.id = id;
|
||||||
|
cb.checked = !!state[id];
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.htmlFor = id;
|
||||||
|
label.textContent = item;
|
||||||
|
if (cb.checked) label.classList.add("done");
|
||||||
|
cb.addEventListener("change", function () {
|
||||||
|
const s = loadState();
|
||||||
|
s[id] = cb.checked;
|
||||||
|
saveState(s);
|
||||||
|
label.classList.toggle("done", cb.checked);
|
||||||
|
});
|
||||||
|
li.appendChild(cb);
|
||||||
|
li.appendChild(label);
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
block.appendChild(ul);
|
||||||
|
root.appendChild(block);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
reset.addEventListener("click", function () {
|
||||||
|
if (confirm("Сбросить все отметки в чек-листе?")) {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
window.renderChecklist();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ===== REMINDERS ===== */
|
||||||
|
(function () {
|
||||||
|
const btn = document.getElementById("enable-notifications");
|
||||||
|
const status = document.getElementById("notif-status");
|
||||||
|
const FIRED_KEY = "reminders-fired";
|
||||||
|
|
||||||
|
function todayKey() { return new Date().toISOString().slice(0, 10); }
|
||||||
|
|
||||||
|
function firedToday(tag) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(localStorage.getItem(FIRED_KEY) || "{}");
|
||||||
|
return data[todayKey()] && data[todayKey()][tag];
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
function markFired(tag) {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(localStorage.getItem(FIRED_KEY) || "{}"); }
|
||||||
|
catch (e) { data = {}; }
|
||||||
|
const k = todayKey();
|
||||||
|
Object.keys(data).forEach(function (d) { if (d !== k) delete data[d]; });
|
||||||
|
data[k] = data[k] || {};
|
||||||
|
data[k][tag] = true;
|
||||||
|
localStorage.setItem(FIRED_KEY, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
function updateStatus() {
|
||||||
|
if (!("Notification" in window)) {
|
||||||
|
status.textContent = "не поддерживается в этом браузере";
|
||||||
|
btn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = Notification.permission;
|
||||||
|
if (p === "granted") {
|
||||||
|
status.textContent = "✅ включены";
|
||||||
|
btn.textContent = "Уведомления включены";
|
||||||
|
btn.disabled = true;
|
||||||
|
} else if (p === "denied") {
|
||||||
|
status.textContent = "❌ заблокированы в браузере";
|
||||||
|
btn.disabled = true;
|
||||||
|
} else {
|
||||||
|
status.textContent = "не запрошены";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function checkAndFire() {
|
||||||
|
if (Notification.permission !== "granted") return;
|
||||||
|
const now = new Date();
|
||||||
|
const hhmm = now.toTimeString().slice(0, 5);
|
||||||
|
const dow = now.getDay();
|
||||||
|
const isWeekend = dow === 0 || dow === 6;
|
||||||
|
|
||||||
|
const steps = isWeekend ? REMINDERS.weekendSteps : REMINDERS.weekdaySteps;
|
||||||
|
if (hhmm === steps.time && !firedToday("steps")) {
|
||||||
|
new Notification("Шаги", { body: steps.text });
|
||||||
|
markFired("steps");
|
||||||
|
}
|
||||||
|
const ig = REMINDERS.instagram;
|
||||||
|
if (dow === ig.day && hhmm === ig.time && !firedToday("ig")) {
|
||||||
|
new Notification("Instagram", { body: ig.text });
|
||||||
|
markFired("ig");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
if (!("Notification" in window)) return;
|
||||||
|
Notification.requestPermission().then(function (p) {
|
||||||
|
updateStatus();
|
||||||
|
if (p === "granted") {
|
||||||
|
new Notification("Уведомления включены", { body: "Я буду напоминать о шагах и Instagram." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
updateStatus();
|
||||||
|
setInterval(checkAndFire, 60_000);
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ===== APP (render) ===== */
|
||||||
|
(function () {
|
||||||
|
function renderFamily() {
|
||||||
|
const root = document.getElementById("family-list");
|
||||||
|
root.innerHTML = "";
|
||||||
|
const PHOTO_KEY = "family-photos";
|
||||||
|
const stored = (function () {
|
||||||
|
try { return JSON.parse(localStorage.getItem(PHOTO_KEY) || "{}"); }
|
||||||
|
catch (e) { return {}; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
FAMILY.members.forEach(function (m, idx) {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "card" + (m.wide ? " wide" : "");
|
||||||
|
const slot = "m" + idx;
|
||||||
|
const src = stored[slot] || m.photo;
|
||||||
|
card.innerHTML = `
|
||||||
|
<label class="photo-wrap" title="Кликните, чтобы загрузить фото">
|
||||||
|
<img src="${src}" alt="${m.name}" onerror="this.src='https://placehold.co/200x200?text=%3F'" />
|
||||||
|
<input type="file" class="file-input" accept="image/*" data-slot="${slot}" />
|
||||||
|
</label>
|
||||||
|
<div class="name">${m.name}</div>
|
||||||
|
<div class="role">${m.role}</div>
|
||||||
|
${m.about ? `<div class="about">${m.about}</div>` : ""}
|
||||||
|
`;
|
||||||
|
root.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelectorAll(".file-input").forEach(function (input) {
|
||||||
|
input.addEventListener("change", function (e) {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const slot = input.dataset.slot;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function (ev) {
|
||||||
|
const dataUrl = ev.target.result;
|
||||||
|
resizeImage(dataUrl, 600, function (small) {
|
||||||
|
const s = JSON.parse(localStorage.getItem(PHOTO_KEY) || "{}");
|
||||||
|
s[slot] = small;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PHOTO_KEY, JSON.stringify(s));
|
||||||
|
const img = input.parentElement.querySelector("img");
|
||||||
|
img.src = small;
|
||||||
|
} catch (err) {
|
||||||
|
alert("Не удалось сохранить фото (превышен лимит хранилища). Попробуйте фото меньшего размера.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const ig = document.getElementById("ig-link");
|
||||||
|
ig.href = FAMILY.instagram;
|
||||||
|
ig.textContent = FAMILY.instagramHandle || FAMILY.instagram;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeImage(dataUrl, maxSide, cb) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = function () {
|
||||||
|
const scale = Math.min(1, maxSide / Math.max(img.width, img.height));
|
||||||
|
const w = Math.round(img.width * scale);
|
||||||
|
const h = Math.round(img.height * scale);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = w; canvas.height = h;
|
||||||
|
canvas.getContext("2d").drawImage(img, 0, 0, w, h);
|
||||||
|
cb(canvas.toDataURL("image/jpeg", 0.85));
|
||||||
|
};
|
||||||
|
img.src = dataUrl;
|
||||||
|
}
|
||||||
|
function getGuideVisited() {
|
||||||
|
try { return JSON.parse(localStorage.getItem("guide-visited") || "{}"); }
|
||||||
|
catch (e) { return {}; }
|
||||||
|
}
|
||||||
|
function setGuideVisited(state) {
|
||||||
|
localStorage.setItem("guide-visited", JSON.stringify(state));
|
||||||
|
}
|
||||||
|
function updateGuideProgress() {
|
||||||
|
const v = getGuideVisited();
|
||||||
|
const done = GUIDE.filter((_, i) => v[i]).length;
|
||||||
|
const el = document.getElementById("guide-progress");
|
||||||
|
if (el) el.textContent = `Посещено: ${done} из ${GUIDE.length}`;
|
||||||
|
}
|
||||||
|
function renderGuide() {
|
||||||
|
const tbody = document.querySelector("#guide-table tbody");
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
const visited = getGuideVisited();
|
||||||
|
GUIDE.forEach(function (g, i) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const mapUrl = "https://maps.google.com/?q=" + encodeURIComponent(g.name + ", Batumi, Georgia");
|
||||||
|
const checked = visited[i] ? "checked" : "";
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><input type="checkbox" data-guide-idx="${i}" ${checked} style="width:18px;height:18px;accent-color:var(--accent);cursor:pointer;" /></td>
|
||||||
|
<td>${i + 1}</td>
|
||||||
|
<td><strong>${g.name}</strong></td>
|
||||||
|
<td>${g.desc}</td>
|
||||||
|
<td>${g.addr || ""} <a href="${mapUrl}" target="_blank" rel="noopener" title="Открыть в Google Maps">🗺️</a></td>
|
||||||
|
<td class="small muted">${g.hours || ""}</td>
|
||||||
|
`;
|
||||||
|
if (visited[i]) tr.style.opacity = "0.55";
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll('input[type="checkbox"][data-guide-idx]').forEach(function (cb) {
|
||||||
|
cb.addEventListener("change", function () {
|
||||||
|
const idx = cb.getAttribute("data-guide-idx");
|
||||||
|
const state = getGuideVisited();
|
||||||
|
if (cb.checked) state[idx] = true; else delete state[idx];
|
||||||
|
setGuideVisited(state);
|
||||||
|
cb.closest("tr").style.opacity = cb.checked ? "0.55" : "1";
|
||||||
|
updateGuideProgress();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
updateGuideProgress();
|
||||||
|
const resetBtn = document.getElementById("reset-guide");
|
||||||
|
if (resetBtn && !resetBtn.dataset.bound) {
|
||||||
|
resetBtn.dataset.bound = "1";
|
||||||
|
resetBtn.addEventListener("click", function () {
|
||||||
|
if (!confirm("Сбросить все отметки посещённых мест?")) return;
|
||||||
|
setGuideVisited({});
|
||||||
|
renderGuide();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderAll() {
|
||||||
|
renderFamily();
|
||||||
|
renderGuide();
|
||||||
|
if (window.renderCountdown) window.renderCountdown();
|
||||||
|
if (window.renderChecklist) window.renderChecklist();
|
||||||
|
}
|
||||||
|
if (localStorage.getItem("auth") === "1") renderAll();
|
||||||
|
window.addEventListener("auth:unlocked", renderAll);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
zhanara.jpg
Normal file
BIN
zhanara.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Reference in New Issue
Block a user