# Webclient

SPA-панель управления умным домом. Общается с сервером через REST API (через `proxy.php`). Живёт в `/webclient`, собирается локально, деплоится статикой.

---

## Сборка и разработка

```bash
npm install   # один раз
npm start     # сборка + watch + live-reload
```

Gulp собирает параллельно:
- `src/scss/main.scss` → `dist/css/main.css` (sass → autoprefixer → minify + sourcemap)
- `src/js/index.js` → `dist/js/main.js` (esbuild bundle + minify + sourcemap)

После сборки следит за изменениями файлов, пересобирает на лету.

### Конфигурация сборки

В `gulpfile.js` есть закомментированная задача `serve` (BrowserSync). Чтобы включить — раскомментировать вызов `serve` в `exports.default`.

---

## Конфигурация приложения

**`config.php`** — единственный файл настроек:

```php
return [
    "version"  => "0.2 dev",
    "server"   => "http://192.168.1.101",   // адрес сервера SHServ
    "allowed_prefixes" => ["/api/v1/"],     // пути, разрешённые через прокси
    "proxy.allowed_origins" => [            // CORS whitelist для proxy.php
        "http://localhost:5173",
    ],
];
```

**`proxy.php`** — CORS-прокси, стоит между браузером и сервером SHServ. Форвардит запросы только по путям из `allowed_prefixes`, проставляет CORS-заголовки, форвардит `Authorization`, `Content-Type`, `Accept`.

**`ui.php`** — kitchensink-страница для просмотра всех UI-компонентов. Открывается в браузере напрямую, не является частью SPA.

---

## JS-архитектура

### Точка входа

`src/js/index.js` — инициализация при `DOMContentLoaded`:

1. Глобальные синглтоны вешаются на `window`: `DataProvider`, `Toasts`, `Modals`, `Helper`, `confirmPopup`, `advancedSelect`, `editableString`
2. `hud()` — инициализирует навигацию
3. `new SmartHomeApi(...)` — создаётся клиент API
4. `new Screens(...)` — создаётся менеджер экранов
5. `routes(screens, sh_api)` — регистрируются все экраны
6. `screens.routing()` — запускается роутинг (polling hash каждые 50 мс)

Константа `API_BASEURL` инжектируется esbuild при сборке из `gulpfile.js`.

---

### Роутинг

Hash-based SPA. Формат: `#!/path`.

Маршруты определены в `src/js/routes.js`:

| Hash | Экран |
|------|-------|
| `#!/` | редирект → `#!/areas/tree` |
| `#!/devices` | список устройств |
| `#!/devices/scanning` | поиск новых устройств |
| `#!/scripts/actions` | action-скрипты |
| `#!/scripts/regular` | regular-скрипты |
| `#!/scripts/scopes` | scope-файлы |
| `#!/areas/tree` | дерево областей |
| всё остальное | экран 404 |

---

### Screens

`src/js/components/Screens.js` — движок SPA.

**Структура объекта экрана:**
```js
{
  alias: "my_screen",
  renderer: () => "<div>...</div>",     // возвращает HTML-строку
  initer: (scr) => {                    // навешивает логику
    // загрузить данные, добавить обработчики
    scr.ready();                        // обязательно в конце
  }
}
```

**Ключевые методы:**

| Метод | Что делает |
|-------|-----------|
| `screens.add(route, screenObj)` | Зарегистрировать экран |
| `screens.switch(alias)` | Переключиться на экран (пересоздаёт DOM) |
| `screens.reload()` | Пересоздать текущий экран |
| `screens.reinit()` | Перезапустить `initer` без пересоздания DOM |
| `screens.ready()` | Пометить экран как готовый (убирает лоадер) |
| `screens.error(title, text)` | Показать экран ошибки |
| `screens.onSwitch(cb)` | Хук на переключение экрана |

---

### SmartHomeApi

`src/js/sh/SmartHomeApi.js` — HTTP-клиент, callback-style.

