Files
remnawave-minishop/frontend/src/admin/sections/SettingsSection.svelte
T

839 lines
28 KiB
Svelte

<script>
import {
Check,
ChevronRight,
Copy,
Eye,
EyeOff,
FileText,
Search,
X,
} from "$components/ui/icons.js";
import * as UiIcons from "$components/ui/icons.js";
import { Accordion, Switch } from "$components/ui/primitives.js";
import Dialog from "$components/ui/dialog.svelte";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminSelect,
} from "$components/patterns/admin/index.js";
import { getContext, onDestroy, onMount } from "svelte";
export let at;
export let onSettingsSaved;
export let currentLang = "ru";
const settingsStore = getContext("settingsStore");
$: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore);
const SETTINGS_SECTION_IDS_HIDDEN_IN_GENERAL_SETTINGS = new Set(["appearance", "pricing"]);
$: visibleSettingsSections = settingsSections.filter(
(section) => !SETTINGS_SECTION_IDS_HIDDEN_IN_GENERAL_SETTINGS.has(section.id)
);
let settingsOpenSections = [];
let settingsOpenSubsections = {};
let revealedSecrets = new Set();
let iconPickerField = null;
let iconPickerSearch = "";
let copiedWebhookKey = "";
let copiedWebhookTimer = null;
const PLATEGA_SBP_KEYS = new Set([
"PLATEGA_SBP_ENABLED",
"PLATEGA_SBP_ADMIN_ONLY_ENABLED",
"PLATEGA_SBP_METHOD",
]);
const PLATEGA_CRYPTO_KEYS = new Set([
"PLATEGA_CRYPTO_ENABLED",
"PLATEGA_CRYPTO_ADMIN_ONLY_ENABLED",
"PLATEGA_CRYPTO_METHOD",
]);
const PLATEGA_LEGACY_KEYS = new Set(["PLATEGA_PAYMENT_METHOD"]);
const SEMANTIC_FIELD_GROUP_ORDER = {
platega_common: 1,
platega_sbp: 2,
platega_crypto: 3,
platega_legacy: 4,
};
$: settingsAllOpen =
visibleSettingsSections.length > 0 &&
settingsOpenSections.length === visibleSettingsSections.length;
$: iconOptions = Object.keys(UiIcons)
.filter((name) => /^[A-Z]/.test(name))
.sort((a, b) => a.localeCompare(b));
$: filteredIconOptions = iconOptions.filter((name) =>
name.toLowerCase().includes(iconPickerSearch.trim().toLowerCase())
);
onMount(() => {
settingsStore.loadSettings();
});
onDestroy(() => {
if (copiedWebhookTimer && typeof window !== "undefined") {
window.clearTimeout(copiedWebhookTimer);
}
});
function toggleAllSections() {
if (settingsOpenSections.length === visibleSettingsSections.length) {
settingsOpenSections = [];
} else {
settingsOpenSections = visibleSettingsSections.map((s) => s.id);
}
}
function valueFor(field) {
if (settingsDirty[field.key]?.deleted) return "";
if (Object.prototype.hasOwnProperty.call(settingsDirty, field.key)) {
return settingsDirty[field.key].value;
}
return field.value ?? "";
}
function isOverridden(field) {
return Boolean(field.overridden) && !settingsDirty[field.key]?.deleted;
}
function isSecretRevealed(key) {
return revealedSecrets.has(key);
}
function toggleSecretReveal(key) {
const next = new Set(revealedSecrets);
if (next.has(key)) next.delete(key);
else next.add(key);
revealedSecrets = next;
}
function secretPlaceholder(field) {
if (settingsDirty[field.key]?.deleted) return fieldPlaceholderText(field) || "********";
if (field.has_value) return at("settings_secret_configured", {}, "Secret is set");
return fieldPlaceholderText(field) || at("settings_secret_empty", {}, "Not set");
}
function iconComponent(name) {
const key = String(name || "").trim();
return key ? UiIcons[key] || null : null;
}
function iconValue(field) {
return String(valueFor(field) || field?.placeholder || "").trim();
}
function iconIsDefault(field) {
return !String(valueFor(field) || "").trim();
}
function iconLabel(field) {
const iconName = iconValue(field);
if (!iconName) return at("settings_icon_empty", {}, "Default icon");
if (iconIsDefault(field)) {
return at("settings_icon_default_value", { icon: iconName }, `Default: ${iconName}`);
}
return iconName;
}
function openIconPicker(field) {
iconPickerField = field;
iconPickerSearch = "";
}
function closeIconPicker() {
iconPickerField = null;
iconPickerSearch = "";
}
function selectIcon(name) {
if (!iconPickerField) return;
settingsStore.markDirty(iconPickerField.key, name);
closeIconPicker();
}
async function handleJsonFile(field, event) {
const file = event?.currentTarget?.files?.[0];
if (!file) return;
try {
const text = await file.text();
settingsStore.markDirty(field.key, text);
} finally {
event.currentTarget.value = "";
}
}
function normalizeWebhookPath(path) {
const normalized = String(path || "").trim();
if (!normalized) return "";
return normalized.startsWith("/") ? normalized : `/${normalized}`;
}
function webhookUrlForField(field) {
const explicit = String(field?.webhook_url || "").trim();
if (explicit) return explicit;
const path = normalizeWebhookPath(field?.webhook_path);
if (!path) return "";
if (field?.webhook_requires_base_url && field?.webhook_base_url_configured === false) {
return "";
}
if (typeof window !== "undefined" && window.location?.origin) {
return `${window.location.origin}${path}`;
}
return path;
}
function groupWebhook(fields) {
const field = (fields || []).find((item) => item.webhook_path || item.webhook_url);
if (!field) return null;
const path = normalizeWebhookPath(field.webhook_path);
const url = webhookUrlForField(field);
if (!url && !path) return null;
return {
key: `${field.provider_id || field.key || "provider"}:${path || url}`,
path,
url,
requiresBaseUrl: Boolean(field.webhook_requires_base_url),
baseConfigured: field.webhook_base_url_configured !== false,
};
}
async function copyWebhookUrl(webhook) {
if (!webhook?.url) return;
try {
await navigator.clipboard.writeText(webhook.url);
copiedWebhookKey = webhook.key;
if (copiedWebhookTimer && typeof window !== "undefined") {
window.clearTimeout(copiedWebhookTimer);
}
if (typeof window !== "undefined") {
copiedWebhookTimer = window.setTimeout(() => {
copiedWebhookKey = "";
copiedWebhookTimer = null;
}, 1400);
}
} catch {
copiedWebhookKey = "";
}
}
function groupSectionFields(section) {
const groups = new Map();
for (const field of section.fields || []) {
const key = field.subsection || "_root";
if (!groups.has(key)) {
groups.set(key, { fields: [], i18nLabelKey: field.i18n_subsection_key || null });
}
const group = groups.get(key);
group.fields.push(field);
if (!group.i18nLabelKey && field.i18n_subsection_key) {
group.i18nLabelKey = field.i18n_subsection_key;
}
}
return Array.from(groups.entries()).map(([id, group]) => ({
id,
label: id === "_root" ? null : id,
i18nLabelKey: group.i18nLabelKey,
webhook: groupWebhook(group.fields),
fields: group.fields,
}));
}
function fieldGroupMeta(
id,
titleKey,
titleFallback,
descriptionKey = "",
descriptionFallback = ""
) {
return { id, titleKey, titleFallback, descriptionKey, descriptionFallback };
}
function plategaSemanticGroup(field) {
const key = String(field?.key || "");
if (PLATEGA_SBP_KEYS.has(key) || key.startsWith("PAYMENT_PLATEGA_SBP_")) {
return fieldGroupMeta(
"platega_sbp",
"settings_group_platega_sbp",
"SBP/card button",
"settings_group_platega_sbp_hint",
"Visibility, method ID, and labels for the SBP/card payment button."
);
}
if (PLATEGA_CRYPTO_KEYS.has(key) || key.startsWith("PAYMENT_PLATEGA_CRYPTO_")) {
return fieldGroupMeta(
"platega_crypto",
"settings_group_platega_crypto",
"Crypto button",
"settings_group_platega_crypto_hint",
"Visibility, method ID, and labels for the crypto payment button."
);
}
if (PLATEGA_LEGACY_KEYS.has(key)) {
return fieldGroupMeta(
"platega_legacy",
"settings_group_platega_legacy",
"Legacy compatibility",
"settings_group_platega_legacy_hint",
"Fallback method for old Platega callbacks and deployments."
);
}
return fieldGroupMeta(
"platega_common",
"settings_group_platega_common",
"Common settings",
"settings_group_platega_common_hint",
"Shared merchant credentials, redirects, and API endpoint."
);
}
function semanticFieldGroup(section, group, field) {
if (section?.id === "payments" && group?.id === "Platega") {
return plategaSemanticGroup(field);
}
return null;
}
function semanticFieldGroups(section, group) {
const fields = group?.fields || [];
const result = new Map();
for (const field of fields) {
const meta = semanticFieldGroup(section, group, field) || fieldGroupMeta("_default", "", "");
if (!result.has(meta.id)) {
result.set(meta.id, { ...meta, fields: [] });
}
result.get(meta.id).fields.push(field);
}
return Array.from(result.values()).sort(
(a, b) =>
(SEMANTIC_FIELD_GROUP_ORDER[a.id] || 999) - (SEMANTIC_FIELD_GROUP_ORDER[b.id] || 999)
);
}
function fieldGroupTitle(group) {
return group.titleKey ? at(group.titleKey, {}, group.titleFallback) : "";
}
function fieldGroupDescription(group) {
return group.descriptionKey ? at(group.descriptionKey, {}, group.descriptionFallback) : "";
}
function adminLocaleKey(key) {
const raw = String(key || "");
return raw.startsWith("admin_") ? raw.slice("admin_".length) : raw;
}
function adminText(key, params = {}, fallback = "") {
return key ? at(adminLocaleKey(key), params, fallback) : fallback;
}
function sectionTitle(id) {
const map = {
general: "Общие",
appearance: "Внешний вид",
pricing: "Тарифы и цены",
payments: "Платёжные системы",
trial: "Триал",
referral: "Реферальная программа",
notifications: "Уведомления",
support: "Поддержка",
devices: "Устройства",
subscription_guides: "Connection guides",
};
return adminText(`settings_section_${id}`, {}, map[id] || id);
}
function englishFieldLabelFallback(key, originalLabel) {
if (!key) return originalLabel || "";
return String(key)
.toLowerCase()
.split("_")
.filter(Boolean)
.map((part) => {
if (part === "id") return "ID";
if (part === "url") return "URL";
if (part === "api") return "API";
if (part === "tg") return "TG";
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join(" ");
}
function fieldLabelText(field) {
const isEnglish = String(currentLang || "")
.toLowerCase()
.startsWith("en");
const fallback = isEnglish ? englishFieldLabelFallback(field.key, field.label) : field.label;
return field.i18n_label_key ? adminText(field.i18n_label_key, {}, fallback) : fallback;
}
function fieldDescriptionText(field) {
if (!field.description) return "";
return field.i18n_description_key
? adminText(field.i18n_description_key, {}, field.description)
: field.description;
}
function fieldPlaceholderText(field) {
const fallback = field.placeholder || "";
return field.i18n_placeholder_key
? adminText(field.i18n_placeholder_key, {}, fallback)
: fallback;
}
function subsectionTitle(group) {
if (!group?.label) return "";
return group.i18nLabelKey ? adminText(group.i18nLabelKey, {}, group.label) : group.label;
}
function choiceItems(field) {
return (field.choices || []).map((choice) => ({
...choice,
label: choice.i18n_label_key
? adminText(choice.i18n_label_key, {}, choice.label)
: choice.label,
}));
}
function setBoolField(field, checked) {
settingsStore.markDirty(field.key, checked);
if (checked && field.mutually_exclusive_key) {
settingsStore.markDirty(field.mutually_exclusive_key, false);
}
}
</script>
{#snippet renderWebhookHint(webhook)}
{@const displayValue = webhook.url || webhook.path}
<div class="admin-webhook-hint">
<div class="admin-webhook-hint-meta">
<strong>{at("settings_provider_webhook_url", {}, "Webhook URL")}</strong>
<small>
{webhook.url
? at(
"settings_provider_webhook_url_hint",
{},
"Use this URL in the provider webhook settings."
)
: at(
"settings_provider_webhook_base_missing",
{ path: webhook.path },
`Set WEBHOOK_BASE_URL to show the full URL for ${webhook.path}.`
)}
</small>
</div>
<div class="admin-webhook-value">
<code title={displayValue}>{displayValue}</code>
<AdminButton
class="admin-webhook-copy"
size="sm"
variant="ghost"
disabled={!webhook.url}
title={at("copy", {}, "Copy")}
onclick={() => copyWebhookUrl(webhook)}
>
{#if copiedWebhookKey === webhook.key}
<Check size={13} />
<span>{at("copied", {}, "Copied")}</span>
{:else}
<Copy size={13} />
<span>{at("copy", {}, "Copy")}</span>
{/if}
</AdminButton>
</div>
</div>
{/snippet}
{#snippet renderGroupedFields(section, group)}
{@const fieldGroups = semanticFieldGroups(section, group)}
{#if fieldGroups.length === 1 && !fieldGroups[0].titleKey}
{#each fieldGroups[0].fields as field}
{@render renderField(field)}
{/each}
{:else}
<div class="admin-settings-field-groups">
{#each fieldGroups as fieldGroup}
<section class="admin-settings-field-group">
{#if fieldGroup.titleKey}
<header class="admin-settings-field-group-head">
<strong>{fieldGroupTitle(fieldGroup)}</strong>
{#if fieldGroupDescription(fieldGroup)}
<small>{fieldGroupDescription(fieldGroup)}</small>
{/if}
</header>
{/if}
<div class="admin-settings-field-group-body">
{#each fieldGroup.fields as field}
{@render renderField(field)}
{/each}
</div>
</section>
{/each}
</div>
{/if}
{/snippet}
{#snippet renderField(field)}
{@const revealed = isSecretRevealed(field.key)}
<div class="admin-setting" class:is-overridden={isOverridden(field)}>
<div class="admin-setting-meta">
<strong>
{fieldLabelText(field)}
{#if field.secret}
<AdminBadge variant="warning">{at("settings_badge_secret", {}, "Secret")}</AdminBadge>
{/if}
{#if isOverridden(field)}
<AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge>
{/if}
</strong>
<code>{field.key}</code>
{#if fieldDescriptionText(field)}
<small>{fieldDescriptionText(field)}</small>
{/if}
</div>
<div class="admin-setting-control">
{#if field.type === "bool"}
<div class="admin-setting-switch">
<Switch.Root
checked={Boolean(valueFor(field))}
onCheckedChange={(checked) => setBoolField(field, checked)}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span
>{valueFor(field)
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}</span
>
</div>
{:else if field.type === "color"}
<input
class="admin-color"
type="color"
value={valueFor(field) || "#00fe7a"}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
<input
class="input"
type="text"
value={valueFor(field) || ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{:else if field.type === "icon"}
{@const selectedIconName = iconValue(field)}
{@const SelectedIcon = iconComponent(selectedIconName)}
<AdminButton
class="admin-icon-picker-trigger"
variant="ghost"
onclick={() => openIconPicker(field)}
>
{#if SelectedIcon}
<svelte:component this={SelectedIcon} size={16} />
{/if}
<span>{iconLabel(field)}</span>
</AdminButton>
{#if !iconIsDefault(field)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => settingsStore.markDirty(field.key, "")}
>
<X size={12} />
{at("clear", {}, "Clear")}
</AdminButton>
{/if}
{:else if field.choices && field.choices.length > 0}
<AdminSelect
class="admin-setting-select"
value={valueFor(field) || ""}
items={choiceItems(field)}
ariaLabel={fieldLabelText(field)}
placeholder={fieldPlaceholderText(field) || fieldLabelText(field)}
onValueChange={(value) => settingsStore.markDirty(field.key, value)}
/>
{:else if field.type === "int" || field.type === "float"}
<input
class="input"
type="number"
step={field.type === "float" ? "0.1" : "1"}
placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{:else if field.type === "text"}
<textarea
class="admin-setting-textarea"
rows="4"
placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
></textarea>
{:else if field.type === "json"}
<div class="admin-json-toolbar">
<input
id={"json-file-" + field.key}
class="admin-json-file-input"
type="file"
accept="application/json,.json"
onchange={(event) => handleJsonFile(field, event)}
/>
<label
class="admin-btn admin-btn-sm admin-btn-ghost admin-json-upload"
for={"json-file-" + field.key}
>
<FileText size={13} />
{at("settings_json_upload", {}, "Load .json")}
</label>
{#if valueFor(field)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => settingsStore.markDirty(field.key, "")}
>
<X size={12} />
{at("clear", {}, "Clear")}
</AdminButton>
{/if}
</div>
<textarea
class="admin-setting-textarea admin-setting-json-textarea"
rows="10"
spellcheck="false"
placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
></textarea>
{:else if field.secret}
<input
class="input"
type={revealed ? "text" : "password"}
placeholder={secretPlaceholder(field)}
autocomplete="off"
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
<AdminButton
size="sm"
variant="ghost"
aria-label={revealed ? at("hide", {}, "Скрыть") : at("show", {}, "Показать")}
onclick={() => toggleSecretReveal(field.key)}
>
{#if revealed}<EyeOff size={13} />{:else}<Eye size={13} />{/if}
</AdminButton>
{:else}
<input
class="input"
type="text"
placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{/if}
{#if isOverridden(field) || settingsDirty[field.key]}
<AdminButton size="sm" variant="ghost" onclick={() => settingsStore.resetField(field)}>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
{/snippet}
{#if settingsLoading || !visibleSettingsSections.length}
<AdminEmptyState
>{settingsLoading
? at("loading", {}, "Загрузка…")
: at("no_data", {}, "Нет данных")}</AdminEmptyState
>
{:else}
<div
style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;"
>
<p class="admin-muted" style="margin:0;">
{at(
"settings_hint",
{},
"Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения."
)}
</p>
<div style="display:flex; gap:8px;">
<AdminButton size="sm" variant="ghost" onclick={toggleAllSections}>
{settingsAllOpen
? at("collapse_all", {}, "Свернуть всё")
: at("expand_all", {}, "Развернуть всё")}
</AdminButton>
{#if Object.keys(settingsDirty).length > 0}
<AdminButton
size="sm"
variant="primary"
onclick={() => settingsStore.saveSettings(onSettingsSaved)}
disabled={settingsSaving}
>
{settingsSaving ? at("saving", {}, "Сохранение...") : at("save", {}, "Сохранить")}
</AdminButton>
{/if}
</div>
</div>
<Accordion.Root type="multiple" bind:value={settingsOpenSections} class="admin-accordion">
{#each visibleSettingsSections as section}
{@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length}
{@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length}
<Accordion.Item value={section.id} class="admin-accordion-item admin-card">
<Accordion.Header class="admin-accordion-header">
<Accordion.Trigger class="admin-accordion-trigger">
<span class="admin-accordion-title">{sectionTitle(section.id)}</span>
<span class="admin-accordion-meta">
{at(
"settings_params_count",
{ count: section.fields.length },
`${section.fields.length} параметров`
)}{#if overriddenInSection}
· {at(
"settings_overridden_count",
{ count: overriddenInSection },
`${overriddenInSection} override`
)}{/if}{#if dirtyInSection}
· {at(
"settings_dirty_count",
{ count: dirtyInSection },
`${dirtyInSection} изм.`
)}{/if}
</span>
<ChevronRight size={16} class="admin-accordion-chev" />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="admin-accordion-content">
{@const groups = groupSectionFields(section)}
{@const rootGroup = groups.find((g) => !g.label)}
{@const labelGroups = groups.filter((g) => g.label)}
<div class="admin-settings-fields">
{#if rootGroup}
{#if rootGroup.webhook}
{@render renderWebhookHint(rootGroup.webhook)}
{/if}
{@render renderGroupedFields(section, rootGroup)}
{/if}
{#if labelGroups.length}
<Accordion.Root
type="multiple"
value={settingsOpenSubsections[section.id] || []}
onValueChange={(v) =>
(settingsOpenSubsections = { ...settingsOpenSubsections, [section.id]: v })}
class="admin-subsection-accordion"
>
{#each labelGroups as group}
{@const subDirty = group.fields.filter((f) =>
Boolean(settingsDirty[f.key])
).length}
{@const subOverridden = group.fields.filter((f) => isOverridden(f)).length}
<Accordion.Item value={group.id} class="admin-settings-subsection">
<Accordion.Header class="admin-accordion-header">
<Accordion.Trigger class="admin-settings-subsection-trigger">
<strong>{subsectionTitle(group)}</strong>
<span class="admin-settings-subsection-meta">
{at(
"settings_fields_count",
{ count: group.fields.length },
`${group.fields.length} полей`
)}{#if subOverridden}
· {at(
"settings_overridden_count",
{ count: subOverridden },
`${subOverridden} override`
)}{/if}{#if subDirty}
· {at(
"settings_dirty_count",
{ count: subDirty },
`${subDirty} изм.`
)}{/if}
</span>
<ChevronRight size={14} class="admin-accordion-chev" />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="admin-accordion-content">
<div class="admin-settings-subsection-body">
{#if group.webhook}
{@render renderWebhookHint(group.webhook)}
{/if}
{@render renderGroupedFields(section, group)}
</div>
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion.Root>
{/if}
</div>
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion.Root>
{/if}
<Dialog
open={Boolean(iconPickerField)}
title={at("settings_icon_picker_title", {}, "Choose icon")}
description={iconPickerField ? fieldLabelText(iconPickerField) : ""}
closeLabel={at("close", {}, "Close")}
onclose={closeIconPicker}
class="admin-icon-picker-dialog"
>
<div class="admin-icon-picker-body">
{#if iconPickerField}
{@const currentIconName = iconValue(iconPickerField)}
{@const CurrentIcon = iconComponent(currentIconName)}
<div class="admin-icon-picker-current">
<span class="admin-icon-picker-current-preview" aria-hidden="true">
{#if CurrentIcon}
<svelte:component this={CurrentIcon} size={24} />
{/if}
</span>
<span class="admin-icon-picker-current-meta">
<small>{at("settings_icon_current", {}, "Current icon")}</small>
<strong>{iconLabel(iconPickerField)}</strong>
</span>
{#if !iconIsDefault(iconPickerField)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => settingsStore.markDirty(iconPickerField.key, "")}
>
<X size={12} />
{at("settings_icon_use_default", {}, "Use default")}
</AdminButton>
{/if}
</div>
{/if}
<div class="admin-icon-picker-toolbar">
<label class="admin-icon-picker-search">
<Search size={15} />
<input
bind:value={iconPickerSearch}
class="input"
type="text"
placeholder={at("search", {}, "Search")}
/>
</label>
</div>
<div class="admin-icon-picker-grid">
{#each filteredIconOptions as iconName}
{@const Icon = iconComponent(iconName)}
<button
class:active={iconPickerField && iconValue(iconPickerField) === iconName}
class="admin-icon-picker-option"
type="button"
onclick={() => selectIcon(iconName)}
>
{#if Icon}
<svelte:component this={Icon} size={18} />
{/if}
<span>{iconName}</span>
</button>
{/each}
</div>
</div>
</Dialog>