feat: implement shared-album multi-user photo album

Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client
(path-style, region garage), embedded golang-migrate migrations, JWT auth with
refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix,
photo upload + async imaging/goexif WebP variants, cursor pagination, presigned
URLs, likes/comments, SSE hub (album + personal), notifications, share links.

Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh,
auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with
reconnect + REST catch-up, three themes + system via data-theme.

Deploy without Docker: Makefile builds the frontend into backend/static and
embeds it (go:embed) into a single statically linked binary; systemd unit and
nginx SSE example included.

Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep
CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
This commit is contained in:
iu
2026-10-10 12:50:33 +03:00
commit 2b2b342492
105 changed files with 12494 additions and 0 deletions
+625
View File
@@ -0,0 +1,625 @@
# Проект: Многопользовательский фотоальбом (galo)
## 1. Цель
Веб-приложение, где несколько пользователей создают общие фотоальбомы,
загружают, просматривают, комментируют и организуют фотографии в реальном времени
с разграничением прав доступа.
**Целевая аудитория:** семьи и друзья (отпуск, свадьбы, праздники), рабочие команды
(мероприятия, стройка), фотографы и их клиенты.
**Проблема, которую решаем:** фото разбросаны по мессенджерам и галереям участников,
теряется авторство и контекст, нет единого пространства с понятными правами.
## 2. Инфраструктура (уже существует)
- **S3:** `s3.localnet` — **Garage** (S3-совместимое хранилище, self-hosted)
- **PostgreSQL:** `pg.localnet:5432` — БД, пользователь, пароль в env
- Локальные Postgres и MinIO **не поднимаем** — только подключаемся к существующим
### Ключевые особенности Garage
- **Path-style обязателен** — `UsePathStyle: true`, виртуальный хостинг (`{bucket}.{host}`) не используется
- **Регион:** `garage` (не стандартный `us-east-1`)
- **Access Key ID** начинается с `GK...`
- **Presigned URL** поддерживаются и являются предпочтительным способом отдачи файлов
## 3. Стек
### Backend: Go 1.22+
- HTTP: **Gin** (или Fiber)
- БД: **PostgreSQL** через `pgx` (подключение к `pg.localnet`)
- Хранилище: **S3 через AWS SDK v2** (подключение к Garage на `s3.localnet`, path-style)
- Real-time: **Server-Sent Events (SSE)** — нативный `net/http` + `http.Flusher`
- Аутентификация: **JWT** (access 15 мин + refresh 30 дней)
- Миграции: **golang-migrate**
- Обработка изображений: **imaging** или **bimg** (превью + EXIF)
- Логирование: **slog** (структурированные логи)
### Frontend: SvelteKit 2 + Svelte 5
- **adapter-static** — сборка в статику, раздаётся Go-бинарником через `embed.FS`
- **TypeScript**
- **TailwindCSS** — utility-first, семантические токены через CSS-переменные
- Real-time: **нативный `EventSource`** с обёрткой для reconnect и backoff
- Только UI — вся логика на Go, обращение через `fetch` к `/api/*`
### Обоснование выбора
- **Svelte** оправдан: маленькие бандлы (важно для галереи фото), простота кода,
зрелость (Thoughtworks перевёл в «Adopt»), идеально как UI-слой над Go API
- **Tailwind** оправдан: скорость разработки UI, автоматическая консистентность,
5–20 КБ gzip в продакшене, отлично гасится компонентным подходом Svelte
- **SSE вместо WebSocket**: односторонний поток server→client полностью закрывает
задачу (новые фото, комментарии, лайки); работает поверх HTTP/1.1 и HTTP/2,
проходит через прокси, встроен в `net/http` без внешних либ
## 4. Запуск и деплой (без Docker)
### Принципы
- **Никаких контейнеров.** Backend — нативный Go-бинарник. Frontend — статика,
вшитая в бинарник через `embed.FS`. Никакого Docker, docker-compose, Kubernetes.
- **Процесс-менеджер:** systemd (unit для backend).
- **Сборка:** Go-тулчейн на сборочной машине, Node.js только на этапе сборки
фронтенда (в CI или локально), в продакшене Node.js не нужен.
- **Статика фронтенда** копируется в `backend/static/` и попадает в бинарник
через `//go:embed static`.
### Сборка backend
```bash
cd backend
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ../bin/shared-album ./cmd/server
```
### Сборка frontend
```bash
cd frontend
npm ci
npm run build # adapter-static → frontend/build/
rm -rf ../backend/static/*
cp -r build/* ../backend/static/
```
### Порядок сборки
1. Собрать frontend (`npm run build`)
2. Скопировать `build/*` в `backend/static/`
3. Собрать backend (`go build`) — статика вшивается в бинарник
4. Положить бинарник на сервер и запустить через systemd
### systemd unit `/etc/systemd/system/shared-album.service`
```ini
[Unit]
Description=Shared Album backend
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=album
Group=album
WorkingDirectory=/opt/shared-album
EnvironmentFile=/opt/shared-album/.env
ExecStart=/opt/shared-album/shared-album
Restart=on-failure
RestartSec=5s
# Безопасность
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/shared-album
[Install]
WantedBy=multi-user.target
```
Активация:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now shared-album
sudo journalctl -u shared-album -f
```
### Прокси (опционально)
Если перед Go стоит nginx — критично выставить для SSE:
```nginx
location /api/sse/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # иначе SSE буферизуется
proxy_cache off;
proxy_read_timeout 1h;
chunked_transfer_encoding on;
}
```
## 5. Переменные окружения (.env)
```env
# PostgreSQL
PG_HOST=pg.localnet
PG_PORT=5432
PG_USER=album
PG_PASSWORD=change_me
PG_DATABASE=shared_album
PG_SSLMODE=disable
# S3 (Garage)
S3_ENDPOINT=http://s3.localnet:3900
S3_REGION=garage
S3_ACCESS_KEY=GKchange_me
S3_SECRET_KEY=change_me
S3_BUCKET=albums
S3_USE_PATH_STYLE=true
S3_PUBLIC_URL=http://s3.localnet:3900/albums
# Auth
JWT_SECRET=change_me_long_random
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=720h
# Server
HTTP_ADDR=:8080
APP_BASE_URL=http://localhost:8080
# SSE
SSE_HEARTBEAT_INTERVAL=25s
SSE_CLIENT_BUFFER=64
```
### Параметры клиента S3 для Garage
```go
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("garage"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
os.Getenv("S3_ACCESS_KEY"),
os.Getenv("S3_SECRET_KEY"),
"",
)),
)
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(os.Getenv("S3_ENDPOINT"))
o.UsePathStyle = true // обязательно для Garage
})
```
**Важно:**
- `UsePathStyle = true` — без этого Garage вернёт ошибку DNS или SignatureDoesNotMatch
- Регион должен совпадать со значением `s3_region` в конфиге Garage (обычно `garage`)
- Access Key ID начинается с `GK...`
## 6. Роли и права
| Действие | Владелец | Админ | Редактор | Участник | Наблюдатель |
|---|---|---|---|---|---|
| Создать/удалить альбом | ✅ | ❌ | ❌ | ❌ | ❌ |
| Приглашать участников | ✅ | ✅ | ⚠️ | ❌ | ❌ |
| Загружать фото | ✅ | ✅ | ✅ | ✅ | ❌ |
| Удалять чужие фото | ✅ | ✅ | ❌ | ❌ | ❌ |
| Удалять свои фото | ✅ | ✅ | ✅ | ✅ | ❌ |
| Комментировать | ✅ | ✅ | ✅ | ✅ | ❌ |
| Менять роли | ✅ | ⚠️ | ❌ | ❌ | ❌ |
| Скачивать | ✅ | ✅ | ✅ | ✅ | ⚠️ |
Права проверяются **на уровне сервиса**, не только UI.
## 7. Функционал MVP
1. **Аутентификация:** регистрация, вход, refresh-токен
2. **Альбомы:** создание, редактирование, удаление, список моих альбомов
3. **Участники:** инвайт по ссылке с токеном, принятие, роли
4. **Фото:** загрузка (по одному и массово), генерация 3 превью
(thumb 200px / preview 1200px / original), извлечение EXIF
5. **Просмотр:** сетка, полноэкранный лайтбокс, свайпы
6. **Социальное:** лайки, комментарии
7. **Real-time через SSE:** новые фото, комментарии, лайки, подключение участников
8. **Уведомления:** web push о новых событиях
9. **Скачивание:** фото по одному и альбомом в zip
10. **Три темы оформления:** светлая, тёмная, нейтральная + системная
## 8. Темы оформления
Три темы + режим «системная», переключение через `data-theme` на `<html>`.
### Токены в `src/app.css`
```css
@import "tailwindcss";
/* Светлая */
:root, [data-theme="light"] {
--bg: 0 0% 100%; --bg-elevated: 0 0% 98%; --bg-muted: 0 0% 95%;
--border: 0 0% 88%;
--text: 0 0% 9%; --text-muted: 0 0% 45%; --text-subtle: 0 0% 60%;
--primary: 221 83% 53%; --primary-fg: 0 0% 100%;
--danger: 0 84% 60%; --success: 142 71% 45%;
--shadow: 0 0% 0% / 0.08;
}
/* Тёмная */
[data-theme="dark"] {
--bg: 0 0% 7%; --bg-elevated: 0 0% 11%; --bg-muted: 0 0% 16%;
--border: 0 0% 22%;
--text: 0 0% 96%; --text-muted: 0 0% 65%; --text-subtle: 0 0% 45%;
--primary: 217 91% 60%; --primary-fg: 0 0% 100%;
--danger: 0 84% 65%; --success: 142 65% 50%;
--shadow: 0 0% 0% / 0.4;
}
/* Нейтральная (тёплая серо-бежевая) */
[data-theme="neutral"] {
--bg: 40 20% 96%; --bg-elevated: 40 22% 92%; --bg-muted: 40 18% 87%;
--border: 40 14% 78%;
--text: 30 10% 15%; --text-muted: 30 8% 40%; --text-subtle: 30 6% 55%;
--primary: 25 90% 50%; --primary-fg: 0 0% 100%;
--danger: 0 72% 55%; --success: 142 55% 42%;
--shadow: 30 20% 20% / 0.12;
}
html, body {
background: hsl(var(--bg));
color: hsl(var(--text));
transition: background-color 200ms ease, color 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
html, body { transition: none; }
}
```
### Маппинг в `tailwind.config.ts`
```ts
colors: {
bg: {
DEFAULT: 'hsl(var(--bg) / <alpha-value>)',
elevated: 'hsl(var(--bg-elevated) / <alpha-value>)',
muted: 'hsl(var(--bg-muted) / <alpha-value>)',
},
border: 'hsl(var(--border) / <alpha-value>)',
text: {
DEFAULT: 'hsl(var(--text) / <alpha-value>)',
muted: 'hsl(var(--text-muted) / <alpha-value>)',
subtle: 'hsl(var(--text-subtle) / <alpha-value>)',
},
primary: {
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
fg: 'hsl(var(--primary-fg) / <alpha-value>)',
},
danger: 'hsl(var(--danger) / <alpha-value>)',
success: 'hsl(var(--success) / <alpha-value>)',
}
```
В компонентах — только семантические классы: `bg-bg-elevated text-text border-border`,
никаких `dark:` префиксов.
### Защита от вспышки
Инлайн-скрипт в `src/app.html` ставит `data-theme` **до** отрисовки:
читает `localStorage.theme` (или `system`), разрешает через `matchMedia` и
устанавливает атрибут на `<html>`.
### Store темы
`src/lib/stores/theme.svelte.ts` — Svelte 5 runes: `$state` для текущей темы,
`initTheme()` в `onMount` в `+layout.svelte`, `setTheme()` пишет в localStorage
и обновляет `data-theme`. Слушает `matchMedia` при режиме `system`.
### Переключатель
`ThemeSwitcher.svelte` — 4 кнопки (☀️ Light / 🌙 Dark / 🌾 Neutral / 💻 System)
с активным состоянием на семантических токенах.
### Нюансы для фотоальбома
- В тёмной теме под фото использовать `bg-bg-muted` и лёгкую рамку `border-border` —
так фото не «режет» глаз
- Подложка лайтбокса зависит от темы: тёмная — почти чёрная, светлая — белая
полупрозрачная, нейтральная — тёплый тёмный
- Контраст `text-muted` на `bg-muted` в нейтральной теме ≥ 4.5:1 (WCAG AA) —
важно для чтения подписей и комментариев
- Presigned URL от Garage не зависят от темы
## 9. Схема БД (в pg.localnet, схема public)
- **users** (id, email, password_hash, name, avatar_url, created_at)
- **albums** (id, owner_id, title, description, cover_photo_id, privacy, created_at)
- **album_members** (album_id, user_id, role, invited_by, joined_at)
- **invites** (id, album_id, token, created_by, expires_at, max_uses, uses)
- **photos** (id, album_id, uploader_id, storage_key, thumb_key, preview_key,
original_filename, size, width, height, mime_type,
taken_at, latitude, longitude, caption, created_at)
- **comments** (id, photo_id, user_id, text, created_at, updated_at)
- **likes** (photo_id, user_id, created_at)
- **notifications** (id, user_id, type, payload_json, read_at, created_at)
## 10. Структура в S3 (Garage)
```
{bucket}/
└── albums/{album_id}/
├── original/{photo_id}.{ext}
├── preview/{photo_id}.webp
└── thumb/{photo_id}.webp
```
Доступ: **presigned URL** со сроком 1 час (бакет приватный).
Garage поддерживает presigned URL через SigV4 query-string signatures.
## 11. API-эндпоинты
### Auth
- `POST /api/auth/register`
- `POST /api/auth/login`
- `POST /api/auth/refresh`
- `POST /api/auth/logout`
### Albums
- `GET /api/albums`
- `POST /api/albums`
- `GET /api/albums/:id`
- `PATCH /api/albums/:id`
- `DELETE /api/albums/:id`
### Members
- `GET /api/albums/:id/members`
- `POST /api/albums/:id/invites`
- `POST /api/invites/:token/accept`
- `PATCH /api/albums/:id/members/:user_id`
- `DELETE /api/albums/:id/members/:user_id`
### Photos
- `GET /api/albums/:id/photos?cursor=&limit=`
- `POST /api/albums/:id/photos` (multipart, поддержка batch)
- `GET /api/photos/:id`
- `DELETE /api/photos/:id`
- `GET /api/photos/:id/download`
### Social
- `POST /api/photos/:id/likes`
- `DELETE /api/photos/:id/likes`
- `GET /api/photos/:id/comments`
- `POST /api/photos/:id/comments`
- `DELETE /api/comments/:id`
### Real-time (SSE)
- `GET /api/sse/albums/:id` — события альбома
- `GET /api/sse/me` — персональный поток (приглашения, уведомления)
## 12. SSE — детали реализации
### Формат события
```
event: photo.created
data: {"id":"...","album_id":"...","thumb_url":"...","uploader":{...}}
```
### Backend: hub на каналах
- В памяти `map[albumID]map[clientID]chan Event`
- Регистрация клиента при подключении, удаление при отключении
- Публикация — неблокирующая запись в канал каждого клиента
- При переполнении буфера — отключение медленного клиента (backpressure)
### Обязательные заголовки
```
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
```
`X-Accel-Buffering: no` критичен, если перед Go стоит nginx.
### Heartbeat
Каждые 25 сек — комментарий `: heartbeat\n\n`, чтобы прокси не рвали соединение.
### Авторизация
`EventSource` не умеет кастомные заголовки, поэтому access-токен передаётся
в query-параметре `?token=...`. Компенсация: короткий TTL (15 мин) и
переподключение с новым токеном. Альтернатива — refresh в HttpOnly-cookie
(с CSRF-защитой).
### Догон пропущенного
`EventSource` не отправляет `Last-Event-ID` надёжно. Поэтому при переподключении
клиент догоняет пропущенное через REST: `GET /api/albums/:id/photos?since=<last_event_time>`.
### Ограничения SSE
- Односторонний поток (server → client), действия клиента — обычные POST/PATCH/DELETE
- Работает поверх HTTP/1.1 и HTTP/2, проходит через большинство прокси
- Лимит 6 одновременных SSE-соединений на домен в HTTP/1.1 (в HTTP/2 нет)
- Для чатов и игр не подходит — только для ленты событий
## 13. Структура проекта
```
shared-album/
├── backend/
│ ├── cmd/server/main.go
│ ├── internal/
│ │ ├── config/ # чтение env
│ │ ├── auth/ # JWT, middleware
│ │ ├── albums/ # handlers, service, repo
│ │ ├── photos/ # upload, resize, storage
│ │ ├── comments/
│ │ ├── likes/
│ │ ├── invites/
│ │ ├── sse/ # hub, broker, publisher
│ │ ├── storage/ # S3 клиент (Garage на s3.localnet)
│ │ └── db/ # подключение (pg.localnet), миграции
│ ├── migrations/
│ ├── static/ # сюда копируется build SvelteKit (go:embed)
│ └── go.mod
├── frontend/
│ ├── src/
│ │ ├── app.html # inline-скрипт для data-theme
│ │ ├── app.css # CSS-переменные трёх тем
│ │ ├── routes/
│ │ │ ├── +layout.svelte # initTheme()
│ │ │ ├── +page.svelte # список альбомов
│ │ │ ├── login/
│ │ │ ├── albums/[id]/
│ │ │ │ ├── +page.svelte # сетка фото + SSE
│ │ │ │ ├── members/
│ │ │ │ └── settings/
│ │ │ └── invites/[token]/
│ │ ├── lib/
│ │ │ ├── api.ts # fetch-обёртка
│ │ │ ├── sse.ts # EventSource с reconnect
│ │ │ ├── stores/
│ │ │ │ ├── auth.svelte.ts
│ │ │ │ └── theme.svelte.ts
│ │ │ └── components/
│ │ │ ├── PhotoGrid.svelte
│ │ │ ├── Lightbox.svelte
│ │ │ ├── Uploader.svelte
│ │ │ ├── Comments.svelte
│ │ │ └── ThemeSwitcher.svelte
│ ├── static/
│ ├── svelte.config.js # adapter-static
│ ├── vite.config.ts
│ └── package.json
├── deploy/
│ ├── shared-album.service # systemd unit
│ └── nginx-sse.conf # пример прокси для SSE
├── tailwind.config.ts
├── .env.example
├── Makefile # build, run, migrate, deploy
└── README.md
```
## 14. Makefile (пример)
```makefile
.PHONY: build frontend backend run migrate
frontend:
cd frontend && npm ci && npm run build
rm -rf backend/static/*
cp -r frontend/build/* backend/static/
backend:
cd backend && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
-o ../bin/shared-album ./cmd/server
build: frontend backend
run: backend
./bin/shared-album
migrate:
cd backend && migrate -path migrations -database \
"postgres://$(PG_USER):$(PG_PASSWORD)@$(PG_HOST):$(PG_PORT)/$(PG_DATABASE)?sslmode=$(PG_SSLMODE)" up
```
## 15. Требования к качеству
- Все эндпоинты защищены JWT-мидлварой, кроме `/auth/*`
- Права проверяются на уровне сервиса, не только UI
- Загрузка фото: стриминг в Garage (`s3.localnet`), генерация 3 превью асинхронно
- SSE: авторизация через токен, подписка только на доступные альбомы,
heartbeat каждые 25 сек, backpressure для медленных клиентов
- Пагинация через cursor (id или created_at), не offset
- Ошибки API — единый формат `{error: {code, message}}`
- Логирование через `slog` (структурированные логи)
- Конфиг через env (12-factor)
- Подключение к `pg.localnet` и Garage (`s3.localnet`) — с ретраями и health-check
- **Garage:** обязательно `UsePathStyle: true` и регион `garage`
- **Без Docker:** один Go-бинарник со вшитой статикой, запуск через systemd
- SSE-эндпоинт не блокирует остальные запросы — отдельная горутина на клиента
- Темы: никаких `dark:` префиксов в компонентах — только семантические токены
## 16. Порядок работы
1. Структура проекта, `.env.example`, `Makefile`, systemd unit
2. Подключение к `pg.localnet`, миграции
3. Клиент S3 к Garage (`s3.localnet`), создание бакета если нет
4. Auth: register/login/refresh
5. CRUD альбомов + инвайты + роли
6. Загрузка фото в Garage + превью + EXIF
7. Лайки, комментарии
8. SSE-хаб и публикация событий
9. SvelteKit UI: все экраны + SSE-подписка + три темы
10. Сборка: frontend → static → go:embed → бинарник
11. README с инструкцией запуска (build, systemd, nginx)
## 17. Что НЕ делать в MVP
- Распознавание лиц, ML-теги
- Мобильные приложения (только адаптивный веб)
- Платные тарифы и биллинг
- Печать фотокниг, экспорт в сторонние сервисы
- Публичный API для внешних интеграций
- Поднимать локальные Postgres и MinIO — используем `pg.localnet` и Garage
- WebSocket — real-time только через SSE
- Виртуальный хостинг для S3 (`{bucket}.s3.localnet`) — только path-style
- **Docker, docker-compose, Kubernetes** — только нативный Go-бинарник + systemd
## 18. Пошаговые промпты для итеративной генерации
### Шаг 1 — Скелет и подключения
Создай структуру проекта shared-album: backend (Go + Gin) и frontend
(SvelteKit 2 + Svelte 5 + adapter-static + Tailwind).
Без Docker — нативная сборка Go-бинарника, статика вшивается через go:embed.
Используй существующие pg.localnet и Garage на s3.localnet.
Добавь .env.example со всеми переменными (PG_*, S3_*, JWT_*, HTTP_*, SSE_*),
config-пакет на Go, подключение к pg.localnet через pgx с ретраями,
клиент S3 к Garage (AWS SDK v2, region=garage, UsePathStyle=true),
health-check /api/health (проверяет БД и S3).
Добавь Makefile: build (frontend → static → backend), run, migrate.
Добавь systemd unit deploy/shared-album.service.
### Шаг 2 — Миграции
Добавь golang-migrate и миграции для таблиц: users, albums, album_members,
invites, photos, comments, likes, notifications. Применяются к pg.localnet.
Реализуй репозитории с базовыми CRUD на pgx.
### Шаг 3 — Аутентификация
Реализуй регистрацию и вход по email/паролю с bcrypt, выдачу JWT access (15 мин)
и refresh (30 дней). Middleware для проверки токена.
Эндпоинты: POST /api/auth/register, /login, /refresh, /logout.
На фронтенде — страницы login и register, store для токена, автообновление при 401.
### Шаг 4 — Альбомы и участники
Реализуй CRUD альбомов и систему инвайтов по токену.
Роли: owner, admin, editor, member, viewer. Проверка прав на уровне сервиса.
Эндпоинты согласно ТЗ. На фронтенде — список альбомов, страница альбома,
страница участников, принятие инвайта.
### Шаг 5 — Загрузка и просмотр фото
Реализуй загрузку фото в Garage с генерацией трёх превью
(thumb 200px, preview 1200px, original) и извлечением EXIF.
Структура ключей: albums/{album_id}/{original|preview|thumb}/{photo_id}.{ext|webp}.
Отдавай фото через presigned URL (срок 1 час).
Эндпоинты загрузки, списка с cursor-пагинацией, удаления.
На фронтенде — Uploader с drag&drop и прогрессом, PhotoGrid с ленивой загрузкой,
Lightbox со свайпами.
### Шаг 6 — SSE real-time
Реализуй SSE-хаб на Go: реестр клиентов по album_id, неблокирующая публикация,
heartbeat каждые 25 сек, backpressure. Эндпоинт GET /api/sse/albums/:id
с авторизацией через ?token=. Заголовки: text/event-stream, no-cache,
X-Accel-Buffering: no. События: photo.created, photo.deleted, comment.created,
like.added, like.removed, member.joined, member.left, album.updated.
Публикуй события из сервисов. На фронтенде — обёртка sse.ts над EventSource
с экспоненциальным backoff и догоном пропущенного через REST.
Обновляй сетку и комментарии в live-режиме.
### Шаг 7 — Темы оформления
Реализуй три темы (light, dark, neutral) + system через data-theme на <html>.
CSS-переменные в app.css, маппинг в tailwind.config.ts на семантические токены
(bg, bg-elevated, bg-muted, border, text, text-muted, text-subtle, primary, danger, success).
Инлайн-скрипт в app.html ставит data-theme до отрисовки (защита от вспышки).
Store theme.svelte.ts на Svelte 5 runes: initTheme() в onMount в +layout.svelte,
setTheme() пишет в localStorage, слушает matchMedia для system.
ThemeSwitcher.svelte — 4 кнопки с активным состоянием.
В компонентах — только семантические классы, никаких dark: префиксов.
### Шаг 8 — Сборка и деплой без Docker
Настрой Makefile: frontend собирается в статику, копируется в backend/static,
backend собирается с go:embed. Финальный артефакт — один бинарник.
Добавь systemd unit в deploy/shared-album.service с EnvironmentFile=.env
и базовым харденингом (NoNewPrivileges, ProtectSystem, PrivateTmp).
Добавь пример nginx-конфига с proxy_buffering off для SSE.
Напиши README: как собрать, как положить бинарник, как запустить через systemd.