```js
const api = new SmartHomeApi({
    base_url:   API_BASEURL,
    token:      "YOUR_TOKEN",
    timeout_ms: 10000,
    proxy_path: "/proxy.php",
    on_unauthorized: ({ error }) => { /* ... */ }
});
```

Все методы принимают коллбэк `(err, data, meta)`:
- `err` — `null` при успехе, иначе `{ type, message, status_code?, raw? }`
- `data` — распарсенный JSON-ответ
- `meta` — `{ url, method, status_code, headers }`

Типы ошибок: `network_error`, `timeout`, `http_error`, `api_error`.

**Модули:**

`api.devices` → `src/js/sh/modules/DevicesApi.js`

| Метод | Endpoint |
|-------|---------|
| `list(cb)` | GET `/api/v1/devices/list` |
| `scanning_setup(cb)` | GET `/api/v1/devices/scanning/setup` |
| `scanning_all(cb)` | GET `/api/v1/devices/scanning/all` |
| `setup_new_device(payload, cb)` | POST `/api/v1/devices/setup/new-device` |
| `get(id, cb)` | GET `/api/v1/devices/id/{id}` |
| `info(id, cb)` | GET `/api/v1/devices/id/{id}/info` |
| `status(id, cb)` | GET `/api/v1/devices/id/{id}/status` |
| `action(payload, cb)` | POST `/api/v1/devices/action` |
| `remove(id, cb)` | GET `/api/v1/devices/id/{id}/remove` |
| `reboot(id, cb)` | GET `/api/v1/devices/id/{id}/reboot` |
| `place_in_area(payload, cb)` | POST `/api/v1/devices/place-in-area` |
| `unassign_from_area(id, cb)` | GET `/api/v1/devices/id/{id}/unassign-from-area` |
| `update_name(payload, cb)` | POST `/api/v1/devices/update-name` |
| `update_description(payload, cb)` | POST `/api/v1/devices/update-description` |
| `update_alias(payload, cb)` | POST `/api/v1/devices/update-alias` |
| `resetup(payload, cb)` | POST `/api/v1/devices/resetup` |
| `reset(payload, cb)` | POST `/api/v1/devices/reset` |

`api.areas` → `src/js/sh/modules/AreasApi.js`

| Метод | Endpoint |
|-------|---------|
| `list(cb)` | GET `/api/v1/areas/list` |
| `inner_list(area_id, cb)` | GET `/api/v1/areas/id/{id}/list` |
| `new_area(payload, cb)` | POST `/api/v1/areas/new-area` |
| `remove(area_id, cb)` | GET `/api/v1/areas/id/{id}/remove` |
| `place_in_area(payload, cb)` | POST `/api/v1/areas/place-in-area` |
| `unassign_from_area(id, cb)` | GET `/api/v1/areas/id/{id}/unassign-from-area` |
| `update_display_name(payload, cb)` | POST `/api/v1/areas/update-display-name` |
| `update_alias(payload, cb)` | POST `/api/v1/areas/update-alias` |
| `devices(area_id, cb)` | GET `/api/v1/areas/id/{id}/devices` |
| `scripts(area_id, cb)` | GET `/api/v1/areas/id/{id}/scripts` |
| `types_list(cb)` | GET `/api/v1/areas/types/list` |
| `reboot_devices(area_id, cb)` | GET `/api/v1/areas/id/{id}/reboot_devices` |

`api.scripts` → `src/js/sh/modules/ScriptsApi.js`

| Метод | Endpoint |
|-------|---------|
| `actions_list(cb)` | GET `/api/v1/scripts/actions/list` |
| `regular_list(cb)` | GET `/api/v1/scripts/regular/list` |
| `scopes_list(cb)` | GET `/api/v1/scripts/scopes/list` |
| `run(payload, cb)` | POST `/api/v1/scripts/actions/run` |
| `scope_update(payload, cb)` | POST `/api/v1/scripts/scopes/update` |
| `action_enable/disable(alias, cb)` | GET `.../alias/{alias}/enable\|disable` |
| `regular_enable/disable(alias, cb)` | GET `.../regular/alias/{alias}/enable\|disable` |
| `scope_enable/disable(name, cb)` | GET `.../scope/{name}/enable\|disable` |
| `place_in_area(payload, cb)` | POST `/api/v1/scripts/place-in-area` |
| `unassign_from_area(id, cb)` | GET `/api/v1/scripts/id/{id}/unassign-from-area` |

