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:
kuzbaeva 2026-05-22 12:59:50 +05:00
parent fa0c89ed69
commit 24136595b7
6 changed files with 1161 additions and 0 deletions

103
PRD.md
View File

@ -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
View File

@ -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` |

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

825
index.html Normal file
View 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
kids.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
zhanara.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB