feat: Home Assistant provider — WebSocket subscription + bot commands
Adds Home Assistant as a service provider with two coordinated surfaces: Notifications (subscription): - Long-lived WebSocket client (aiohttp ws_connect) with auth handshake, exponential-backoff reconnect, bounded event queue, and area-registry enrichment cached per (re)connect - ServiceProvider ABC gains an optional `subscribe()` method for push-style providers; HomeAssistantServiceProvider uses it via a per-provider supervisor task started in the FastAPI lifespan - 4 event types (state_changed, automation_triggered, call_service, event_fired), 4 default Jinja templates (en + ru), HA-specific tracker filters (entity_glob, domain_allowlist, exact entity ids) - Extracted shared dispatch pipeline (api/webhooks.py → services/ event_dispatch.py) so subscription and webhook ingest share the same event_log + deferred-dispatch + quiet-hours code path Bot commands: - /status, /entities [glob], /state <entity_id>, /areas - Multi-command WS session so /status and /areas cost one handshake - Sensitive-attribute blocklist (camera access_token, entity_picture, etc.) and 30-attribute cap to keep /state output safe and within Telegram's message size - Error-message redaction strips URL userinfo before surfacing to chat Frontend: - HA descriptor with toggle ConfigField type (new) and tag-input filter mode for free-text glob/domain lists (new TagInput component) - 15 command slots + 4 notification slots wired into the existing template-config UI
This commit is contained in:
@@ -505,3 +505,52 @@ button:focus-visible, a:focus-visible {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared toggle switch — used by provider config forms, tracking-config
|
||||
extraTrackingFields, and anywhere else we render a boolean field.
|
||||
Kept global so adding a new ConfigField type='toggle' caller doesn't
|
||||
need to copy the CSS into its scoped <style>. */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-switch .toggle-track {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.375rem;
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch .toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.1875rem;
|
||||
left: 0.1875rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: var(--color-foreground);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-track {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-track::after {
|
||||
transform: translateX(1.125rem);
|
||||
background: var(--color-primary-foreground);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Free-text chip input. Bind a string[] of values; commit a new chip on
|
||||
* Enter, comma, or blur. Backspace on empty input deletes the last chip
|
||||
* for parity with native chip-input UX.
|
||||
*
|
||||
* Used by ProviderDescriptor.userFilters with inputMode === 'tags' for
|
||||
* free-text filter keys like Home Assistant's entity_glob and
|
||||
* domain_allowlist. Distinct from MultiEntitySelect, which renders a
|
||||
* picker dropdown sourced from an enumerable list.
|
||||
*/
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
values: string[];
|
||||
onchange: (values: string[]) => void;
|
||||
placeholder?: string;
|
||||
icon?: string;
|
||||
/** Strip / reject anything matching this regex on each entry. */
|
||||
sanitize?: (raw: string) => string | null;
|
||||
}
|
||||
|
||||
let { values, onchange, placeholder = '', icon, sanitize }: Props = $props();
|
||||
|
||||
let draft = $state('');
|
||||
|
||||
function addRaw(raw: string): void {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return;
|
||||
const cleaned = sanitize ? sanitize(trimmed) : trimmed;
|
||||
if (!cleaned) return;
|
||||
if (values.includes(cleaned)) return;
|
||||
onchange([...values, cleaned]);
|
||||
}
|
||||
|
||||
function commitDraft(): void {
|
||||
if (!draft.trim()) return;
|
||||
// Allow comma-separated paste — split on commas and add each.
|
||||
for (const piece of draft.split(',')) {
|
||||
addRaw(piece);
|
||||
}
|
||||
draft = '';
|
||||
}
|
||||
|
||||
function removeAt(index: number): void {
|
||||
onchange(values.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
commitDraft();
|
||||
} else if (e.key === 'Backspace' && draft === '' && values.length > 0) {
|
||||
e.preventDefault();
|
||||
removeAt(values.length - 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tag-input">
|
||||
{#each values as value, i (`${i}-${value}`)}
|
||||
<span class="tag-chip">
|
||||
{#if icon}<MdiIcon name={icon} size={12} />{/if}
|
||||
<span class="tag-text">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
class="tag-remove"
|
||||
onclick={() => removeAt(i)}
|
||||
>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={draft}
|
||||
onkeydown={onKey}
|
||||
onblur={commitDraft}
|
||||
placeholder={values.length === 0 ? placeholder : ''}
|
||||
class="tag-draft"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tag-input {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-background);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.tag-input:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
background: var(--color-muted);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.tag-remove:hover {
|
||||
background: var(--color-border);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.tag-draft {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
|
||||
.tag-draft::placeholder {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
</style>
|
||||
@@ -235,6 +235,13 @@
|
||||
"typeNut": "NUT (UPS)",
|
||||
"typeGooglePhotos": "Google Photos",
|
||||
"typeWebhook": "Generic Webhook",
|
||||
"typeHomeAssistant": "Home Assistant",
|
||||
"haAccessToken": "Long-Lived Access Token",
|
||||
"haAccessTokenKeep": "Long-Lived Access Token (leave empty to keep current)",
|
||||
"haAccessTokenHint": "Create one in HA → Profile → Long-Lived Access Tokens. Required for WebSocket subscription.",
|
||||
"haAccessTokenRequired": "Home Assistant access token is required.",
|
||||
"haVerifyTls": "Verify TLS certificate",
|
||||
"haVerifyTlsHint": "Disable only for self-signed HA on a trusted LAN. Keep enabled for any internet-reachable instance.",
|
||||
"loadError": "Failed to load providers.",
|
||||
"externalDomain": "External Domain",
|
||||
"optional": "optional",
|
||||
@@ -320,6 +327,13 @@
|
||||
"selectBoards": "Select boards...",
|
||||
"upsDevices": "UPS Devices",
|
||||
"selectUpsDevices": "Select UPS devices...",
|
||||
"entities": "Entities",
|
||||
"selectEntities": "Select entities...",
|
||||
"entities_count": "entity(ies)",
|
||||
"haEntityGlob": "Entity glob filter",
|
||||
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
|
||||
"haDomainAllowlist": "Domain allowlist",
|
||||
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
|
||||
"eventTypes": "Event Types",
|
||||
"notificationTargets": "Notification Targets",
|
||||
"scanInterval": "Scan Interval (seconds)",
|
||||
@@ -644,6 +658,11 @@
|
||||
"upsOverload": "UPS overloaded",
|
||||
"scheduledMessage": "Scheduled message",
|
||||
"webhookReceived": "Webhook received",
|
||||
"haStateChanged": "Entity state changed",
|
||||
"haAutomationTriggered": "Automation triggered",
|
||||
"haServiceCalled": "Service called",
|
||||
"haEventFired": "Other HA event (catch-all)",
|
||||
"haEventFiredHint": "Fires for any HA event type not covered by the boxes above. Useful for custom integrations; expect high volume.",
|
||||
"trackImages": "Track images",
|
||||
"trackVideos": "Track videos",
|
||||
"favoritesOnly": "Favorites only",
|
||||
@@ -1345,7 +1364,8 @@
|
||||
"providerScheduler": "Time-based scheduled messages",
|
||||
"providerNut": "Network UPS monitoring",
|
||||
"providerGooglePhotos": "Google Photos albums & shared libraries",
|
||||
"providerWebhook": "Receive events via HTTP POST"
|
||||
"providerWebhook": "Receive events via HTTP POST",
|
||||
"providerHomeAssistant": "Home Assistant event bus over WebSocket"
|
||||
},
|
||||
"webhookLogs": {
|
||||
"title": "Recent Payloads",
|
||||
|
||||
@@ -235,6 +235,13 @@
|
||||
"typeNut": "NUT (ИБП)",
|
||||
"typeGooglePhotos": "Google Фото",
|
||||
"typeWebhook": "Универсальный вебхук",
|
||||
"typeHomeAssistant": "Home Assistant",
|
||||
"haAccessToken": "Долгоживущий токен доступа",
|
||||
"haAccessTokenKeep": "Долгоживущий токен (оставьте пустым для сохранения)",
|
||||
"haAccessTokenHint": "Создайте в HA → Профиль → Long-Lived Access Tokens. Нужен для WebSocket-подписки.",
|
||||
"haAccessTokenRequired": "Токен доступа Home Assistant обязателен.",
|
||||
"haVerifyTls": "Проверять TLS-сертификат",
|
||||
"haVerifyTlsHint": "Отключайте только для самоподписанного HA в доверенной локальной сети. Оставляйте включённым для любого экземпляра, доступного из интернета.",
|
||||
"loadError": "Не удалось загрузить провайдеры.",
|
||||
"externalDomain": "Внешний домен",
|
||||
"optional": "необязательно",
|
||||
@@ -320,6 +327,13 @@
|
||||
"selectBoards": "Выберите доски...",
|
||||
"upsDevices": "ИБП устройства",
|
||||
"selectUpsDevices": "Выберите ИБП...",
|
||||
"entities": "Сущности",
|
||||
"selectEntities": "Выберите сущности...",
|
||||
"entities_count": "сущность(ей)",
|
||||
"haEntityGlob": "Фильтр по entity (glob)",
|
||||
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
|
||||
"haDomainAllowlist": "Разрешённые домены",
|
||||
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
|
||||
"eventTypes": "Типы событий",
|
||||
"notificationTargets": "Получатели уведомлений",
|
||||
"scanInterval": "Интервал проверки (секунды)",
|
||||
@@ -644,6 +658,11 @@
|
||||
"upsOverload": "Перегрузка ИБП",
|
||||
"scheduledMessage": "Запланированное сообщение",
|
||||
"webhookReceived": "Вебхук получен",
|
||||
"haStateChanged": "Состояние сущности изменилось",
|
||||
"haAutomationTriggered": "Сработала автоматизация",
|
||||
"haServiceCalled": "Вызвана служба",
|
||||
"haEventFired": "Прочее событие HA (catch-all)",
|
||||
"haEventFiredHint": "Срабатывает на любые типы событий HA, не охваченные чекбоксами выше. Полезно для пользовательских интеграций; ожидайте большой объём.",
|
||||
"trackImages": "Фото",
|
||||
"trackVideos": "Видео",
|
||||
"favoritesOnly": "Только избранные",
|
||||
@@ -1345,7 +1364,8 @@
|
||||
"providerScheduler": "Запланированные сообщения по расписанию",
|
||||
"providerNut": "Мониторинг ИБП через NUT",
|
||||
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
|
||||
"providerWebhook": "Приём событий через HTTP POST"
|
||||
"providerWebhook": "Приём событий через HTTP POST",
|
||||
"providerHomeAssistant": "Шина событий Home Assistant по WebSocket"
|
||||
},
|
||||
"webhookLogs": {
|
||||
"title": "Последние запросы",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ProviderDescriptor } from './types';
|
||||
|
||||
export const homeAssistantDescriptor: ProviderDescriptor = {
|
||||
type: 'home_assistant',
|
||||
defaultName: 'Home Assistant',
|
||||
icon: 'mdiHomeAssistant',
|
||||
hasUrl: true,
|
||||
urlPlaceholder: 'http://homeassistant.local:8123',
|
||||
|
||||
configFields: [
|
||||
{
|
||||
key: 'access_token', configKey: 'access_token',
|
||||
label: 'providers.haAccessToken', editLabel: 'providers.haAccessTokenKeep',
|
||||
type: 'password', required: 'create-only', hint: 'providers.haAccessTokenHint',
|
||||
},
|
||||
{
|
||||
key: 'verify_tls', configKey: 'verify_tls',
|
||||
label: 'providers.haVerifyTls',
|
||||
type: 'toggle', optional: true, hint: 'providers.haVerifyTlsHint',
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
|
||||
buildConfig(form, editing) {
|
||||
const config: Record<string, unknown> = { url: form.url };
|
||||
if (form.access_token) config.access_token = form.access_token;
|
||||
// Coerce truthy/falsy form values to a real boolean. The toggle
|
||||
// control binds to `checked`, so this is normally already a bool,
|
||||
// but legacy form state may carry the string defaults.
|
||||
config.verify_tls = form.verify_tls === false || form.verify_tls === 'false' ? false : true;
|
||||
if (!editing && !form.access_token) {
|
||||
return { config, error: 'providers.haAccessTokenRequired' };
|
||||
}
|
||||
return { config };
|
||||
},
|
||||
|
||||
hasConfigChanged(form, existing) {
|
||||
const existingVerify = existing.verify_tls !== false;
|
||||
const formVerify = !(form.verify_tls === false || form.verify_tls === 'false');
|
||||
return (
|
||||
form.url !== (existing.url || '') ||
|
||||
!!form.access_token ||
|
||||
existingVerify !== formVerify
|
||||
);
|
||||
},
|
||||
|
||||
eventFields: [
|
||||
{ key: 'track_ha_state_changed', label: 'trackingConfig.haStateChanged', default: true },
|
||||
{ key: 'track_ha_automation_triggered', label: 'trackingConfig.haAutomationTriggered', default: false },
|
||||
{ key: 'track_ha_service_called', label: 'trackingConfig.haServiceCalled', default: false },
|
||||
{
|
||||
key: 'track_ha_event_fired',
|
||||
label: 'trackingConfig.haEventFired',
|
||||
default: false,
|
||||
hint: 'trackingConfig.haEventFiredHint',
|
||||
},
|
||||
],
|
||||
|
||||
// entity_glob / domain_allowlist tag-style filters. Stored on the
|
||||
// tracker's `filters` JSON column (not the flat form root) — the
|
||||
// TrackerForm reads `inputMode: 'tags'` to render a chip input rather
|
||||
// than a picker, and `filterKey` routes the value into
|
||||
// `tracker.filters[filterKey]` at save time.
|
||||
userFilters: [
|
||||
{
|
||||
key: 'entity_glob',
|
||||
filterKey: 'entity_glob',
|
||||
inputMode: 'tags',
|
||||
label: 'notificationTracker.haEntityGlob',
|
||||
placeholder: 'notificationTracker.haEntityGlobPlaceholder',
|
||||
icon: 'mdiAsterisk',
|
||||
},
|
||||
{
|
||||
key: 'domain_allowlist',
|
||||
filterKey: 'domain_allowlist',
|
||||
inputMode: 'tags',
|
||||
label: 'notificationTracker.haDomainAllowlist',
|
||||
placeholder: 'notificationTracker.haDomainAllowlistPlaceholder',
|
||||
icon: 'mdiTagOutline',
|
||||
},
|
||||
],
|
||||
|
||||
collectionMeta: {
|
||||
label: 'notificationTracker.entities',
|
||||
icon: 'mdiViewList',
|
||||
placeholder: 'notificationTracker.selectEntities',
|
||||
countLabel: 'notificationTracker.entities_count',
|
||||
desc: (col: { state?: string; domain?: string; entity_id?: string; id?: string }) => {
|
||||
const parts: string[] = [];
|
||||
if (col.domain) parts.push(col.domain);
|
||||
if (col.state) parts.push(col.state);
|
||||
if (parts.length === 0) return col.entity_id || col.id || '';
|
||||
return parts.join(' · ');
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { schedulerDescriptor } from './scheduler';
|
||||
import { nutDescriptor } from './nut';
|
||||
import { googlePhotosDescriptor } from './google-photos';
|
||||
import { webhookDescriptor } from './webhook';
|
||||
import { homeAssistantDescriptor } from './home-assistant';
|
||||
|
||||
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
||||
['immich', immichDescriptor],
|
||||
@@ -22,6 +23,7 @@ const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
||||
['nut', nutDescriptor],
|
||||
['google_photos', googlePhotosDescriptor],
|
||||
['webhook', webhookDescriptor],
|
||||
['home_assistant', homeAssistantDescriptor],
|
||||
]);
|
||||
|
||||
/** Look up a provider descriptor by type. Returns null for unknown types. */
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface ConfigField {
|
||||
configKey?: string;
|
||||
/** i18n key for the field label. */
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'number' | 'grid-select';
|
||||
type: 'text' | 'password' | 'number' | 'grid-select' | 'toggle';
|
||||
/** Grid-select item source function name from grid-items.ts. */
|
||||
gridItems?: string;
|
||||
gridColumns?: number;
|
||||
@@ -123,17 +123,30 @@ export interface CollectionMeta {
|
||||
// ── User-identity filters (TrackerForm) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Declares a filter that picks user identities from the provider's known
|
||||
* senders. Rendered as a MultiEntitySelect populated from the provider's
|
||||
* `/users` endpoint. The picked values are stored as `string[]` under
|
||||
* `tracker.filters[key]`.
|
||||
* Declares a filter rendered on the tracker form. Two input modes:
|
||||
*
|
||||
* * ``picker`` (default) — populated from the provider's ``/users``
|
||||
* endpoint, rendered as a ``MultiEntitySelect``. Used for sender
|
||||
* allowlists / blocklists where the valid values are known.
|
||||
* * ``tags`` — free-text chip input. Used for glob patterns and other
|
||||
* filter values that aren't enumerable in advance.
|
||||
*
|
||||
* Either way the picked values are stored as ``string[]`` under
|
||||
* ``tracker.filters[filterKey ?? key]``.
|
||||
*/
|
||||
export interface UserFilterMeta {
|
||||
/** Filter key inside `tracker.filters` (e.g. "senders", "exclude_senders"). */
|
||||
/** Form field key — used internally for binding. */
|
||||
key: string;
|
||||
/** i18n key for the label rendered above the picker. */
|
||||
/**
|
||||
* Filter key inside ``tracker.filters``. Defaults to ``key`` when
|
||||
* omitted (backward compat with the original sender allowlist usage).
|
||||
*/
|
||||
filterKey?: string;
|
||||
/** ``picker`` (default) or ``tags`` for free-text chip input. */
|
||||
inputMode?: 'picker' | 'tags';
|
||||
/** i18n key for the label rendered above the input. */
|
||||
label: string;
|
||||
/** i18n key for the picker placeholder. */
|
||||
/** i18n key for the placeholder (picker dropdown or chip input). */
|
||||
placeholder: string;
|
||||
/** MDI icon shown on chips and dropdown rows. */
|
||||
icon: string;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
||||
import TagInput from '$lib/components/TagInput.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
|
||||
interface Props {
|
||||
@@ -123,14 +124,24 @@
|
||||
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
|
||||
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
|
||||
{#each descriptor.userFilters as uf (uf.key)}
|
||||
{@const filterKey = uf.filterKey ?? uf.key}
|
||||
<div>
|
||||
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
|
||||
<MultiEntitySelect
|
||||
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
|
||||
values={form.filters[uf.key] || []}
|
||||
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
|
||||
placeholder={t(uf.placeholder)}
|
||||
/>
|
||||
{#if uf.inputMode === 'tags'}
|
||||
<TagInput
|
||||
values={form.filters[filterKey] || []}
|
||||
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
|
||||
placeholder={t(uf.placeholder)}
|
||||
icon={uf.icon}
|
||||
/>
|
||||
{:else}
|
||||
<MultiEntitySelect
|
||||
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
|
||||
values={form.filters[filterKey] || []}
|
||||
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
|
||||
placeholder={t(uf.placeholder)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -321,6 +321,11 @@
|
||||
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
|
||||
min={field.min} max={field.max}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{:else if field.type === 'toggle'}
|
||||
<label class="toggle-switch">
|
||||
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
{:else}
|
||||
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
||||
required={field.required === true || (field.required === 'create-only' && !editing)}
|
||||
|
||||
@@ -107,6 +107,11 @@
|
||||
{:else if field.type === 'number'}
|
||||
<input id="prv-{field.key}" type="number" bind:value={form[field.key]} min={field.min} max={field.max}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{:else if field.type === 'toggle'}
|
||||
<label class="toggle-switch">
|
||||
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
{:else}
|
||||
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
||||
required={field.required === true || field.required === 'create-only'}
|
||||
|
||||
Reference in New Issue
Block a user