---

### DataProvider

`src/js/DataProvider.js` — кеш данных в рамках сессии. Основное назначение: хранить структуры по ID, чтобы потом получить их в попапах и экранах без повторных запросов к серверу.

**Базовый доступ:**
```js
window.DataProvider.set("key", value);
window.DataProvider.get("key");
window.DataProvider.setRaw("key", value);  // сохраняет с префиксом "raw."
window.DataProvider.getRaw("key");
```

**Получить из кеша или загрузить с сервера:**
```js
DataProvider.getOrFetch(
    `raw.devices.${id}`,
    (cb) => sh_api.devices.get(id, (err, resp) => cb(err, resp?.data?.device)),
    (err, device) => {
        if (err) return scr.error("Ошибка", err.message);
        // данные здесь — либо из кеша, либо свежезагруженные
    }
);
```

**Инвалидация после мутаций:**
```js
DataProvider.invalidate("raw.devices.12");          // один ключ
DataProvider.invalidatePrefix("raw.devices");       // всё под префиксом
```

**Получить всё закешированное под префиксом:**
```js
const allDevices = DataProvider.getCollection("raw.devices");  // → [device, device, ...]
```

**Ключи кеша, которые заполняют API-модули:**

| Ключ | Заполняется при |
|------|----------------|
| `raw.devices.{id}` | `api.devices.list()` |
| `raw.areas.{id}` | `api.areas.list()` |
| `raw.actions_scripts.{alias}` | `api.scripts.actions_list()` |
| `raw.regular_scripts.{alias}` | `api.scripts.regular_list()` |
| `raw.scopes.{name}` | `api.scripts.scopes_list()` |
| `raw.scanning.setup.devices.{i}` | `api.devices.scanning_setup()` |
| `raw.scanning.devices.{i}` | `api.devices.scanning_all()` |

---

### Паттерн добавления нового экрана

1. Создать файл `src/js/components/screens/my-section/my-screen.js`:
```js
export function myScreen(sh_api) {
    return {
        alias: "my_screen",
        renderer: () => `<div class="my-screen">...</div>`,
        initer: (scr) => {
            sh_api.devices.list((err, data) => {
                if (err) return scr.error("Ошибка", err.message);
                // заполнить DOM
                scr.ready();
            });
        }
    };
}
```

2. Зарегистрировать маршрут в `src/js/routes.js`:
```js
screens.add("#!/my-section", myScreen(sh_api));
```

3. Добавить ссылку в навигацию в `src/js/components/hud.js`.

---

## SCSS-архитектура

```
src/scss/
├── main.scss               ← точка входа
├── _fonts.scss             ← подключение шрифтов (IBM Plex Mono)
├── _palette-colors.scss    ← все CSS-переменные и SCSS-переменные цветов
├── _mixins.scss            ← media_up/down/between, hover_touch
├── _spacing.scss           ← шкала отступов $space-0..$space-12
├── _utils.scss             ← утилиты: .mt-*, .mb-*, .p-*, .g-*, .d-none
├── _ui.scss                ← импортирует ui_components/
├── _app.scss               ← импортирует app/
├── ui_components/          ← переиспользуемые компоненты
│   ├── _buttons.scss       ← .btn, .btn-primary, .btn-accent, .btn-small, .with-icon
│   ├── _forms.scss         ← .input, .textarea, .select, .form-group, .label
│   ├── _modals.scss        ← .modal, .modal-panel, .modal-header/body/footer
│   ├── _cards.scss         ← .card, .card-content, .script-action
│   ├── _lists.scss         ← .list, .list-item, .list-nav, .list-action
│   ├── _toasts.scss        ← .toast, .toast-success/warning/danger
│   ├── _badges.scss        ← .badge, .badge-success/warning/primary
│   ├── _tables.scss        ← .table, .table-row, .table-empty
│   ├── _alerts.scss        ← .alert, .alert-primary/success/error
│   ├── _typography.scss    ← .h1-.h6, .contrast, .text-light
│   ├── _palette.scss       ← .bg-*, .text-* цветовые утилиты
│   ├── _loader.scss        ← .loader, .circle-loader
│   ├── _advanced-select.scss
│   └── _editable-string.scss
└── app/
    ├── _hud.scss           ← .hud, .navigation, .nav-link
    ├── _load-screen.scss   ← .load-screen, .a-show
    ├── _error-screen.scss  ← .error-screen
    └── _empty-here.scss    ← .empty-here
```

