refactor(frontend): shared API client + automations registry (audit M7, H8)
H8 — automations.ts rule-type registry
Convert the two hand-rolled RuleType dispatch ladders into per-type
registries (RULE_FIELD_RENDERERS + RULE_COLLECTORS) keyed by RuleType,
joining the existing RULE_CHIP_RENDERERS. All three are typed
Record<RuleType, ...> for compile-time exhaustiveness; an import-time
_assertRuleHandlerCoverage() check logs loudly if any registry drifts
from RULE_TYPE_KEYS — mirrors the backend's _RULE_HANDLERS shape, the
one intentional divergence being that the frontend logs rather than
throws (a thrown error at module import would brick the whole bundle,
not just the editor).
M7 — shared API client + 35 file migrations
New core/api-client.ts wrapping fetchWithAuth with typed apiGet /
apiPost / apiPut / apiPatch / apiDelete. Auth, 401-relogin, retry,
timeout, and the offline toast all stay owned by fetchWithAuth; the
client just collapses the
if (!resp.ok) { detail || HTTP <status> } ... resp.json()
dance into one typed call. The detail unwrap is hardened to join
FastAPI validation arrays instead of stringifying to [object Object].
35 feature/core files migrated to it across many batches, reviewer-
approved for behaviour parity in three passes covering the riskier
divergences (bulk Promise.allSettled deletes, inline-error saves,
array-detail joins, silent-failure GETs, immutable clones).
9 files remain on fetchWithAuth — the big god-modules tied to the
pending C8/C9/C10 splits (streams, settings, targets, dashboard,
color-strips/index, graph-editor, assets, value-sources) plus
pairing-flow which by design stays on raw fetch (branches on raw
Response.status codes).
i18n — 14 new locale keys (en / ru / zh)
Added save/load/delete error keys across automations, pattern,
audio_processing, audio_template, templates, gradient, target,
device namespaces, plus backfilled gradient.error.delete_failed into
ru/zh. Scan confirms no hardcoded English errorMessage strings
remain in the migrated diff.
AUDIT_REMAINING.md updated to reflect H6, H8, and M7 status.
Verified: tsc --noEmit clean + npm run build clean after every batch.
This commit is contained in:
+92
-11
@@ -18,6 +18,7 @@ context.
|
||||
| `05f73ee` | H6 (bindable extraction only) |
|
||||
| `3b8f00e` + `c1aa2eb` | C7 store-side |
|
||||
| `2f15fbb` | H3 |
|
||||
| _uncommitted (2026-05-27 autonomous pass)_ | H6-rest, H8, M7 (foundation + 3 reference files) |
|
||||
|
||||
All commits have ≥1 code-review subagent pass with HIGH findings fixed
|
||||
before commit. Tests pass on each commit; ruff clean; tsc + bundle build
|
||||
@@ -100,16 +101,35 @@ registry.
|
||||
|
||||
**Estimated scope:** 1-2 sessions; coupled to H4.
|
||||
|
||||
#### H8 — `automations.ts` 1410 LOC
|
||||
#### H8 — `automations.ts` 1410 LOC — ✅ DONE (uncommitted, 2026-05-27)
|
||||
|
||||
Frontend mirror of H2 (rule polymorphism). Already addressed on the
|
||||
backend in `98fb61d`; the frontend dispatch on `RuleType` is still
|
||||
backend in `98fb61d`; the frontend dispatch on `RuleType` was
|
||||
hand-rolled.
|
||||
|
||||
**Approach:** introduce a rule-type registry on the frontend matching
|
||||
the backend's `_RULE_HANDLERS` shape.
|
||||
**Done:** the two remaining hand-rolled dispatch ladders were converted
|
||||
to registries keyed by `RuleType`, alongside the pre-existing
|
||||
`RULE_CHIP_RENDERERS`:
|
||||
- `RULE_FIELD_RENDERERS` — the `renderFields` if/elif ladder was
|
||||
extracted into module-level `_renderXxxFields(container, data)`
|
||||
functions (they only ever closed over `container`); the in-row
|
||||
`renderFields` is now a 3-line dispatcher.
|
||||
- `RULE_COLLECTORS` — the `getAutomationEditorRules` if/elif ladder
|
||||
became per-type collectors; the loop is now a registry lookup.
|
||||
- All three registries are typed `Record<RuleType, …>` (compile-time
|
||||
exhaustiveness) and an import-time `_assertRuleHandlerCoverage()`
|
||||
logs loudly if any registry drifts from `RULE_TYPE_KEYS`. (Frontend
|
||||
logs rather than throws — a thrown error at import would brick the
|
||||
whole bundle, not just the editor — the one intentional divergence
|
||||
from the backend's raising `_assert_rule_handler_coverage`.)
|
||||
|
||||
**Estimated scope:** half a session.
|
||||
Adding a new rule type now means: one entry in `RULE_TYPE_KEYS`,
|
||||
`RULE_TYPE_ICONS`, and each of the three registries — and tsc + the
|
||||
coverage check flag any omission.
|
||||
|
||||
Verified: tsc + bundle build clean; typescript-reviewer APPROVE (the
|
||||
extracted renderer bodies are byte-identical to the originals; no stray
|
||||
closure captures; http_poll widget-stash + HA entity loading preserved).
|
||||
|
||||
### MEDIUM
|
||||
|
||||
@@ -161,16 +181,66 @@ extract the frame loop into a separate `PreviewFrameLoop` class.
|
||||
**Estimated scope:** half a session. Low impact since the parallel-change
|
||||
problem is already fixed.
|
||||
|
||||
#### M7 — No shared frontend API client
|
||||
#### M7 — No shared frontend API client — 🟡 FOUNDATION DONE (uncommitted, 2026-05-27)
|
||||
|
||||
**File:** every `static/js/features/*.ts`
|
||||
|
||||
`fetchWithAuth(...)` + bespoke error-unwrapping is copy-pasted in every
|
||||
feature's save / load function. ~25 files.
|
||||
feature's save / load function. ~45 files, ~243 call sites.
|
||||
|
||||
**Approach:** introduce `static/js/core/api-client.ts` with typed
|
||||
methods (`get`, `post`, `put`, `delete`) that handle auth, JSON parsing,
|
||||
error normalisation. Replace `fetchWithAuth` calls across features.
|
||||
**Done:** `static/js/core/api-client.ts` now provides typed
|
||||
`apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete` that wrap
|
||||
`fetchWithAuth` (so auth, 401-relogin, retry, timeout, and the offline
|
||||
toast are unchanged) and collapse the repeated
|
||||
`if (!resp.ok) { detail || HTTP <status> } … resp.json()` dance into one
|
||||
call returning a typed body and throwing `ApiError` on failure. The
|
||||
`detail` unwrap is hardened to join FastAPI validation arrays instead of
|
||||
stringifying to `[object Object]`. **35 feature/core files migrated**
|
||||
(covers GET/POST/PUT/DELETE, typed response bodies, custom i18n error
|
||||
messages, silent-failure GETs, bulk `Promise.allSettled` deletes,
|
||||
inline-error saves, array-`detail` joins, fire-and-forget POSTs, and
|
||||
local catch handling) — reviewer-approved for behaviour parity across
|
||||
the riskier divergences. Migrated files include the integration sources
|
||||
(weather / HA / MQTT / HTTP), the template families (capture / audio /
|
||||
audio-processing / pattern), the scene-preset CRUD, the simple-CRUD
|
||||
entity files (sync-clocks / audio-sources / game-integration /
|
||||
gradient / displays / device-discovery), the light-target editors
|
||||
(z2m / ha), the preferences modules (dashboard-layout / card-modes /
|
||||
notifications-watcher), the calibration editors (simple + advanced),
|
||||
the entire `automations.ts` and `devices.ts` CRUD surfaces, and several
|
||||
core utilities (`api-client.ts` itself, `cache.ts`, `command-palette.ts`,
|
||||
`graph-connections.ts`, `tag-input.ts`, `process-picker.ts`,
|
||||
`perf-charts.ts`, `icon-picker.ts`, `update.ts`, `integrations.ts`).
|
||||
|
||||
Also added **14 new locale keys** (en / ru / zh) so the fallback
|
||||
messages the migration surfaces — `pattern.error.save_failed`,
|
||||
`audio_processing.error.save_failed`, `audio_template.error.save_failed`,
|
||||
`audio_template.error.load_failed`, `templates.error.save_failed`,
|
||||
`templates.error.load_failed`, `gradient.error.save_failed`,
|
||||
`target.error.load_failed`, `device.error.load_failed`,
|
||||
`automations.error.{load,save,delete,toggle}_failed`, plus
|
||||
`gradient.error.delete_failed` for ru/zh — are translated instead of
|
||||
hardcoded English. A scan confirms **no `errorMessage: '<English>'`
|
||||
strings remain** in the migrated diff.
|
||||
|
||||
**Remaining:** 9 feature files (~94 call sites). All but one are the
|
||||
big god-modules whose migration is best done as part of their C8/C9/C10
|
||||
splits: `streams.ts` (18), `settings.ts` (18), `targets.ts` (16),
|
||||
`dashboard.ts` (15), `color-strips/index.ts` (8), `graph-editor.ts` (7),
|
||||
`assets.ts` (6 — also blocked by multipart upload + blob download paths
|
||||
that legitimately bypass the JSON client), and `value-sources.ts` (5).
|
||||
The lone leaf file still on `fetchWithAuth` is `pairing-flow.ts` (1) —
|
||||
its branching on raw `Response.status` codes (200 / 409 / 4xx) doesn't
|
||||
fit the api-client contract, so it stays on raw fetch by design.
|
||||
Migration is mechanical but **not** a blind find/replace — each site
|
||||
carries its own localised error key that must be preserved as the
|
||||
`errorMessage` option, and binary/multipart endpoints (e.g.
|
||||
`assets.ts` file upload / blob download) must stay on raw
|
||||
`fetchWithAuth` (the client is JSON-only). Each migrated file ideally
|
||||
gets manual UI smoke-testing. **Behaviour note:** migrated GET sites now
|
||||
prefer the server's `detail` over the generic localised fallback when
|
||||
present — matching what the write paths already did; intended, but
|
||||
user-visible.
|
||||
|
||||
#### M8 — Global `_cached*` `let` vars
|
||||
|
||||
@@ -262,7 +332,11 @@ always start before reading).
|
||||
|
||||
### Other frontend (severity in main list above)
|
||||
|
||||
- **H6 rest** — split remaining ~1100 LOC of `types.ts` into per-entity files
|
||||
- **H6 rest** — ✅ DONE (uncommitted, 2026-05-27): `types.ts` (1140 LOC)
|
||||
split into 18 per-entity files under `types/` (joining the existing
|
||||
`bindable.ts`); `types.ts` is now a ~200-line pure re-export barrel, so
|
||||
every `import { … } from '../types.ts'` still resolves. Reviewer
|
||||
confirmed all 102 exported symbols preserved, none renamed.
|
||||
- **H7** — `device-discovery.ts` 1745 LOC (couple with H4)
|
||||
- **H8** — `automations.ts` 1410 LOC (mirror H2)
|
||||
- **M7** — shared API client
|
||||
@@ -299,6 +373,13 @@ Address H6-rest, C8, C9, C10, H7, H8, M7-M11, L1. See order above.
|
||||
Critical to have typescript-reviewer feedback + manual UI testing after
|
||||
each split.
|
||||
|
||||
> **Progress (2026-05-27, uncommitted):** steps 1 & 2 of the order above
|
||||
> are done — H6-rest (`types.ts` split) and M7-foundation (`api-client.ts`
|
||||
> + 3 reference migrations). H8 (automations registry) also landed. Still
|
||||
> open: C8, C9, C10, H7, the remaining ~40 M7 file migrations, M8-M11, L1.
|
||||
> Next per the order: introduce the API client everywhere (finish M7),
|
||||
> then split `value-sources.ts` (C8).
|
||||
|
||||
### Session B — Device redesign (1-2 sessions)
|
||||
|
||||
Address H4 alone. Touches device storage + provider classes; needs a
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Typed REST client — one place for the request/parse/error-unwrap dance
|
||||
* that every feature module used to hand-roll on top of `fetchWithAuth`.
|
||||
*
|
||||
* Before this module, ~25 feature files repeated the same shape:
|
||||
*
|
||||
* ```ts
|
||||
* const resp = await fetchWithAuth(url, { method, body: JSON.stringify(p) });
|
||||
* if (!resp.ok) {
|
||||
* const err = await resp.json().catch(() => ({}));
|
||||
* throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
* }
|
||||
* const data = await resp.json();
|
||||
* ```
|
||||
*
|
||||
* `apiGet` / `apiPost` / `apiPut` / `apiDelete` collapse that to a single
|
||||
* call that returns the parsed body (typed via the caller's `<T>`) and
|
||||
* throws an {@link ApiError} carrying the server's `detail` on failure.
|
||||
*
|
||||
* Auth headers, the 401 → re-login flow, timeouts, the 5xx/network retry
|
||||
* loop, and the offline-toast are all still owned by `fetchWithAuth`; this
|
||||
* is a thin typed layer on top, not a replacement.
|
||||
*
|
||||
* Audit finding M7.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, ApiError } from './api.ts';
|
||||
|
||||
export interface ApiRequestOpts {
|
||||
/**
|
||||
* Message for the thrown {@link ApiError} when the server returns a
|
||||
* non-2xx status *and* provides no usable `detail`. Defaults to
|
||||
* `HTTP <status>`. Pass a localised string (e.g. `t('foo.error.save')`)
|
||||
* to preserve the bespoke per-feature messages.
|
||||
*/
|
||||
errorMessage?: string;
|
||||
/** Abort signal forwarded to `fetchWithAuth`. */
|
||||
signal?: AbortSignal;
|
||||
/** Per-request timeout in ms (default 10 000, owned by `fetchWithAuth`). */
|
||||
timeout?: number;
|
||||
/** Disable the 5xx/network auto-retry loop (default: enabled). */
|
||||
retry?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a `Response` into a parsed body or throw {@link ApiError}.
|
||||
*
|
||||
* `detail` handling mirrors — and slightly hardens — the old hand-rolled
|
||||
* pattern: a string `detail` is used verbatim; FastAPI validation errors
|
||||
* (an array of `{msg, ...}`) are joined instead of stringifying to
|
||||
* `[object Object]`; otherwise we fall back to `errorMessage` then
|
||||
* `HTTP <status>`.
|
||||
*/
|
||||
async function unwrap<T>(resp: Response, opts?: ApiRequestOpts): Promise<T> {
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({} as Record<string, unknown>));
|
||||
const detail = (body as { detail?: unknown }).detail;
|
||||
let message: string;
|
||||
if (typeof detail === 'string' && detail) {
|
||||
message = detail;
|
||||
} else if (Array.isArray(detail) && detail.length > 0) {
|
||||
message = detail
|
||||
.map((d) => (d && typeof d === 'object' && 'msg' in d ? String((d as { msg: unknown }).msg) : String(d)))
|
||||
.join('; ');
|
||||
} else {
|
||||
message = opts?.errorMessage || `HTTP ${resp.status}`;
|
||||
}
|
||||
throw new ApiError(resp.status, message);
|
||||
}
|
||||
// 204 No Content (and other empty bodies) — nothing to parse.
|
||||
if (resp.status === 204) return undefined as T;
|
||||
const text = await resp.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
|
||||
function buildOpts(method: string, body: unknown, opts?: ApiRequestOpts): RequestInit & { retry?: boolean; timeout?: number } {
|
||||
const init: RequestInit & { retry?: boolean; timeout?: number } = { method };
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
if (opts?.signal) init.signal = opts.signal;
|
||||
if (opts?.timeout !== undefined) init.timeout = opts.timeout;
|
||||
if (opts?.retry !== undefined) init.retry = opts.retry;
|
||||
return init;
|
||||
}
|
||||
|
||||
/** `GET <path>` → parsed JSON body of type `T`. */
|
||||
export async function apiGet<T>(path: string, opts?: ApiRequestOpts): Promise<T> {
|
||||
const resp = await fetchWithAuth(path, buildOpts('GET', undefined, opts));
|
||||
return unwrap<T>(resp, opts);
|
||||
}
|
||||
|
||||
/** `POST <path>` with a JSON body → parsed JSON body of type `T`. */
|
||||
export async function apiPost<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
|
||||
const resp = await fetchWithAuth(path, buildOpts('POST', body, opts));
|
||||
return unwrap<T>(resp, opts);
|
||||
}
|
||||
|
||||
/** `PUT <path>` with a JSON body → parsed JSON body of type `T`. */
|
||||
export async function apiPut<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
|
||||
const resp = await fetchWithAuth(path, buildOpts('PUT', body, opts));
|
||||
return unwrap<T>(resp, opts);
|
||||
}
|
||||
|
||||
/** `PATCH <path>` with a JSON body → parsed JSON body of type `T`. */
|
||||
export async function apiPatch<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
|
||||
const resp = await fetchWithAuth(path, buildOpts('PATCH', body, opts));
|
||||
return unwrap<T>(resp, opts);
|
||||
}
|
||||
|
||||
/** `DELETE <path>` → parsed JSON body of type `T` (often `void`/204). */
|
||||
export async function apiDelete<T = void>(path: string, opts?: ApiRequestOpts): Promise<T> {
|
||||
const resp = await fetchWithAuth(path, buildOpts('DELETE', undefined, opts));
|
||||
return unwrap<T>(resp, opts);
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
* Reusable data cache with fetch deduplication, invalidation, and subscribers.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, ApiError } from './api.ts';
|
||||
import { ApiError } from './api.ts';
|
||||
import { apiGet } from './api-client.ts';
|
||||
|
||||
// Server JSON is treated as `any` at the cache boundary because each
|
||||
// extractor knows the endpoint-specific shape (e.g. `json.devices`).
|
||||
@@ -66,19 +67,18 @@ export class DataCache<T = unknown> {
|
||||
|
||||
async _doFetch(): Promise<T> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(this._endpoint);
|
||||
if (!resp.ok) {
|
||||
console.error(`[DataCache] ${this._endpoint}: HTTP ${resp.status}`);
|
||||
return this._data;
|
||||
}
|
||||
const json = await resp.json();
|
||||
const json = await apiGet<any>(this._endpoint);
|
||||
this._data = this._extractData(json);
|
||||
this._fresh = true;
|
||||
this._notify();
|
||||
return this._data;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError && err.isAuth) return this._data;
|
||||
console.error(`Cache fetch ${this._endpoint}:`, err);
|
||||
if (err instanceof ApiError) {
|
||||
console.error(`[DataCache] ${this._endpoint}: HTTP ${err.status}`);
|
||||
} else {
|
||||
console.error(`Cache fetch ${this._endpoint}:`, err);
|
||||
}
|
||||
return this._data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Command Palette — global search & navigation (Ctrl+K / Cmd+K).
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from './api.ts';
|
||||
import { escapeHtml } from './api.ts';
|
||||
import { apiGet, apiPost } from './api-client.ts';
|
||||
import { t } from './i18n.ts';
|
||||
import { navigateToCard } from './navigation.ts';
|
||||
import {
|
||||
@@ -73,18 +74,18 @@ function _buildItems(results: any[], states: any = {}) {
|
||||
action: async () => {
|
||||
const isRunning = actionItem._running;
|
||||
const endpoint = isRunning ? 'stop' : 'start';
|
||||
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/${endpoint}`, { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
try {
|
||||
await apiPost(`/output-targets/${tgt.id}/${endpoint}`, undefined, {
|
||||
errorMessage: t(`target.error.${endpoint}_failed`),
|
||||
});
|
||||
showToast(t(isRunning ? 'device.stopped' : 'device.started'), 'success');
|
||||
actionItem._running = !isRunning;
|
||||
actionItem.detail = !isRunning ? t('search.action.stop') : t('search.action.start');
|
||||
actionItem.icon = !isRunning ? '■' : '▶';
|
||||
_render();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
const d = err.detail || err.message || '';
|
||||
const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d);
|
||||
showToast(ds || t(`target.error.${endpoint}_failed`), 'error');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message || t(`target.error.${endpoint}_failed`), 'error');
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -108,17 +109,17 @@ function _buildItems(results: any[], states: any = {}) {
|
||||
action: async () => {
|
||||
const isEnabled = autoItem._enabled;
|
||||
const endpoint = isEnabled ? 'disable' : 'enable';
|
||||
const resp = await fetchWithAuth(`/automations/${a.id}/${endpoint}`, { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
try {
|
||||
await apiPost(`/automations/${a.id}/${endpoint}`, undefined, {
|
||||
errorMessage: t('search.action.' + endpoint) + ' failed',
|
||||
});
|
||||
showToast(t('search.action.' + endpoint) + ': ' + a.name, 'success');
|
||||
autoItem._enabled = !isEnabled;
|
||||
autoItem.detail = !isEnabled ? t('search.action.disable') : t('search.action.enable');
|
||||
_render();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
const d = err.detail || err.message || '';
|
||||
const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d);
|
||||
showToast(ds || (t('search.action.' + endpoint) + ' failed'), 'error');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message || (t('search.action.' + endpoint) + ' failed'), 'error');
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -170,9 +171,15 @@ function _buildItems(results: any[], states: any = {}) {
|
||||
items.push({
|
||||
name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡',
|
||||
action: async () => {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${sp.id}/activate`, { method: 'POST' });
|
||||
if (resp.ok) { showToast(t('scenes.activated'), 'success'); }
|
||||
else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || t('scenes.error.activate_failed'), 'error'); }
|
||||
try {
|
||||
await apiPost(`/scene-presets/${sp.id}/activate`, undefined, {
|
||||
errorMessage: t('scenes.error.activate_failed'),
|
||||
});
|
||||
showToast(t('scenes.activated'), 'success');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message || t('scenes.error.activate_failed'), 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -209,14 +216,12 @@ const _responseKeys = [
|
||||
|
||||
async function _fetchAllEntities() {
|
||||
const [statesData, ...results] = await Promise.all([
|
||||
fetchWithAuth('/output-targets/batch/states', { retry: false, timeout: 5000 })
|
||||
.then(r => r.ok ? r.json() : {})
|
||||
.then((data: any) => data.states || {})
|
||||
apiGet<{ states?: any }>('/output-targets/batch/states', { retry: false, timeout: 5000 })
|
||||
.then((data) => data.states || {})
|
||||
.catch(() => ({})),
|
||||
..._responseKeys.map(([ep, key]) =>
|
||||
fetchWithAuth(ep as string, { retry: false, timeout: 5000 })
|
||||
.then((r: any) => r.ok ? r.json() : {})
|
||||
.then((data: any) => data[key as string] || [])
|
||||
apiGet<any>(ep as string, { retry: false, timeout: 5000 })
|
||||
.then((data) => data[key as string] || [])
|
||||
.catch((): any[] => [])),
|
||||
]);
|
||||
return _buildItems(results, statesData);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Supports creating, changing, and detaching connections via the graph editor.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from './api.ts';
|
||||
import { apiPut } from './api-client.ts';
|
||||
import {
|
||||
streamsCache, colorStripSourcesCache, valueSourcesCache,
|
||||
audioSourcesCache, outputTargetsCache, automationsCacheObj,
|
||||
@@ -151,11 +151,7 @@ export async function updateConnection(targetId: string, targetKind: string, fie
|
||||
const body = { [field]: newSourceId };
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) return false;
|
||||
await apiPut(url, body);
|
||||
// Invalidate the relevant cache so data refreshes
|
||||
if (entry.cache) entry.cache.invalidate();
|
||||
return true;
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
* attachProcessPicker(container, textarea);
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from './api.ts';
|
||||
import { escapeHtml } from './api.ts';
|
||||
import { apiGet } from './api-client.ts';
|
||||
import { t } from './i18n.ts';
|
||||
import { ICON_SEARCH } from './icons.ts';
|
||||
|
||||
@@ -241,16 +242,21 @@ class NamePalette {
|
||||
/* ─── fetch helpers ────────────────────────────────────────── */
|
||||
|
||||
async function _fetchProcesses(): Promise<string[]> {
|
||||
const resp = await fetchWithAuth('/system/processes');
|
||||
if (!resp || !resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
return data.processes || [];
|
||||
try {
|
||||
const data = await apiGet<{ processes?: string[] }>('/system/processes');
|
||||
return data.processes || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function _fetchNotificationApps(): Promise<string[]> {
|
||||
const resp = await fetchWithAuth('/color-strip-sources/os-notifications/history');
|
||||
if (!resp || !resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
let data: { history?: any[] };
|
||||
try {
|
||||
data = await apiGet<{ history?: any[] }>('/color-strip-sources/os-notifications/history');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const history: any[] = data.history || [];
|
||||
// Deduplicate app names, preserving original case of first occurrence
|
||||
const seen = new Map<string, string>();
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* Tags are stored lowercase, trimmed, deduplicated.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from './api.ts';
|
||||
import { apiGet } from './api-client.ts';
|
||||
|
||||
let _allTagsCache: string[] | null = null;
|
||||
let _allTagsFetchPromise: Promise<string[]> | null = null;
|
||||
@@ -22,8 +22,7 @@ let _allTagsFetchPromise: Promise<string[]> | null = null;
|
||||
export async function fetchAllTags(): Promise<string[]> {
|
||||
if (_allTagsCache) return _allTagsCache;
|
||||
if (_allTagsFetchPromise) return _allTagsFetchPromise;
|
||||
_allTagsFetchPromise = fetchWithAuth('/tags')
|
||||
.then(r => r.json())
|
||||
_allTagsFetchPromise = apiGet<{ tags?: string[] }>('/tags')
|
||||
.then(data => {
|
||||
_allTagsCache = data.tags || [];
|
||||
_allTagsFetchPromise = null;
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* The canvas shows monitor rectangles that can be repositioned for visual clarity.
|
||||
*/
|
||||
|
||||
import { API_BASE, fetchWithAuth } from '../core/api.ts';
|
||||
import { API_BASE } from '../core/api.ts';
|
||||
import { apiGet, apiPut } from '../core/api-client.ts';
|
||||
import { colorStripSourcesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
@@ -137,14 +138,14 @@ const _modal = new AdvancedCalibrationModal();
|
||||
|
||||
export async function showAdvancedCalibration(cssId: string): Promise<void> {
|
||||
try {
|
||||
const [cssSources, psResp] = await Promise.all([
|
||||
const [cssSources, psData] = await Promise.all([
|
||||
colorStripSourcesCache.fetch(),
|
||||
fetchWithAuth('/picture-sources'),
|
||||
apiGet<{ streams?: PictureSource[] }>('/picture-sources').catch((): { streams?: PictureSource[] } => ({})),
|
||||
]);
|
||||
const source = cssSources.find(s => s.id === cssId);
|
||||
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||
const calibration: Calibration = source.calibration || {} as Calibration;
|
||||
const psList = psResp.ok ? ((await psResp.json()).streams || []) : [];
|
||||
const psList = psData.streams || [];
|
||||
|
||||
_state.cssId = cssId;
|
||||
_state.sourceType = source.source_type || 'picture_advanced';
|
||||
@@ -223,22 +224,13 @@ export async function saveAdvancedCalibration(): Promise<void> {
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ source_type: _state.sourceType, calibration }),
|
||||
await apiPut(`/color-strip-sources/${cssId}`, { source_type: _state.sourceType, calibration }, {
|
||||
errorMessage: t('calibration.error.save_failed'),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showToast(t('calibration.saved'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
_modal.forceClose();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
const detail = err.detail || err.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
showToast(detailStr || t('calibration.error.save_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('calibration.saved'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
_modal.forceClose();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(error.message || t('calibration.error.save_failed'), 'error');
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
_cachedAudioFilterDefs,
|
||||
audioFilterDefsCache,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
@@ -158,9 +159,7 @@ export async function editAudioProcessingTemplate(templateId: string) {
|
||||
try {
|
||||
if (_cachedAudioFilterDefs.length === 0) await audioFilterDefsCache.fetch();
|
||||
|
||||
const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`);
|
||||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||||
const tmpl = await response.json();
|
||||
const tmpl = await apiGet<any>(`/audio-processing-templates/${templateId}`);
|
||||
|
||||
document.getElementById('apt-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_processing.edit')}`;
|
||||
(document.getElementById('apt-id') as HTMLInputElement).value = templateId;
|
||||
@@ -212,13 +211,10 @@ export async function saveAudioProcessingTemplate() {
|
||||
};
|
||||
|
||||
try {
|
||||
const url = templateId ? `/audio-processing-templates/${templateId}` : '/audio-processing-templates';
|
||||
const method = templateId ? 'PUT' : 'POST';
|
||||
const response = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to save template');
|
||||
if (templateId) {
|
||||
await apiPut(`/audio-processing-templates/${templateId}`, payload, { errorMessage: t('audio_processing.error.save_failed') });
|
||||
} else {
|
||||
await apiPost('/audio-processing-templates', payload, { errorMessage: t('audio_processing.error.save_failed') });
|
||||
}
|
||||
|
||||
showToast(templateId ? t('audio_processing.updated') : t('audio_processing.created'), 'success');
|
||||
@@ -235,9 +231,7 @@ export async function saveAudioProcessingTemplate() {
|
||||
|
||||
export async function cloneAudioProcessingTemplate(templateId: string) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/audio-processing-templates/${templateId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load template');
|
||||
const tmpl = await resp.json();
|
||||
const tmpl = await apiGet<any>(`/audio-processing-templates/${templateId}`, { errorMessage: t('audio_processing.error.load') });
|
||||
await showAudioProcessingTemplateModal(tmpl);
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
@@ -252,11 +246,7 @@ export async function deleteAudioProcessingTemplate(templateId: string) {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||||
}
|
||||
await apiDelete(`/audio-processing-templates/${templateId}`, { errorMessage: t('audio_processing.error.delete') });
|
||||
showToast(t('audio_processing.deleted'), 'success');
|
||||
audioProcessingTemplatesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
*/
|
||||
|
||||
import { _cachedAudioSources, _cachedAudioTemplates, _cachedAudioProcessingTemplates, audioProcessingTemplatesCache, apiKey, audioSourcesCache } from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { API_BASE, escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { logError } from '../core/log.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -178,16 +179,10 @@ export async function saveAudioSource() {
|
||||
}
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/audio-sources/${id}` : '/audio-sources';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
if (id) {
|
||||
await apiPut(`/audio-sources/${id}`, payload);
|
||||
} else {
|
||||
await apiPost('/audio-sources', payload);
|
||||
}
|
||||
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
|
||||
audioSourceModal.forceClose();
|
||||
@@ -203,9 +198,7 @@ export async function saveAudioSource() {
|
||||
|
||||
export async function editAudioSource(sourceId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('audio_source.error.load'));
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>(`/audio-sources/${sourceId}`, { errorMessage: t('audio_source.error.load') });
|
||||
await showAudioSourceModal(data.source_type, data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -217,12 +210,9 @@ export async function editAudioSource(sourceId: any) {
|
||||
|
||||
export async function cloneAudioSource(sourceId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('audio_source.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showAudioSourceModal(data.source_type, data);
|
||||
const data = await apiGet<any>(`/audio-sources/${sourceId}`, { errorMessage: t('audio_source.error.load') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showAudioSourceModal(data.source_type, { ...rest, name: `${data.name} (copy)` });
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -236,11 +226,7 @@ export async function deleteAudioSource(sourceId: any) {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiDelete(`/audio-sources/${sourceId}`);
|
||||
showToast(t('audio_source.deleted'), 'success');
|
||||
audioSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
@@ -267,9 +253,7 @@ let _cachedDevicesByEngine = {};
|
||||
|
||||
async function _loadAudioDevices() {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/audio-devices');
|
||||
if (!resp.ok) throw new Error('fetch failed');
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ by_engine?: Record<string, any[]> }>('/audio-devices');
|
||||
_cachedDevicesByEngine = data.by_engine || {};
|
||||
} catch {
|
||||
_cachedDevicesByEngine = {};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@
|
||||
import {
|
||||
calibrationTestState, EDGE_TEST_COLORS, displaysCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.ts';
|
||||
import { API_BASE, getHeaders } from '../core/api.ts';
|
||||
import { apiGet, apiPut } from '../core/api-client.ts';
|
||||
import { colorStripSourcesCache, devicesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
@@ -92,10 +93,7 @@ async function _clearCSSTestMode() {
|
||||
const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value;
|
||||
if (!testDeviceId) return;
|
||||
try {
|
||||
await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ device_id: testDeviceId, edges: {} }),
|
||||
});
|
||||
await apiPut(`/color-strip-sources/${cssId}/calibration/test`, { device_id: testDeviceId, edges: {} });
|
||||
} catch (err) {
|
||||
console.error('Failed to clear CSS test mode:', err);
|
||||
}
|
||||
@@ -109,11 +107,8 @@ function _setOverlayBtnActive(active: any) {
|
||||
|
||||
async function _checkOverlayStatus(cssId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
_setOverlayBtnActive(data.active);
|
||||
}
|
||||
const data = await apiGet<{ active?: boolean }>(`/color-strip-sources/${cssId}/overlay/status`);
|
||||
_setOverlayBtnActive(!!data.active);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -121,9 +116,7 @@ export async function toggleCalibrationOverlay() {
|
||||
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value;
|
||||
if (!cssId) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`);
|
||||
if (!resp.ok) return;
|
||||
const { active } = await resp.json();
|
||||
const { active } = await apiGet<{ active?: boolean }>(`/color-strip-sources/${cssId}/overlay/status`);
|
||||
if (active) {
|
||||
await stopCSSOverlay(cssId);
|
||||
_setOverlayBtnActive(false);
|
||||
@@ -143,14 +136,11 @@ export async function toggleCalibrationOverlay() {
|
||||
|
||||
export async function showCalibration(deviceId: any) {
|
||||
try {
|
||||
const [response, displays] = await Promise.all([
|
||||
fetchWithAuth(`/devices/${deviceId}`),
|
||||
const [device, displays] = await Promise.all([
|
||||
apiGet<any>(`/devices/${deviceId}`),
|
||||
displaysCache.fetch().catch((): any[] => []),
|
||||
]);
|
||||
|
||||
if (!response.ok) { showToast(t('calibration.error.load_failed'), 'error'); return; }
|
||||
|
||||
const device = await response.json();
|
||||
const calibration = device.calibration;
|
||||
|
||||
const preview = document.querySelector('.calibration-preview') as HTMLElement;
|
||||
@@ -843,17 +833,9 @@ export async function toggleTestEdge(edge: any) {
|
||||
updateCalibrationPreview();
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ device_id: testDeviceId, edges }),
|
||||
await apiPut(`/color-strip-sources/${cssId}/calibration/test`, { device_id: testDeviceId, edges }, {
|
||||
errorMessage: t('calibration.error.test_toggle_failed'),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
error.textContent = detailStr || t('calibration.error.test_toggle_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to toggle CSS test edge:', err);
|
||||
@@ -875,17 +857,9 @@ export async function toggleTestEdge(edge: any) {
|
||||
updateCalibrationPreview();
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/devices/${deviceId}/calibration/test`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ edges })
|
||||
await apiPut(`/devices/${deviceId}/calibration/test`, { edges }, {
|
||||
errorMessage: t('calibration.error.test_toggle_failed'),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
error.textContent = detailStr || t('calibration.error.test_toggle_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to toggle test edge:', err);
|
||||
@@ -965,34 +939,23 @@ export async function saveCalibration() {
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (cssMode) {
|
||||
const cssSourceType = (document.getElementById('calibration-css-source-type') as HTMLInputElement).value || 'picture';
|
||||
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ source_type: cssSourceType, calibration, led_count: declaredLedCount }),
|
||||
await apiPut(`/color-strip-sources/${cssId}`, { source_type: cssSourceType, calibration, led_count: declaredLedCount }, {
|
||||
errorMessage: t('calibration.error.save_failed'),
|
||||
});
|
||||
} else {
|
||||
response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(calibration),
|
||||
await apiPut(`/devices/${deviceId}/calibration`, calibration, {
|
||||
errorMessage: t('calibration.error.save_failed'),
|
||||
});
|
||||
}
|
||||
if (response.ok) {
|
||||
showToast(t('calibration.saved'), 'success');
|
||||
if (cssMode) colorStripSourcesCache.invalidate();
|
||||
calibModal.forceClose();
|
||||
if (cssMode) {
|
||||
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||
} else {
|
||||
window.loadDevices();
|
||||
}
|
||||
showToast(t('calibration.saved'), 'success');
|
||||
if (cssMode) colorStripSourcesCache.invalidate();
|
||||
calibModal.forceClose();
|
||||
if (cssMode) {
|
||||
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
error.textContent = detailStr || t('calibration.error.save_failed');
|
||||
error.style.display = 'block';
|
||||
window.loadDevices();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* Surface keys are free-form strings — anything calling `setCardMode` is
|
||||
* implicitly registering that key. Defaults are returned for unknown keys.
|
||||
*/
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { apiGet, apiPut } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
|
||||
const LS_KEY = 'card_modes_v1';
|
||||
@@ -131,10 +131,7 @@ function _scheduleServerPush(): void {
|
||||
|
||||
async function _pushToServer(prefs: CardModePrefsV1): Promise<void> {
|
||||
try {
|
||||
await fetchWithAuth('/preferences/card-modes', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(prefs),
|
||||
});
|
||||
await apiPut('/preferences/card-modes', prefs);
|
||||
} catch (e) {
|
||||
console.warn('card-modes server PUT failed', e);
|
||||
}
|
||||
@@ -160,9 +157,7 @@ export function hydrateCardModesFromCache(): CardModePrefsV1 {
|
||||
export async function syncCardModesFromServer(): Promise<void> {
|
||||
if (_serverSyncedOnce) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/preferences/card-modes');
|
||||
if (!resp || !resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>('/preferences/card-modes');
|
||||
if (data && typeof data === 'object' && (data as Record<string, unknown>).version) {
|
||||
_current = _normalise(data);
|
||||
_persistLocal();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Extracted from color-strips.ts to reduce file size.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from '../../core/api.ts';
|
||||
import { apiPost, apiPut, apiDelete } from '../../core/api-client.ts';
|
||||
import { gradientsCache, GradientEntity } from '../../core/state.ts';
|
||||
import { t } from '../../core/i18n.ts';
|
||||
import { showToast, showConfirm } from '../../core/ui.ts';
|
||||
@@ -98,11 +98,7 @@ export async function promptAndSaveGradientPreset() {
|
||||
color: s.color,
|
||||
}));
|
||||
try {
|
||||
await fetchWithAuth('/gradients', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), stops }),
|
||||
});
|
||||
await apiPost('/gradients', { name: name.trim(), stops });
|
||||
await gradientsCache.fetch({ force: true });
|
||||
showToast(t('color_strip.gradient.preset.saved'), 'success');
|
||||
} catch (e: any) {
|
||||
@@ -112,7 +108,7 @@ export async function promptAndSaveGradientPreset() {
|
||||
|
||||
export async function deleteAndRefreshGradientPreset(gradientId: any) {
|
||||
try {
|
||||
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
|
||||
await apiDelete(`/gradients/${gradientId}`);
|
||||
await gradientsCache.fetch({ force: true });
|
||||
showToast(t('color_strip.gradient.preset.deleted'), 'success');
|
||||
} catch (e: any) {
|
||||
@@ -221,12 +217,10 @@ export async function saveGradientEntity() {
|
||||
const payload: any = { name, stops, description, tags };
|
||||
|
||||
try {
|
||||
const url = id ? `/gradients/${id}` : '/gradients';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
||||
if (!res!.ok) {
|
||||
const err = await res!.json();
|
||||
throw new Error(err.detail || 'Failed to save gradient');
|
||||
if (id) {
|
||||
await apiPut(`/gradients/${id}`, payload, { errorMessage: t('gradient.error.save_failed') });
|
||||
} else {
|
||||
await apiPost('/gradients', payload, { errorMessage: t('gradient.error.save_failed') });
|
||||
}
|
||||
|
||||
showToast(id ? t('gradient.updated') : t('gradient.created'), 'success');
|
||||
@@ -256,7 +250,7 @@ export async function deleteGradient(gradientId: string) {
|
||||
const ok = await showConfirm(t('gradient.confirm_delete', { name: g.name }));
|
||||
if (!ok) return;
|
||||
try {
|
||||
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
|
||||
await apiDelete(`/gradients/${gradientId}`, { errorMessage: t('gradient.error.delete_failed') });
|
||||
gradientsCache.invalidate();
|
||||
showToast(t('gradient.deleted'), 'success');
|
||||
if (window.loadPictureSources) await window.loadPictureSources();
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Extracted from color-strips.ts to reduce file size.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from '../../core/api.ts';
|
||||
import { escapeHtml } from '../../core/api.ts';
|
||||
import { apiGet, apiPost } from '../../core/api-client.ts';
|
||||
import { t } from '../../core/i18n.ts';
|
||||
import { showToast } from '../../core/ui.ts';
|
||||
import {
|
||||
@@ -313,20 +314,17 @@ export function ensureNotifSoundEntitySelect() {
|
||||
|
||||
export async function testNotification(sourceId: string) {
|
||||
try {
|
||||
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!;
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showToast(err.detail || t('color_strip.notification.test.error'), 'error');
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.streams_notified > 0) {
|
||||
const data = await apiPost<{ streams_notified?: number }>(
|
||||
`/color-strip-sources/${sourceId}/notify`, undefined,
|
||||
{ errorMessage: t('color_strip.notification.test.error') },
|
||||
);
|
||||
if ((data.streams_notified ?? 0) > 0) {
|
||||
showToast(t('color_strip.notification.test.ok'), 'success');
|
||||
} else {
|
||||
showToast(t('color_strip.notification.test.no_streams'), 'warning');
|
||||
}
|
||||
} catch {
|
||||
showToast(t('color_strip.notification.test.error'), 'error');
|
||||
} catch (e: any) {
|
||||
showToast(e?.message || t('color_strip.notification.test.error'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,9 +353,7 @@ async function _loadNotificationHistory() {
|
||||
if (!list) return;
|
||||
|
||||
try {
|
||||
const resp = (await fetchWithAuth('/color-strip-sources/os-notifications/history'))!;
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>('/color-strip-sources/os-notifications/history');
|
||||
|
||||
if (!data.available) {
|
||||
list.innerHTML = '';
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* not a closed enum. New cards can be added in v1.1+ (audio meters, alerts,
|
||||
* preview strips, etc.) without a schema bump or migration.
|
||||
*/
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { apiGet, apiPut } from '../core/api-client.ts';
|
||||
|
||||
const LS_KEY = 'dashboard_layout_v1';
|
||||
const SCHEMA_VERSION = 1;
|
||||
@@ -397,9 +397,7 @@ export function hydrateDashboardLayoutFromCache(): DashboardLayoutV1 {
|
||||
export async function syncDashboardLayoutFromServer(): Promise<void> {
|
||||
if (_serverSyncedOnce) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/preferences/dashboard-layout');
|
||||
if (!resp || !resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>('/preferences/dashboard-layout');
|
||||
if (data && typeof data === 'object' && data.version) {
|
||||
const merged = _mergeWithDefaults(data);
|
||||
_current = merged;
|
||||
@@ -431,10 +429,7 @@ export function saveDashboardLayout(next: DashboardLayoutV1): void {
|
||||
|
||||
async function _pushToServer(layout: DashboardLayoutV1): Promise<void> {
|
||||
try {
|
||||
await fetchWithAuth('/preferences/dashboard-layout', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(layout),
|
||||
});
|
||||
await apiPut('/preferences/dashboard-layout', layout);
|
||||
} catch (e) {
|
||||
console.warn('dashboard layout PUT failed', e);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
_discoveryCache, set_discoveryCache,
|
||||
csptCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
|
||||
import { API_BASE, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost } from '../core/api-client.ts';
|
||||
import { devicesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, desktopFocus } from '../core/ui.ts';
|
||||
@@ -1036,20 +1037,11 @@ export async function scanForDevices(forceType?: any) {
|
||||
|
||||
try {
|
||||
const scanTimeout = scanType === 'ble' ? 8 : 3;
|
||||
const response = await fetchWithAuth(`/devices/discover?timeout=${scanTimeout}&device_type=${encodeURIComponent(scanType)}`);
|
||||
const data = await apiGet<{ devices?: any[] }>(`/devices/discover?timeout=${scanTimeout}&device_type=${encodeURIComponent(scanType)}`);
|
||||
|
||||
loading.style.display = 'none';
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
|
||||
if (!response.ok) {
|
||||
if (!isSerialDevice(scanType)) {
|
||||
empty.style.display = 'block';
|
||||
(empty.querySelector('small') as HTMLElement).textContent = t('device.scan.error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
_discoveryCache[scanType] = data.devices || [];
|
||||
|
||||
// Only render if the user is still on this type
|
||||
@@ -1267,36 +1259,25 @@ export async function handleAddDevice(event: any) {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetchWithAuth('/devices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
// result is logged by the API layer; no console.log here.
|
||||
showToast(t('device_discovery.added'), 'success');
|
||||
devicesCache.invalidate();
|
||||
addDeviceModal.forceClose();
|
||||
if (typeof window.loadDevices === 'function') await window.loadDevices();
|
||||
if (!localStorage.getItem('deviceTutorialSeen')) {
|
||||
localStorage.setItem('deviceTutorialSeen', '1');
|
||||
setTimeout(() => {
|
||||
if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial();
|
||||
}, 300);
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error('Failed to add device:', errorData);
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
error.textContent = detailStr || t('device_discovery.error.add_failed');
|
||||
error.style.display = 'block';
|
||||
await apiPost('/devices', body, { errorMessage: t('device_discovery.error.add_failed') });
|
||||
showToast(t('device_discovery.added'), 'success');
|
||||
devicesCache.invalidate();
|
||||
addDeviceModal.forceClose();
|
||||
if (typeof window.loadDevices === 'function') await window.loadDevices();
|
||||
if (!localStorage.getItem('deviceTutorialSeen')) {
|
||||
localStorage.setItem('deviceTutorialSeen', '1');
|
||||
setTimeout(() => {
|
||||
if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial();
|
||||
}, 300);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to add device:', err);
|
||||
showToast(err.message || t('device_discovery.error.add_failed'), 'error');
|
||||
// Surface the message inline (HTTP errors carry the server detail,
|
||||
// array-detail is joined by the api-client; network errors fall back
|
||||
// to the localised default).
|
||||
error.textContent = err.message || t('device_discovery.error.add_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1315,17 +1296,15 @@ export async function _fetchOpenrgbZones(baseUrl: any, containerId: any, preChec
|
||||
container.innerHTML = `<span class="zone-loading">${t('device.openrgb.zone.loading')}</span>`;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
container.innerHTML = `<span class="zone-error">${err.detail || t('device.openrgb.zone.error')}</span>`;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ zones?: any[] }>(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`, {
|
||||
errorMessage: t('device.openrgb.zone.error'),
|
||||
});
|
||||
_renderZoneCheckboxes(container, data.zones, preChecked);
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
container.innerHTML = `<span class="zone-error">${t('device.openrgb.zone.error')}</span>`;
|
||||
// HTTP errors carry the server detail in err.message; fall back to
|
||||
// the localised generic on network errors.
|
||||
container.innerHTML = `<span class="zone-error">${escapeHtml(err.message || t('device.openrgb.zone.error'))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1733,9 +1712,7 @@ function _showGameSenseFields(show: boolean) {
|
||||
|
||||
export async function cloneDevice(deviceId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load device');
|
||||
const device = await resp.json();
|
||||
const device = await apiGet<any>(`/devices/${deviceId}`, { errorMessage: t('device.error.load_failed') });
|
||||
showAddDevice(device.device_type || 'wled', device);
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
_deviceBrightnessCache, updateDeviceBrightness,
|
||||
csptCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isGroupDevice } from '../core/api.ts';
|
||||
import { API_BASE, getHeaders, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isGroupDevice } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { devicesCache } from '../core/state.ts';
|
||||
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureDdpColorOrderIconSelect, destroyDdpColorOrderIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
@@ -363,19 +364,13 @@ export async function turnOffDevice(deviceId: any) {
|
||||
const confirmed = await showConfirm(t('confirm.turn_off_device'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ power: false })
|
||||
await apiPut(`/devices/${deviceId}/power`, { power: false }, {
|
||||
errorMessage: t('device.error.power_off_failed'),
|
||||
});
|
||||
if (setResp.ok) {
|
||||
showToast(t('device.power.off_success'), 'success');
|
||||
} else {
|
||||
const error = await setResp.json();
|
||||
showToast(error.detail || 'Failed', 'error');
|
||||
}
|
||||
showToast(t('device.power.off_success'), 'success');
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('device.error.power_off_failed'), 'error');
|
||||
showToast(error.message || t('device.error.power_off_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,23 +378,19 @@ export async function pingDevice(deviceId: any) {
|
||||
const btn = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"] .card-ping-btn`) as HTMLElement | null;
|
||||
if (btn) btn.classList.add('spinning');
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?';
|
||||
showToast(data.device_online
|
||||
? t('device.ping.online', { ms })
|
||||
: t('device.ping.offline'), data.device_online ? 'success' : 'error');
|
||||
// Refresh device cards to update health dot
|
||||
devicesCache.invalidate();
|
||||
await window.loadDevices();
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
showToast(err.detail || 'Ping failed', 'error');
|
||||
}
|
||||
const data = await apiPost<{ device_online?: boolean; device_latency_ms?: number }>(
|
||||
`/devices/${deviceId}/ping`, undefined, { errorMessage: t('device.ping.error') },
|
||||
);
|
||||
const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?';
|
||||
showToast(data.device_online
|
||||
? t('device.ping.online', { ms })
|
||||
: t('device.ping.offline'), data.device_online ? 'success' : 'error');
|
||||
// Refresh device cards to update health dot
|
||||
devicesCache.invalidate();
|
||||
await window.loadDevices();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('device.ping.error'), 'error');
|
||||
showToast(error.message || t('device.ping.error'), 'error');
|
||||
} finally {
|
||||
if (btn) btn.classList.remove('spinning');
|
||||
}
|
||||
@@ -414,30 +405,20 @@ export async function removeDevice(deviceId: any) {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/devices/${deviceId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('device.removed'), 'success');
|
||||
devicesCache.invalidate();
|
||||
window.loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.detail || t('device.error.remove_failed'), 'error');
|
||||
}
|
||||
await apiDelete(`/devices/${deviceId}`, { errorMessage: t('device.error.remove_failed') });
|
||||
showToast(t('device.removed'), 'success');
|
||||
devicesCache.invalidate();
|
||||
window.loadDevices();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to remove device:', error);
|
||||
showToast(t('device.error.remove_failed'), 'error');
|
||||
showToast(error.message || t('device.error.remove_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function showSettings(deviceId: any) {
|
||||
try {
|
||||
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`);
|
||||
if (!deviceResponse.ok) { showToast(t('device.error.settings_load_failed'), 'error'); return; }
|
||||
|
||||
const device = await deviceResponse.json();
|
||||
const device = await apiGet<any>(`/devices/${deviceId}`, { errorMessage: t('device.error.settings_load_failed') });
|
||||
const isAdalight = isSerialDevice(device.device_type);
|
||||
const caps = device.capabilities || [];
|
||||
|
||||
@@ -934,18 +915,7 @@ export async function saveDeviceSettings() {
|
||||
}
|
||||
const csptId = (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '';
|
||||
body.default_css_processing_template_id = csptId;
|
||||
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!deviceResponse.ok) {
|
||||
const errorData = await deviceResponse.json();
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
settingsModal.showError(detailStr || t('device.error.update'));
|
||||
return;
|
||||
}
|
||||
await apiPut(`/devices/${deviceId}`, body, { errorMessage: t('device.error.update') });
|
||||
|
||||
showToast(t('settings.saved'), 'success');
|
||||
devicesCache.invalidate();
|
||||
@@ -978,16 +948,9 @@ export async function saveCardBrightness(deviceId: any, value: any) {
|
||||
const bri = parseInt(value);
|
||||
updateDeviceBrightness(deviceId, bri);
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ brightness: bri })
|
||||
await apiPut(`/devices/${deviceId}/brightness`, { brightness: bri }, {
|
||||
errorMessage: t('device.error.brightness'),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
const detail = errData.detail || errData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
showToast(detailStr || t('device.error.brightness'), 'error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
showToast(err.message || t('device.error.brightness'), 'error');
|
||||
@@ -999,9 +962,7 @@ export async function fetchDeviceBrightness(deviceId: any) {
|
||||
if (_brightnessFetchInFlight.has(deviceId)) return;
|
||||
_brightnessFetchInFlight.add(deviceId);
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>(`/devices/${deviceId}/brightness`);
|
||||
updateDeviceBrightness(deviceId, data.brightness);
|
||||
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null;
|
||||
if (slider) {
|
||||
@@ -1078,9 +1039,7 @@ async function _populateSettingsSerialPorts(currentUrl: any) {
|
||||
|
||||
try {
|
||||
const discoverType = settingsModal.deviceType || 'adalight';
|
||||
const resp = await fetchWithAuth(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ devices?: any[] }>(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`);
|
||||
const devices = data.devices || [];
|
||||
|
||||
select.innerHTML = '';
|
||||
@@ -1154,11 +1113,9 @@ export async function enrichOpenrgbZoneBadges(deviceId: any, deviceUrl: any) {
|
||||
_zoneCountInFlight.add(baseUrl);
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ zones?: Array<{ name: string; led_count: number }> }>(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
|
||||
const counts: any = {};
|
||||
for (const z of data.zones) {
|
||||
for (const z of (data.zones || [])) {
|
||||
counts[z.name.toLowerCase()] = z.led_count;
|
||||
}
|
||||
_zoneCountCache[baseUrl] = counts;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
availableEngines,
|
||||
} from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { apiGet, apiPost } from '../core/api-client.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
import type { Display } from '../types.ts';
|
||||
|
||||
@@ -87,9 +87,7 @@ async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void>
|
||||
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/config/displays?engine_type=${engineType}`);
|
||||
if (!resp.ok) throw new Error(`${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ displays?: Display[] }>(`/config/displays?engine_type=${engineType}`);
|
||||
const displays = data.displays || [];
|
||||
|
||||
// Store in cache so selectDisplay() can look them up
|
||||
@@ -137,14 +135,10 @@ window._adbConnectFromPicker = async function () {
|
||||
|
||||
input.disabled = true;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/adb/connect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ address }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Connection failed');
|
||||
}
|
||||
// No errorMessage option: the catch already prefixes the toast with
|
||||
// the localised `displays.picker.adb_connect.error` label, and the
|
||||
// server's `detail` (or `HTTP <status>` fallback) becomes the suffix.
|
||||
await apiPost('/adb/connect', { address });
|
||||
showToast(t('displays.picker.adb_connect.success'), 'success');
|
||||
|
||||
// Refresh the picker with updated device list
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
gameIntegrationsCache, gameAdaptersCache,
|
||||
_cachedGameIntegrations, _cachedGameAdapters,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -48,10 +49,8 @@ const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
// ── Bulk actions ──
|
||||
|
||||
function _bulkDeleteGameIntegrations(ids: string[]) {
|
||||
return Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/game-integrations/${id}`, { method: 'DELETE' })
|
||||
)).then(results => {
|
||||
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
|
||||
return Promise.allSettled(ids.map(id => apiDelete(`/game-integrations/${id}`))).then(results => {
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
||||
else showToast(t('game_integration.deleted'), 'success');
|
||||
gameIntegrationsCache.invalidate();
|
||||
@@ -192,13 +191,9 @@ export async function autoSetupGameIntegration() {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth(`/game-integrations/${id}/auto-setup`, { method: 'POST' });
|
||||
if (!res || !res.ok) {
|
||||
const err = await res!.json();
|
||||
showToast(err.detail || t('game_integration.auto_setup.failed'), 'error');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const data = await apiPost<{ success: boolean; file_path?: string; token_generated?: boolean; message?: string }>(
|
||||
`/game-integrations/${id}/auto-setup`, undefined, { errorMessage: t('game_integration.auto_setup.failed') },
|
||||
);
|
||||
if (data.success) {
|
||||
let msg = t('game_integration.auto_setup.success');
|
||||
if (data.file_path) msg += `\n${data.file_path}`;
|
||||
@@ -424,11 +419,8 @@ let _cachedPresets: EffectPreset[] = [];
|
||||
async function _loadPresets(): Promise<EffectPreset[]> {
|
||||
if (_cachedPresets.length > 0) return _cachedPresets;
|
||||
try {
|
||||
const res = await fetchWithAuth('/game-integrations/presets');
|
||||
if (res && res.ok) {
|
||||
const data = await res.json();
|
||||
_cachedPresets = data.presets || [];
|
||||
}
|
||||
const data = await apiGet<{ presets?: EffectPreset[] }>('/game-integrations/presets');
|
||||
_cachedPresets = data.presets || [];
|
||||
} catch { /* ignore */ }
|
||||
return _cachedPresets;
|
||||
}
|
||||
@@ -494,10 +486,8 @@ function _startEventMonitor(integrationId: string) {
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetchWithAuth(`/game-integrations/${integrationId}/events`);
|
||||
if (!res || !res.ok) return;
|
||||
const data = await res.json();
|
||||
const events: GameEventRecord[] = data.events || [];
|
||||
const data = await apiGet<{ events?: GameEventRecord[] }>(`/game-integrations/${integrationId}/events`);
|
||||
const events = data.events || [];
|
||||
if (events.length === 0) return;
|
||||
feed.innerHTML = events.slice(0, 20).map(ev => {
|
||||
const ts = new Date(ev.timestamp).toLocaleTimeString();
|
||||
@@ -535,9 +525,7 @@ export function testGameConnection() {
|
||||
_connectionTestTimer = setInterval(async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const res = await fetchWithAuth(`/game-integrations/${id}/status`);
|
||||
if (!res || !res.ok) return;
|
||||
const status: GameIntegrationStatus = await res.json();
|
||||
const status = await apiGet<GameIntegrationStatus>(`/game-integrations/${id}/status`);
|
||||
if (status.event_count > 0) {
|
||||
clearInterval(_connectionTestTimer!);
|
||||
_connectionTestTimer = null;
|
||||
@@ -725,12 +713,10 @@ export async function saveGameIntegration() {
|
||||
};
|
||||
|
||||
try {
|
||||
const url = id ? `/game-integrations/${id}` : '/game-integrations';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
||||
if (!res || !res.ok) {
|
||||
const err = await res!.json();
|
||||
throw new Error(err.detail || t('game_integration.error.save_failed'));
|
||||
if (id) {
|
||||
await apiPut(`/game-integrations/${id}`, payload, { errorMessage: t('game_integration.error.save_failed') });
|
||||
} else {
|
||||
await apiPost('/game-integrations', payload, { errorMessage: t('game_integration.error.save_failed') });
|
||||
}
|
||||
showToast(id ? t('game_integration.updated') : t('game_integration.created'), 'success');
|
||||
gameIntegrationsCache.invalidate();
|
||||
@@ -746,7 +732,7 @@ export async function deleteGameIntegration(entityId: string) {
|
||||
const ok = await showConfirm(t('game_integration.confirm_delete'));
|
||||
if (!ok) return;
|
||||
try {
|
||||
await fetchWithAuth(`/game-integrations/${entityId}`, { method: 'DELETE' });
|
||||
await apiDelete(`/game-integrations/${entityId}`, { errorMessage: t('game_integration.error.delete_failed') });
|
||||
showToast(t('game_integration.deleted'), 'success');
|
||||
gameIntegrationsCache.invalidate();
|
||||
loadGameIntegrations();
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
colorStripSourcesCache, outputTargetsCache, valueSourcesCache,
|
||||
getHAEntityFriendlyName, setHAEntityNames,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut } from '../core/api-client.ts';
|
||||
import { logError } from '../core/log.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
@@ -149,9 +150,7 @@ function _getEntityItems() {
|
||||
async function _fetchHAEntities(haSourceId: string): Promise<void> {
|
||||
if (!haSourceId) { _cachedHAEntities = []; return; }
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
|
||||
if (!resp.ok) { _cachedHAEntities = []; return; }
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`);
|
||||
_cachedHAEntities = data.entities || [];
|
||||
// Mirror into the shared cache so card chips/swatches across the
|
||||
// app pick up friendly names on the next render.
|
||||
@@ -381,9 +380,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
||||
|
||||
if (isEdit) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
editData = await resp.json();
|
||||
editData = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -542,22 +539,10 @@ export async function saveHALightEditor(): Promise<void> {
|
||||
payload.target_type = 'ha_light';
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (targetId) {
|
||||
response = await fetchWithAuth(`/output-targets/${targetId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await apiPut(`/output-targets/${targetId}`, payload);
|
||||
} else {
|
||||
response = await fetchWithAuth('/output-targets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${response.status}`);
|
||||
await apiPost('/output-targets', payload);
|
||||
}
|
||||
|
||||
showToast(targetId ? t('ha_light.updated') : t('ha_light.created'), 'success');
|
||||
@@ -579,12 +564,9 @@ export async function editHALightTarget(targetId: string): Promise<void> {
|
||||
|
||||
export async function cloneHALightTarget(targetId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showHALightEditor(null, data);
|
||||
const data = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showHALightEditor(null, { ...rest, name: `${data.name} (copy)` });
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -834,8 +816,7 @@ const _haLightActions: Record<string, (id: string) => void> = {
|
||||
|
||||
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
await apiPost(`/output-targets/${targetId}/${action}`);
|
||||
outputTargetsCache.invalidate();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} catch (e: any) {
|
||||
@@ -848,19 +829,13 @@ export async function turnOffHALightTarget(targetId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('confirm.turn_off_ha_light') || 'Turn off mapped lights?');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(
|
||||
`/output-targets/${targetId}/ha-light/turn-off`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (resp.ok) {
|
||||
showToast(t('ha_light.turn_off.success') || 'Lights turned off', 'success');
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showToast(err.detail || t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error');
|
||||
}
|
||||
await apiPost(`/output-targets/${targetId}/ha-light/turn-off`, undefined, {
|
||||
errorMessage: t('ha_light.turn_off.failed') || 'Failed to turn off lights',
|
||||
});
|
||||
showToast(t('ha_light.turn_off.success') || 'Lights turned off', 'success');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error');
|
||||
showToast(e.message || t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
_cachedHASources, haSourcesCache,
|
||||
_haEntityNamesCache, setHAEntityNames,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -75,12 +76,10 @@ const haSourceModal = new HASourceModal();
|
||||
export async function fetchHAEntities(haSourceId: string): Promise<void> {
|
||||
if (!haSourceId) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`);
|
||||
setHAEntityNames(haSourceId, data.entities || []);
|
||||
} catch {
|
||||
// Leave any existing cache entry intact.
|
||||
// Leave any existing cache entry intact (any non-2xx or network error).
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,16 +173,10 @@ export async function saveHASource(): Promise<void> {
|
||||
if (token) payload.token = token;
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/home-assistant/sources/${id}` : '/home-assistant/sources';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
if (id) {
|
||||
await apiPut(`/home-assistant/sources/${id}`, payload);
|
||||
} else {
|
||||
await apiPost('/home-assistant/sources', payload);
|
||||
}
|
||||
showToast(t(id ? 'ha_source.updated' : 'ha_source.created'), 'success');
|
||||
haSourceModal.forceClose();
|
||||
@@ -199,9 +192,7 @@ export async function saveHASource(): Promise<void> {
|
||||
|
||||
export async function editHASource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('ha_source.error.load'));
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<HomeAssistantSource>(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') });
|
||||
await showHASourceModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -211,12 +202,9 @@ export async function editHASource(sourceId: string): Promise<void> {
|
||||
|
||||
export async function cloneHASource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('ha_source.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showHASourceModal(data);
|
||||
const data = await apiGet<HomeAssistantSource>(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showHASourceModal({ ...rest, name: `${data.name} (copy)` } as HomeAssistantSource);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -227,11 +215,7 @@ export async function deleteHASource(sourceId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('ha_source.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiDelete(`/home-assistant/sources/${sourceId}`);
|
||||
showToast(t('ha_source.deleted'), 'success');
|
||||
haSourcesCache.invalidate();
|
||||
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
||||
@@ -251,9 +235,7 @@ export async function testHASource(): Promise<void> {
|
||||
if (testBtn) testBtn.classList.add('loading');
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${id}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiPost<HATestResult>(`/home-assistant/sources/${id}/test`);
|
||||
if (data.success) {
|
||||
showToast(`${t('ha_source.test.success')} | HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
|
||||
} else {
|
||||
@@ -267,6 +249,14 @@ export async function testHASource(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape returned by `POST /home-assistant/sources/{id}/test`. */
|
||||
interface HATestResult {
|
||||
success: boolean;
|
||||
ha_version?: string;
|
||||
entity_count?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ── Card rendering ──
|
||||
|
||||
export function createHASourceCard(source: HomeAssistantSource) {
|
||||
@@ -328,9 +318,7 @@ const _haSourceActions: Record<string, (id: string) => void> = {
|
||||
|
||||
async function _testHASourceFromCard(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiPost<HATestResult>(`/home-assistant/sources/${sourceId}/test`);
|
||||
if (data.success) {
|
||||
showToast(`HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
|
||||
} else {
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
import {
|
||||
_cachedHTTPEndpoints, httpEndpointsCache,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -315,16 +316,10 @@ export async function saveHTTPEndpoint(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/http/endpoints/${id}` : '/http/endpoints';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
if (id) {
|
||||
await apiPut(`/http/endpoints/${id}`, payload);
|
||||
} else {
|
||||
await apiPost('/http/endpoints', payload);
|
||||
}
|
||||
showToast(t(id ? 'http_endpoint.updated' : 'http_endpoint.created'), 'success');
|
||||
httpEndpointModal.forceClose();
|
||||
@@ -340,9 +335,7 @@ export async function saveHTTPEndpoint(): Promise<void> {
|
||||
|
||||
export async function editHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
|
||||
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
|
||||
const data: HTTPEndpoint = await resp.json();
|
||||
const data = await apiGet<HTTPEndpoint>(`/http/endpoints/${endpointId}`, { errorMessage: t('http_endpoint.error.load') });
|
||||
await showHTTPEndpointModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -352,14 +345,14 @@ export async function editHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
|
||||
export async function cloneHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
|
||||
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
// Cloning never reveals the token — user must re-enter if needed.
|
||||
data.auth_token_set = false;
|
||||
await showHTTPEndpointModal(data);
|
||||
const data = await apiGet<HTTPEndpoint>(`/http/endpoints/${endpointId}`, { errorMessage: t('http_endpoint.error.load') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showHTTPEndpointModal({
|
||||
...rest,
|
||||
name: `${data.name} (copy)`,
|
||||
// Cloning never reveals the token — user must re-enter if needed.
|
||||
auth_token_set: false,
|
||||
} as HTTPEndpoint);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -370,11 +363,7 @@ export async function deleteHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('http_endpoint.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiDelete(`/http/endpoints/${endpointId}`);
|
||||
showToast(t('http_endpoint.deleted'), 'success');
|
||||
httpEndpointsCache.invalidate();
|
||||
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
||||
@@ -427,13 +416,7 @@ export async function testHTTPEndpoint(): Promise<void> {
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/http/endpoints/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, method, auth_token: token, headers, timeout_s }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data: HTTPTestResponse = await resp.json();
|
||||
const data = await apiPost<HTTPTestResponse>('/http/endpoints/test', { url, method, auth_token: token, headers, timeout_s });
|
||||
_renderTestResult(out, data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -493,12 +476,7 @@ function _renderTestResult(out: HTMLElement, data: HTTPTestResponse) {
|
||||
|
||||
async function _testHTTPEndpointFromCard(endpointId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}/test`, { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const data: HTTPTestResponse = await resp.json();
|
||||
const data = await apiPost<HTTPTestResponse>(`/http/endpoints/${endpointId}/test`);
|
||||
if (data.success) {
|
||||
const status = data.status_code != null ? ` (${data.status_code})` : '';
|
||||
showToast(`${t('http_endpoint.test.success')}${status}`, 'success');
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiPut } from '../core/api-client.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
import { devicesCache, outputTargetsCache } from '../core/state.ts';
|
||||
import {
|
||||
@@ -494,22 +495,14 @@ async function _applyChange(nextIconId: string, nextColor: string): Promise<void
|
||||
if (adapter.bodyExtras) {
|
||||
Object.assign(body, adapter.bodyExtras(entityId));
|
||||
}
|
||||
const resp = await fetchWithAuth(adapter.endpoint(entityId), {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showToast((err && (err as any).detail) || t('device.icon.error.save_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
await apiPut(adapter.endpoint(entityId), body, { errorMessage: t('device.icon.error.save_failed') });
|
||||
if (nextIconId) _pushRecent(nextIconId);
|
||||
showToast(t('device.icon.saved') || 'Icon saved', 'success');
|
||||
await adapter.reload();
|
||||
closeIconPicker();
|
||||
} catch (error: any) {
|
||||
if (error?.isAuth) return;
|
||||
showToast(t('device.icon.error.save_failed') || 'Failed to save icon', 'error');
|
||||
showToast(error?.message || t('device.icon.error.save_failed') || 'Failed to save icon', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { CardSection } from '../core/card-sections.ts';
|
||||
import { TreeNav } from '../core/tree-nav.ts';
|
||||
import { updateSubTabHash } from './tabs.ts';
|
||||
import { getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { apiDelete } from '../core/api-client.ts';
|
||||
import { showToast, setTabRefreshing } from '../core/ui.ts';
|
||||
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
|
||||
@@ -29,10 +29,8 @@ import * as P from '../core/icon-paths.ts';
|
||||
|
||||
function _bulkDeleteFactory(endpoint: string, cache: any, toast: string) {
|
||||
return async (ids: string[]) => {
|
||||
const results = await Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/${endpoint}/${id}`, { method: 'DELETE' })
|
||||
));
|
||||
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
|
||||
const results = await Promise.allSettled(ids.map(id => apiDelete(`/${endpoint}/${id}`)));
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
||||
else showToast(t(toast), 'success');
|
||||
cache.invalidate();
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
import { mqttSourcesCache } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -144,16 +145,10 @@ export async function saveMQTTSource(): Promise<void> {
|
||||
if (password) payload.password = password;
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/mqtt/sources/${id}` : '/mqtt/sources';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
if (id) {
|
||||
await apiPut(`/mqtt/sources/${id}`, payload);
|
||||
} else {
|
||||
await apiPost('/mqtt/sources', payload);
|
||||
}
|
||||
showToast(t(id ? 'mqtt_source.updated' : 'mqtt_source.created'), 'success');
|
||||
mqttSourceModal.forceClose();
|
||||
@@ -168,9 +163,7 @@ export async function saveMQTTSource(): Promise<void> {
|
||||
|
||||
export async function editMQTTSource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('mqtt_source.error.load'));
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<MQTTSource>(`/mqtt/sources/${sourceId}`, { errorMessage: t('mqtt_source.error.load') });
|
||||
await showMQTTSourceModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -180,12 +173,9 @@ export async function editMQTTSource(sourceId: string): Promise<void> {
|
||||
|
||||
export async function cloneMQTTSource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('mqtt_source.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showMQTTSourceModal(data);
|
||||
const data = await apiGet<MQTTSource>(`/mqtt/sources/${sourceId}`, { errorMessage: t('mqtt_source.error.load') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showMQTTSourceModal({ ...rest, name: `${data.name} (copy)` } as MQTTSource);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -196,11 +186,7 @@ export async function deleteMQTTSource(sourceId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('mqtt_source.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiDelete(`/mqtt/sources/${sourceId}`);
|
||||
showToast(t('mqtt_source.deleted'), 'success');
|
||||
mqttSourcesCache.invalidate();
|
||||
} catch (e: any) {
|
||||
@@ -219,9 +205,7 @@ export async function testMQTTSource(): Promise<void> {
|
||||
if (testBtn) testBtn.classList.add('loading');
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/mqtt/sources/${id}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiPost<MQTTTestResult>(`/mqtt/sources/${id}/test`);
|
||||
if (data.success) {
|
||||
showToast(t('mqtt_source.test.success'), 'success');
|
||||
} else {
|
||||
@@ -235,11 +219,15 @@ export async function testMQTTSource(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape returned by `POST /mqtt/sources/{id}/test`. */
|
||||
interface MQTTTestResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function _testMQTTSourceFromCard(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiPost<MQTTTestResult>(`/mqtt/sources/${sourceId}/test`);
|
||||
if (data.success) {
|
||||
showToast(t('mqtt_source.test.success'), 'success');
|
||||
} else {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* settings.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { apiGet, apiPut } from '../core/api-client.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { logError } from '../core/log.ts';
|
||||
@@ -102,9 +102,7 @@ export async function startNotificationsWatcher(): Promise<void> {
|
||||
/** Pull the latest prefs from the server and cache them. */
|
||||
export async function refreshNotificationPreferences(): Promise<NotificationPreferences> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/preferences/notifications');
|
||||
if (!resp.ok) return _prefs;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>('/preferences/notifications');
|
||||
_prefs = { ...DEFAULT_PREFS, ...data, channels: { ...DEFAULT_PREFS.channels, ...(data.channels || {}) } };
|
||||
} catch (err) {
|
||||
logError('notifications.fetch', err);
|
||||
@@ -116,15 +114,7 @@ export async function refreshNotificationPreferences(): Promise<NotificationPref
|
||||
export async function saveNotificationPreferences(
|
||||
next: NotificationPreferences,
|
||||
): Promise<NotificationPreferences> {
|
||||
const resp = await fetchWithAuth('/preferences/notifications', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const saved = await resp.json();
|
||||
const saved = await apiPut<any>('/preferences/notifications', next);
|
||||
_prefs = { ...DEFAULT_PREFS, ...saved, channels: { ...DEFAULT_PREFS.channels, ...(saved.channels || {}) } };
|
||||
return _prefs;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
PATTERN_RECT_BORDERS,
|
||||
streamsCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { API_BASE, getHeaders, escapeHtml } from '../core/api.ts';
|
||||
import { apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { patternTemplatesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
||||
@@ -260,20 +261,10 @@ export async function savePatternTemplate(): Promise<void> {
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (templateId) {
|
||||
response = await fetchWithAuth(`/pattern-templates/${templateId}`, {
|
||||
method: 'PUT', body: JSON.stringify(payload),
|
||||
});
|
||||
await apiPut(`/pattern-templates/${templateId}`, payload, { errorMessage: t('pattern.error.save_failed') });
|
||||
} else {
|
||||
response = await fetchWithAuth('/pattern-templates', {
|
||||
method: 'POST', body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || 'Failed to save');
|
||||
await apiPost('/pattern-templates', payload, { errorMessage: t('pattern.error.save_failed') });
|
||||
}
|
||||
|
||||
showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success');
|
||||
@@ -305,20 +296,13 @@ export async function deletePatternTemplate(templateId: string): Promise<void> {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/pattern-templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('pattern.deleted'), 'success');
|
||||
patternTemplatesCache.invalidate();
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.detail || t('pattern.error.delete_failed'), 'error');
|
||||
}
|
||||
await apiDelete(`/pattern-templates/${templateId}`, { errorMessage: t('pattern.error.delete_failed') });
|
||||
showToast(t('pattern.deleted'), 'success');
|
||||
patternTemplatesCache.invalidate();
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('pattern.error.delete_failed'), 'error');
|
||||
showToast(error.message || t('pattern.error.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* cheap for 120-sample lines.
|
||||
*/
|
||||
|
||||
import { fetchMetricsHistory, fetchWithAuth } from '../core/api.ts';
|
||||
import { fetchMetricsHistory } from '../core/api.ts';
|
||||
import { apiGet } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { dashboardPollInterval } from '../core/state.ts';
|
||||
import { isActiveTab } from '../core/tab-registry.ts';
|
||||
@@ -1102,9 +1103,7 @@ function _renderValuePair(key: string, sysVal: string, appVal: string | null): v
|
||||
|
||||
async function _fetchPerformance(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/performance');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>('/system/performance');
|
||||
_lastFetchData = data;
|
||||
_applyPerfDataToDom(data, /*pushHistory=*/true);
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Rendered as a CardSection inside the Automations tab, plus dashboard compact cards.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
@@ -135,10 +136,8 @@ export const csScenes = new CardSection('scenes', {
|
||||
bulkActions: [{
|
||||
key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete',
|
||||
handler: async (ids) => {
|
||||
const results = await Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/scene-presets/${id}`, { method: 'DELETE' })
|
||||
));
|
||||
const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
|
||||
const results = await Promise.allSettled(ids.map(id => apiDelete(`/scene-presets/${id}`)));
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
||||
else showToast(t('scenes.deleted'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
@@ -384,37 +383,22 @@ export async function saveScenePreset(): Promise<void> {
|
||||
const tags = _sceneTagsInput ? _sceneTagsInput.getValue() : [];
|
||||
|
||||
try {
|
||||
let resp;
|
||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => (el as HTMLElement).dataset.targetId);
|
||||
const body = { name, description, target_ids, tags };
|
||||
if (_editingId) {
|
||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => (el as HTMLElement).dataset.targetId);
|
||||
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description, target_ids, tags }),
|
||||
});
|
||||
await apiPut(`/scene-presets/${_editingId}`, body, { errorMessage: t('scenes.error.save_failed') });
|
||||
} else {
|
||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => (el as HTMLElement).dataset.targetId);
|
||||
resp = await fetchWithAuth('/scene-presets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description, target_ids, tags }),
|
||||
});
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
errorEl.textContent = err.detail || t('scenes.error.save_failed');
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
await apiPost('/scene-presets', body, { errorMessage: t('scenes.error.save_failed') });
|
||||
}
|
||||
|
||||
scenePresetModal.forceClose();
|
||||
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
errorEl.textContent = t('scenes.error.save_failed');
|
||||
errorEl.textContent = error.message || t('scenes.error.save_failed');
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
@@ -488,17 +472,9 @@ export async function addSceneTarget(): Promise<void> {
|
||||
|
||||
export async function activateScenePreset(presetId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${presetId}/activate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
const detail = errData.detail || errData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
showToast(detailStr || t('scenes.error.activate_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
const result = await resp.json();
|
||||
const result = await apiPost<{ status: string; errors: any[] }>(
|
||||
`/scene-presets/${presetId}/activate`, undefined, { errorMessage: t('scenes.error.activate_failed') },
|
||||
);
|
||||
if (result.status === 'activated') {
|
||||
showToast(t('scenes.activated'), 'success');
|
||||
} else {
|
||||
@@ -507,7 +483,7 @@ export async function activateScenePreset(presetId: string): Promise<void> {
|
||||
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('scenes.error.activate_failed'), 'error');
|
||||
showToast(error.message || t('scenes.error.activate_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,20 +496,11 @@ export async function recaptureScenePreset(presetId: string): Promise<void> {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${presetId}/recapture`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast(t('scenes.recaptured'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} else {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
const detail = errData.detail || errData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
showToast(detailStr || t('scenes.error.recapture_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
await apiPost(`/scene-presets/${presetId}/recapture`, undefined, { errorMessage: t('scenes.error.recapture_failed') });
|
||||
showToast(t('scenes.recaptured'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(error.message || t('scenes.error.recapture_failed'), 'error');
|
||||
}
|
||||
@@ -592,20 +559,11 @@ export async function deleteScenePreset(presetId: string): Promise<void> {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${presetId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast(t('scenes.deleted'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} else {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
const detail = errData.detail || errData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
showToast(detailStr || t('scenes.error.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
await apiDelete(`/scene-presets/${presetId}`, { errorMessage: t('scenes.error.delete_failed') });
|
||||
showToast(t('scenes.deleted'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(error.message || t('scenes.error.delete_failed'), 'error');
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
audioTemplatesCache,
|
||||
apiKey,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { API_BASE, escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { logError } from '../core/log.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
@@ -61,9 +62,7 @@ const audioTemplateModal = new AudioTemplateModal();
|
||||
|
||||
async function loadAvailableAudioEngines() {
|
||||
try {
|
||||
const response = await fetchWithAuth('/audio-engines');
|
||||
if (!response.ok) throw new Error(`Failed to load audio engines: ${response.status}`);
|
||||
const data = await response.json();
|
||||
const data = await apiGet<{ engines?: any[] }>('/audio-engines');
|
||||
setAvailableAudioEngines(data.engines || []);
|
||||
|
||||
const select = document.getElementById('audio-template-engine') as HTMLSelectElement;
|
||||
@@ -232,9 +231,7 @@ export async function showAddAudioTemplateModal(cloneData: any = null) {
|
||||
|
||||
export async function editAudioTemplate(templateId: any) {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/audio-templates/${templateId}`);
|
||||
if (!response.ok) throw new Error(`Failed to load audio template: ${response.status}`);
|
||||
const template = await response.json();
|
||||
const template = await apiGet<any>(`/audio-templates/${templateId}`);
|
||||
|
||||
setCurrentEditingAudioTemplateId(templateId);
|
||||
document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.edit')}`;
|
||||
@@ -284,16 +281,10 @@ export async function saveAudioTemplate() {
|
||||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (templateId) {
|
||||
response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
await apiPut(`/audio-templates/${templateId}`, payload, { errorMessage: t('audio_template.error.save_failed') });
|
||||
} else {
|
||||
response = await fetchWithAuth('/audio-templates', { method: 'POST', body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to save audio template');
|
||||
await apiPost('/audio-templates', payload, { errorMessage: t('audio_template.error.save_failed') });
|
||||
}
|
||||
|
||||
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
|
||||
@@ -312,11 +303,7 @@ export async function deleteAudioTemplate(templateId: any) {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to delete audio template');
|
||||
}
|
||||
await apiDelete(`/audio-templates/${templateId}`, { errorMessage: t('audio_template.error.delete') });
|
||||
showToast(t('audio_template.deleted'), 'success');
|
||||
audioTemplatesCache.invalidate();
|
||||
await loadAudioTemplates();
|
||||
@@ -328,11 +315,9 @@ export async function deleteAudioTemplate(templateId: any) {
|
||||
|
||||
export async function cloneAudioTemplate(templateId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/audio-templates/${templateId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load audio template');
|
||||
const tmpl = await resp.json();
|
||||
const tmpl = await apiGet<any>(`/audio-templates/${templateId}`, { errorMessage: t('audio_template.error.load_failed') });
|
||||
showAddAudioTemplateModal(tmpl);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to clone audio template:', error);
|
||||
showToast(t('audio_template.error.clone_failed'), 'error');
|
||||
@@ -364,21 +349,18 @@ export async function showTestAudioTemplateModal(templateId: any) {
|
||||
// Load audio devices for picker — filter by engine type
|
||||
const deviceSelect = document.getElementById('test-audio-template-device') as HTMLSelectElement;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/audio-devices');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
// Use engine-specific device list if available, fall back to flat list
|
||||
const devices = (engineType && data.by_engine && data.by_engine[engineType])
|
||||
? data.by_engine[engineType]
|
||||
: (data.devices || []);
|
||||
deviceSelect.innerHTML = devices.map(d => {
|
||||
const label = d.name;
|
||||
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
||||
return `<option value="${val}">${escapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
if (devices.length === 0) {
|
||||
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
||||
}
|
||||
const data = await apiGet<{ by_engine?: Record<string, any[]>; devices?: any[] }>('/audio-devices');
|
||||
// Use engine-specific device list if available, fall back to flat list
|
||||
const devices = (engineType && data.by_engine && data.by_engine[engineType])
|
||||
? data.by_engine[engineType]
|
||||
: (data.devices || []);
|
||||
deviceSelect.innerHTML = devices.map(d => {
|
||||
const label = d.name;
|
||||
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
||||
return `<option value="${val}">${escapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
if (devices.length === 0) {
|
||||
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
||||
}
|
||||
} catch {
|
||||
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
captureTemplatesCache, displaysCache, enginesCache,
|
||||
apiKey,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { API_BASE, getHeaders, escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm, openLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setupBackdropClose } from '../core/ui.ts';
|
||||
@@ -105,9 +106,7 @@ export async function showAddTemplateModal(cloneData: any = null) {
|
||||
|
||||
export async function editTemplate(templateId: any) {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
|
||||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||||
const template = await response.json();
|
||||
const template = await apiGet<any>(`/capture-templates/${templateId}`);
|
||||
|
||||
setCurrentEditingTemplateId(templateId);
|
||||
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`;
|
||||
@@ -414,9 +413,7 @@ async function loadDisplaysForTest() {
|
||||
|
||||
// Always refetch for engines with own displays (devices may change); use cache for desktop
|
||||
if (!_cachedDisplays || engineHasOwnDisplays) {
|
||||
const response = await fetchWithAuth(url);
|
||||
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
|
||||
const displaysData = await response.json();
|
||||
const displaysData = await apiGet<{ displays?: any[] }>(url);
|
||||
displaysCache.update(displaysData.displays || []);
|
||||
}
|
||||
|
||||
@@ -607,16 +604,10 @@ export async function saveTemplate() {
|
||||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (templateId) {
|
||||
response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
await apiPut(`/capture-templates/${templateId}`, payload, { errorMessage: t('templates.error.save_failed') });
|
||||
} else {
|
||||
response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to save template');
|
||||
await apiPost('/capture-templates', payload, { errorMessage: t('templates.error.save_failed') });
|
||||
}
|
||||
|
||||
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
|
||||
@@ -635,11 +626,7 @@ export async function deleteTemplate(templateId: any) {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||||
}
|
||||
await apiDelete(`/capture-templates/${templateId}`, { errorMessage: t('templates.error.delete') });
|
||||
showToast(t('templates.deleted'), 'success');
|
||||
captureTemplatesCache.invalidate();
|
||||
await loadCaptureTemplates();
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
import { _cachedSyncClocks, syncClocksCache } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -124,16 +125,10 @@ export async function saveSyncClock(): Promise<void> {
|
||||
const payload = { name, speed, description, tags: _syncClockTagsInput ? _syncClockTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/sync-clocks/${id}` : '/sync-clocks';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
if (id) {
|
||||
await apiPut(`/sync-clocks/${id}`, payload);
|
||||
} else {
|
||||
await apiPost('/sync-clocks', payload);
|
||||
}
|
||||
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
|
||||
syncClockModal.forceClose();
|
||||
@@ -149,9 +144,7 @@ export async function saveSyncClock(): Promise<void> {
|
||||
|
||||
export async function editSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
|
||||
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<SyncClock>(`/sync-clocks/${clockId}`, { errorMessage: t('sync_clock.error.load') });
|
||||
await showSyncClockModal(data);
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
@@ -161,12 +154,9 @@ export async function editSyncClock(clockId: string): Promise<void> {
|
||||
|
||||
export async function cloneSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
|
||||
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showSyncClockModal(data);
|
||||
const data = await apiGet<SyncClock>(`/sync-clocks/${clockId}`, { errorMessage: t('sync_clock.error.load') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showSyncClockModal({ ...rest, name: `${data.name} (copy)` } as SyncClock);
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -177,11 +167,7 @@ export async function deleteSyncClock(clockId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('sync_clock.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiDelete(`/sync-clocks/${clockId}`);
|
||||
showToast(t('sync_clock.deleted'), 'success');
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
@@ -195,8 +181,7 @@ export async function deleteSyncClock(clockId: string): Promise<void> {
|
||||
|
||||
export async function pauseSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
await apiPost(`/sync-clocks/${clockId}/pause`);
|
||||
showToast(t('sync_clock.paused'), 'success');
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
@@ -208,8 +193,7 @@ export async function pauseSyncClock(clockId: string): Promise<void> {
|
||||
|
||||
export async function resumeSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
await apiPost(`/sync-clocks/${clockId}/resume`);
|
||||
showToast(t('sync_clock.resumed'), 'success');
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
@@ -221,8 +205,7 @@ export async function resumeSyncClock(clockId: string): Promise<void> {
|
||||
|
||||
export async function resetSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
await apiPost(`/sync-clocks/${clockId}/reset`);
|
||||
showToast(t('sync_clock.reset_done'), 'success');
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Auto-update — check for new releases, show banner, manage settings.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut } from '../core/api-client.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
@@ -129,10 +129,7 @@ export function dismissUpdate(): void {
|
||||
_hideBanner();
|
||||
_setVersionBadgeUpdate(false);
|
||||
|
||||
fetchWithAuth('/system/update/dismiss', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ version }),
|
||||
}).catch(() => {});
|
||||
apiPost('/system/update/dismiss', { version }).catch(() => {});
|
||||
}
|
||||
|
||||
// ─── Apply update ───────────────────────────────────────────
|
||||
@@ -151,14 +148,7 @@ export async function applyUpdate(): Promise<void> {
|
||||
btns.forEach(b => (b as HTMLButtonElement).disabled = true);
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/apply', {
|
||||
method: 'POST',
|
||||
timeout: 600000, // 10 min for download + apply
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiPost('/system/update/apply', undefined, { timeout: 600000 /* 10 min for download + apply */ });
|
||||
// Server will shut down — the frontend reconnect overlay handles the rest
|
||||
showToast(t('update.applying'), 'info');
|
||||
} catch (err) {
|
||||
@@ -171,9 +161,7 @@ export async function applyUpdate(): Promise<void> {
|
||||
|
||||
export async function loadUpdateStatus(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/status');
|
||||
if (!resp.ok) return;
|
||||
const status: UpdateStatus = await resp.json();
|
||||
const status = await apiGet<UpdateStatus>('/system/update/status');
|
||||
_lastStatus = status;
|
||||
_applyStatus(status);
|
||||
} catch {
|
||||
@@ -260,12 +248,7 @@ export async function checkForUpdates(): Promise<void> {
|
||||
if (spinner) spinner.style.display = '';
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/check', { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const status: UpdateStatus = await resp.json();
|
||||
const status = await apiPost<UpdateStatus>('/system/update/check');
|
||||
_lastStatus = status;
|
||||
_applyStatus(status);
|
||||
|
||||
@@ -350,9 +333,7 @@ export function initUpdateSettingsPanel(): void {
|
||||
|
||||
export async function loadUpdateSettings(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/settings');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ enabled: boolean; check_interval_hours: number; include_prerelease: boolean }>('/system/update/settings');
|
||||
|
||||
const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null;
|
||||
const intervalEl = document.getElementById('update-interval') as HTMLSelectElement | null;
|
||||
@@ -388,14 +369,7 @@ export async function saveUpdateSettings(): Promise<void> {
|
||||
if (Number.isNaN(check_interval_hours)) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled, check_interval_hours, include_prerelease }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiPut('/system/update/settings', { enabled, check_interval_hours, include_prerelease });
|
||||
} catch (err) {
|
||||
showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
import { _cachedWeatherSources, weatherSourcesCache } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -166,16 +167,10 @@ export async function saveWeatherSource(): Promise<void> {
|
||||
};
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/weather-sources/${id}` : '/weather-sources';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
if (id) {
|
||||
await apiPut(`/weather-sources/${id}`, payload);
|
||||
} else {
|
||||
await apiPost('/weather-sources', payload);
|
||||
}
|
||||
showToast(t(id ? 'weather_source.updated' : 'weather_source.created'), 'success');
|
||||
weatherSourceModal.forceClose();
|
||||
@@ -191,9 +186,7 @@ export async function saveWeatherSource(): Promise<void> {
|
||||
|
||||
export async function editWeatherSource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('weather_source.error.load'));
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<WeatherSource>(`/weather-sources/${sourceId}`, { errorMessage: t('weather_source.error.load') });
|
||||
await showWeatherSourceModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -203,12 +196,9 @@ export async function editWeatherSource(sourceId: string): Promise<void> {
|
||||
|
||||
export async function cloneWeatherSource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('weather_source.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showWeatherSourceModal(data);
|
||||
const data = await apiGet<WeatherSource>(`/weather-sources/${sourceId}`, { errorMessage: t('weather_source.error.load') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showWeatherSourceModal({ ...rest, name: `${data.name} (copy)` } as WeatherSource);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -219,11 +209,7 @@ export async function deleteWeatherSource(sourceId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('weather_source.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiDelete(`/weather-sources/${sourceId}`);
|
||||
showToast(t('weather_source.deleted'), 'success');
|
||||
weatherSourcesCache.invalidate();
|
||||
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
||||
@@ -243,9 +229,7 @@ export async function testWeatherSource(): Promise<void> {
|
||||
if (testBtn) testBtn.classList.add('loading');
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${id}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiPost<WeatherTestResult>(`/weather-sources/${id}/test`);
|
||||
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -255,6 +239,13 @@ export async function testWeatherSource(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape returned by `POST /weather-sources/{id}/test`. */
|
||||
interface WeatherTestResult {
|
||||
condition: string;
|
||||
temperature: number;
|
||||
wind_speed: number;
|
||||
}
|
||||
|
||||
// ── Geolocation ──
|
||||
|
||||
export function weatherSourceGeolocate(): void {
|
||||
@@ -336,9 +327,7 @@ const _weatherSourceActions: Record<string, (id: string) => void> = {
|
||||
|
||||
async function _testWeatherSourceFromCard(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiPost<WeatherTestResult>(`/weather-sources/${sourceId}/test`);
|
||||
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
colorStripSourcesCache, mqttSourcesCache,
|
||||
outputTargetsCache, valueSourcesCache,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut } from '../core/api-client.ts';
|
||||
import { logError } from '../core/log.ts';
|
||||
import { safeJsonParse } from '../core/storage.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
@@ -303,9 +304,7 @@ export async function showZ2MLightEditor(targetId: string | null = null, cloneDa
|
||||
let editData: any = null;
|
||||
if (isEdit) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
editData = await resp.json();
|
||||
editData = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -463,13 +462,10 @@ export async function saveZ2MLightEditor(): Promise<void> {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = targetId
|
||||
? await fetchWithAuth(`/output-targets/${targetId}`, { method: 'PUT', body: JSON.stringify(payload) })
|
||||
: await fetchWithAuth('/output-targets', { method: 'POST', body: JSON.stringify(payload) });
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${response.status}`);
|
||||
if (targetId) {
|
||||
await apiPut(`/output-targets/${targetId}`, payload);
|
||||
} else {
|
||||
await apiPost('/output-targets', payload);
|
||||
}
|
||||
showToast(targetId ? t('z2m_light.updated') : t('z2m_light.created'), 'success');
|
||||
outputTargetsCache.invalidate();
|
||||
@@ -489,12 +485,9 @@ export async function editZ2MLightTarget(targetId: string): Promise<void> {
|
||||
|
||||
export async function cloneZ2MLightTarget(targetId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showZ2MLightEditor(null, data);
|
||||
const data = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showZ2MLightEditor(null, { ...rest, name: `${data.name} (copy)` });
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -659,8 +652,7 @@ const _z2mLightActions: Record<string, (id: string) => void> = {
|
||||
|
||||
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
await apiPost(`/output-targets/${targetId}/${action}`);
|
||||
outputTargetsCache.invalidate();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} catch (e: any) {
|
||||
@@ -673,16 +665,13 @@ export async function turnOffZ2MLightTarget(targetId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('confirm.turn_off_z2m_light') || 'Turn off mapped bulbs?');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}/z2m-light/turn-off`, { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
showToast(t('z2m_light.turn_off.success') || 'Bulbs turned off', 'success');
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showToast(err.detail || t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error');
|
||||
}
|
||||
await apiPost(`/output-targets/${targetId}/z2m-light/turn-off`, undefined, {
|
||||
errorMessage: t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs',
|
||||
});
|
||||
showToast(t('z2m_light.turn_off.success') || 'Bulbs turned off', 'success');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error');
|
||||
showToast(e.message || t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"templates.error.engines": "Failed to load engines",
|
||||
"templates.error.required": "Please fill in all required fields",
|
||||
"templates.error.delete": "Failed to delete template",
|
||||
"templates.error.save_failed": "Failed to save template",
|
||||
"templates.error.load_failed": "Failed to load template",
|
||||
"templates.test.title": "Test Capture",
|
||||
"templates.test.description": "Test this template before saving to see a capture preview and performance metrics.",
|
||||
"templates.test.display": "Display:",
|
||||
@@ -1283,6 +1285,10 @@
|
||||
"automations.deleted": "Automation deleted",
|
||||
"automations.error.name_required": "Name is required",
|
||||
"automations.error.clone_failed": "Failed to clone automation",
|
||||
"automations.error.load_failed": "Failed to load automation",
|
||||
"automations.error.save_failed": "Failed to save automation",
|
||||
"automations.error.delete_failed": "Failed to delete automation",
|
||||
"automations.error.toggle_failed": "Failed to toggle automation",
|
||||
"scenes.title": "Scenes",
|
||||
"scenes.add": "Capture Scene",
|
||||
"scenes.edit": "Edit Scene",
|
||||
@@ -1824,6 +1830,8 @@
|
||||
"audio_template.error.engines": "Failed to load audio engines",
|
||||
"audio_template.error.required": "Please fill in all required fields",
|
||||
"audio_template.error.delete": "Failed to delete audio template",
|
||||
"audio_template.error.save_failed": "Failed to save audio template",
|
||||
"audio_template.error.load_failed": "Failed to load audio template",
|
||||
"streams.group.value": "Value Sources",
|
||||
"streams.group.sync": "Sync Clocks",
|
||||
"streams.group.gradients": "Gradients",
|
||||
@@ -1843,6 +1851,7 @@
|
||||
"gradient.error.name_required": "Name is required",
|
||||
"gradient.error.min_stops": "At least 2 color stops are required",
|
||||
"gradient.error.delete_failed": "Failed to delete gradient",
|
||||
"gradient.error.save_failed": "Failed to save gradient",
|
||||
"gradient.create_name": "New gradient name:",
|
||||
"gradient.edit_name": "Rename gradient:",
|
||||
"gradient.confirm_delete": "Delete gradient \"{name}\"?",
|
||||
@@ -2208,6 +2217,7 @@
|
||||
"device.error.update": "Failed to update device",
|
||||
"device.error.save": "Failed to save settings",
|
||||
"device.error.clone_failed": "Failed to clone device",
|
||||
"device.error.load_failed": "Failed to load device",
|
||||
"device_discovery.error.fill_all_fields": "Please fill in all fields",
|
||||
"device_discovery.added": "Device added successfully",
|
||||
"device_discovery.error.add_failed": "Failed to add device",
|
||||
@@ -2249,6 +2259,7 @@
|
||||
"target.error.stop_failed": "Failed to stop target",
|
||||
"target.error.clone_failed": "Failed to clone target",
|
||||
"target.error.delete_failed": "Failed to delete target",
|
||||
"target.error.load_failed": "Failed to load target",
|
||||
"targets.stop_all.button": "Stop All",
|
||||
"targets.stop_all.none_running": "No targets are currently running",
|
||||
"targets.stop_all.stopped": "Stopped {count} target(s)",
|
||||
@@ -2263,6 +2274,7 @@
|
||||
"pattern.error.clone_failed": "Failed to clone pattern template",
|
||||
"pattern.error.delete_failed": "Failed to delete pattern template",
|
||||
"pattern.error.capture_bg_failed": "Failed to capture background",
|
||||
"pattern.error.save_failed": "Failed to save pattern template",
|
||||
"stream.error.clone_picture_failed": "Failed to clone picture source",
|
||||
"stream.error.clone_capture_failed": "Failed to clone capture template",
|
||||
"stream.error.clone_pp_failed": "Failed to clone postprocessing template",
|
||||
@@ -2953,6 +2965,7 @@
|
||||
"audio_processing.error.load": "Error loading audio processing template",
|
||||
"audio_processing.error.delete": "Error deleting audio processing template",
|
||||
"audio_processing.error.clone_failed": "Failed to clone audio processing template",
|
||||
"audio_processing.error.save_failed": "Failed to save audio processing template",
|
||||
"audio_processing.filter_count": "Filter count",
|
||||
"audio_processing.filters_label": "filters",
|
||||
"streams.group.audio_processing": "Audio Processing",
|
||||
|
||||
@@ -180,6 +180,8 @@
|
||||
"templates.error.engines": "Не удалось загрузить движки",
|
||||
"templates.error.required": "Пожалуйста, заполните все обязательные поля",
|
||||
"templates.error.delete": "Не удалось удалить шаблон",
|
||||
"templates.error.save_failed": "Не удалось сохранить шаблон",
|
||||
"templates.error.load_failed": "Не удалось загрузить шаблон",
|
||||
"templates.test.title": "Тест Захвата",
|
||||
"templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.",
|
||||
"templates.test.display": "Дисплей:",
|
||||
@@ -1317,6 +1319,10 @@
|
||||
"automations.deleted": "Автоматизация удалена",
|
||||
"automations.error.name_required": "Введите название",
|
||||
"automations.error.clone_failed": "Не удалось клонировать автоматизацию",
|
||||
"automations.error.load_failed": "Не удалось загрузить автоматизацию",
|
||||
"automations.error.save_failed": "Не удалось сохранить автоматизацию",
|
||||
"automations.error.delete_failed": "Не удалось удалить автоматизацию",
|
||||
"automations.error.toggle_failed": "Не удалось переключить автоматизацию",
|
||||
"scenes.title": "Сцены",
|
||||
"scenes.add": "Захватить сцену",
|
||||
"scenes.edit": "Редактировать сцену",
|
||||
@@ -1789,6 +1795,10 @@
|
||||
"audio_template.error.engines": "Не удалось загрузить аудиодвижки",
|
||||
"audio_template.error.required": "Пожалуйста, заполните все обязательные поля",
|
||||
"audio_template.error.delete": "Не удалось удалить аудиошаблон",
|
||||
"audio_template.error.save_failed": "Не удалось сохранить аудиошаблон",
|
||||
"audio_template.error.load_failed": "Не удалось загрузить аудиошаблон",
|
||||
"gradient.error.save_failed": "Не удалось сохранить градиент",
|
||||
"gradient.error.delete_failed": "Не удалось удалить градиент",
|
||||
"streams.group.value": "Источники значений",
|
||||
"streams.group.sync": "Часы синхронизации",
|
||||
"tree.group.picture": "Источники изображений",
|
||||
@@ -2067,6 +2077,7 @@
|
||||
"device.error.update": "Не удалось обновить устройство",
|
||||
"device.error.save": "Не удалось сохранить настройки",
|
||||
"device.error.clone_failed": "Не удалось клонировать устройство",
|
||||
"device.error.load_failed": "Не удалось загрузить устройство",
|
||||
"device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля",
|
||||
"device_discovery.added": "Устройство успешно добавлено",
|
||||
"device_discovery.error.add_failed": "Не удалось добавить устройство",
|
||||
@@ -2108,6 +2119,7 @@
|
||||
"target.error.stop_failed": "Не удалось остановить цель",
|
||||
"target.error.clone_failed": "Не удалось клонировать цель",
|
||||
"target.error.delete_failed": "Не удалось удалить цель",
|
||||
"target.error.load_failed": "Не удалось загрузить цель",
|
||||
"targets.stop_all.button": "Остановить все",
|
||||
"targets.stop_all.none_running": "Нет запущенных целей",
|
||||
"targets.stop_all.stopped": "Остановлено целей: {count}",
|
||||
@@ -2122,6 +2134,7 @@
|
||||
"pattern.error.clone_failed": "Не удалось клонировать шаблон узоров",
|
||||
"pattern.error.delete_failed": "Не удалось удалить шаблон узоров",
|
||||
"pattern.error.capture_bg_failed": "Не удалось захватить фон",
|
||||
"pattern.error.save_failed": "Не удалось сохранить шаблон узоров",
|
||||
"stream.error.clone_picture_failed": "Не удалось клонировать источник изображения",
|
||||
"stream.error.clone_capture_failed": "Не удалось клонировать шаблон захвата",
|
||||
"stream.error.clone_pp_failed": "Не удалось клонировать шаблон постобработки",
|
||||
@@ -2634,6 +2647,7 @@
|
||||
"audio_processing.error.load": "Ошибка загрузки шаблона обработки звука",
|
||||
"audio_processing.error.delete": "Ошибка удаления шаблона обработки звука",
|
||||
"audio_processing.error.clone_failed": "Не удалось клонировать шаблон обработки звука",
|
||||
"audio_processing.error.save_failed": "Не удалось сохранить шаблон обработки звука",
|
||||
"audio_processing.filter_count": "Количество фильтров",
|
||||
"audio_processing.filters_label": "фильтров",
|
||||
"streams.group.audio_processing": "Обработка звука",
|
||||
|
||||
@@ -178,6 +178,8 @@
|
||||
"templates.error.engines": "加载引擎失败",
|
||||
"templates.error.required": "请填写所有必填项",
|
||||
"templates.error.delete": "删除模板失败",
|
||||
"templates.error.save_failed": "保存模板失败",
|
||||
"templates.error.load_failed": "加载模板失败",
|
||||
"templates.test.title": "测试采集",
|
||||
"templates.test.description": "保存前测试此模板,查看采集预览和性能指标。",
|
||||
"templates.test.display": "显示器:",
|
||||
@@ -1313,6 +1315,10 @@
|
||||
"automations.deleted": "自动化已删除",
|
||||
"automations.error.name_required": "名称为必填项",
|
||||
"automations.error.clone_failed": "克隆自动化失败",
|
||||
"automations.error.load_failed": "加载自动化失败",
|
||||
"automations.error.save_failed": "保存自动化失败",
|
||||
"automations.error.delete_failed": "删除自动化失败",
|
||||
"automations.error.toggle_failed": "切换自动化失败",
|
||||
"scenes.title": "场景",
|
||||
"scenes.add": "捕获场景",
|
||||
"scenes.edit": "编辑场景",
|
||||
@@ -1785,6 +1791,10 @@
|
||||
"audio_template.error.engines": "加载音频引擎失败",
|
||||
"audio_template.error.required": "请填写所有必填项",
|
||||
"audio_template.error.delete": "删除音频模板失败",
|
||||
"audio_template.error.save_failed": "保存音频模板失败",
|
||||
"audio_template.error.load_failed": "加载音频模板失败",
|
||||
"gradient.error.save_failed": "保存渐变失败",
|
||||
"gradient.error.delete_failed": "删除渐变失败",
|
||||
"streams.group.value": "值源",
|
||||
"streams.group.sync": "同步时钟",
|
||||
"tree.group.picture": "图片源",
|
||||
@@ -2063,6 +2073,7 @@
|
||||
"device.error.update": "更新设备失败",
|
||||
"device.error.save": "保存设置失败",
|
||||
"device.error.clone_failed": "克隆设备失败",
|
||||
"device.error.load_failed": "加载设备失败",
|
||||
"device_discovery.error.fill_all_fields": "请填写所有字段",
|
||||
"device_discovery.added": "设备添加成功",
|
||||
"device_discovery.error.add_failed": "添加设备失败",
|
||||
@@ -2104,6 +2115,7 @@
|
||||
"target.error.stop_failed": "停止目标失败",
|
||||
"target.error.clone_failed": "克隆目标失败",
|
||||
"target.error.delete_failed": "删除目标失败",
|
||||
"target.error.load_failed": "加载目标失败",
|
||||
"targets.stop_all.button": "全部停止",
|
||||
"targets.stop_all.none_running": "当前没有运行中的目标",
|
||||
"targets.stop_all.stopped": "已停止 {count} 个目标",
|
||||
@@ -2118,6 +2130,7 @@
|
||||
"pattern.error.clone_failed": "克隆图案模板失败",
|
||||
"pattern.error.delete_failed": "删除图案模板失败",
|
||||
"pattern.error.capture_bg_failed": "捕获背景失败",
|
||||
"pattern.error.save_failed": "保存图案模板失败",
|
||||
"stream.error.clone_picture_failed": "克隆图片源失败",
|
||||
"stream.error.clone_capture_failed": "克隆捕获模板失败",
|
||||
"stream.error.clone_pp_failed": "克隆后处理模板失败",
|
||||
@@ -2628,6 +2641,7 @@
|
||||
"audio_processing.error.load": "加载音频处理模板时出错",
|
||||
"audio_processing.error.delete": "删除音频处理模板时出错",
|
||||
"audio_processing.error.clone_failed": "克隆音频处理模板失败",
|
||||
"audio_processing.error.save_failed": "保存音频处理模板失败",
|
||||
"audio_processing.filter_count": "过滤器数量",
|
||||
"audio_processing.filters_label": "个过滤器",
|
||||
"streams.group.audio_processing": "音频处理",
|
||||
|
||||
Reference in New Issue
Block a user