**Цвета** (`_palette-colors.scss`) — тёмная кибер-тема:
- Фон: `$color-black: #0A0A0D`, `$color-dark: #1A1A23`
- Акценты: `$color-cyan: #00FFCC`, `$color-orange: #ff6f30`, `$color-electric-blue: #00B3FF`
- Состояния: success `$color-neon-green`, warning `$color-neon-yellow`, error `#FF3C00`

**Брейкпоинты** в `_mixins.scss`: `xs 360` / `sm 480` / `md 768` / `lg 1024` / `xl 1280` / `xxl 1440`.

**Анимационные классы:** `.a-show` (появление), `.a-hide` (исчезновение) — CSS-переходы 300 мс.

---

## UI-компоненты (JS)

| Компонент | Файл | Назначение |
|-----------|------|-----------|
| `Toasts` | `toasts.js` | Всплывающие уведомления: `.success(msg)`, `.error(msg)`, `.warning(msg)` |
| `Modals` | `modals.js` | Строитель модальных окон: `Modals.create(id, { title, body, actions, onready })` |
| `confirmPopup` | `confirm-popup.js` | Диалог подтверждения: `confirmPopup("Текст?", onConfirm, onCancel)` |
| `advancedSelect` | `advanced-select.js` | Dropdown с фильтрацией и клавиатурной навигацией |
| `editableString` | `editable-string.js` | Inline-редактор строки, событие `onChange` |
| `Helper` | `helper.js` | Генераторы HTML (`Helper.template.*`), управление состояниями (`Helper.states.*`), нормализация данных (`Helper.unification.*`) |

**Состояния кнопок/карточек через Helper:**
```js
Helper.states.btnLoadingState(btnElement, true);   // показать спиннер
Helper.states.btnLoadingState(btnElement, false);  // восстановить
```

---

## Структура компонентов экранов

```
src/js/components/screens/
├── devices/
│   ├── devices.js                  ← роутер к экранам устройств
│   ├── devices-list-screen.js      ← таблица всех устройств
│   ├── devices-scanning-screen.js  ← поиск и добавление новых
│   ├── device-state-component.js   ← рендер состояния по типу (relay/button/sensor/hatch)
│   ├── device-details-popup.js     ← попап с деталями устройства
│   ├── device-setup-form-popup.js  ← форма добавления устройства
│   └── devices-funcs.js            ← общие функции для экранов устройств
├── areas/
│   ├── areas.js                    ← роутер к экранам областей
│   ├── areas-tree-screen.js        ← иерархическое дерево областей
│   ├── areas-create-new-modal.js   ← форма создания области
│   ├── areas-details-modal.js      ← редактирование области
│   ├── areas-devices-modal.js      ← устройства в области
│   ├── areas-actions-modal.js      ← скрипты в области
│   ├── areas-placeto-component.js  ← компонент перемещения в дерево
│   └── areas-funcs.js
└── scripts/
    ├── scripts.js                  ← роутер к экранам скриптов
    ├── scripts-actions-screen.js   ← action-скрипты
    ├── scripts-regular-screen.js   ← regular-скрипты
    ├── scripts-scopes-screen.js    ← scope-файлы
    ├── scripts-action-popup.js     ← детали и запуск action-скрипта
    └── scripts-funcs.js
```
