545 lines
19 KiB
Svelte
545 lines
19 KiB
Svelte
<script>
|
|
import { ChevronRight, Eye, EyeOff, 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, onMount } from "svelte";
|
|
|
|
export let at;
|
|
export let onSettingsSaved;
|
|
export let isCompact = false;
|
|
export let currentLang = "ru";
|
|
|
|
const settingsStore = getContext("settingsStore");
|
|
|
|
$: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore);
|
|
$: visibleSettingsSections = settingsSections.filter((section) => section.id !== "appearance");
|
|
|
|
let settingsOpenSections = [];
|
|
let settingsOpenSubsections = {};
|
|
let revealedSecrets = new Set();
|
|
let iconPickerField = null;
|
|
let iconPickerSearch = "";
|
|
|
|
$: 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().then(() => {
|
|
if ($settingsStore.settingsSections.length) {
|
|
const ids = $settingsStore.settingsSections
|
|
.filter((s) => s.id !== "appearance")
|
|
.map((s) => s.id);
|
|
settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice();
|
|
}
|
|
});
|
|
});
|
|
|
|
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();
|
|
}
|
|
|
|
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,
|
|
fields: group.fields,
|
|
}));
|
|
}
|
|
|
|
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: "Устройства",
|
|
};
|
|
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,
|
|
}));
|
|
}
|
|
</script>
|
|
|
|
{#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) => settingsStore.markDirty(field.key, 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.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}
|
|
{#each rootGroup.fields as field}
|
|
{@render renderField(field)}
|
|
{/each}
|
|
{/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">
|
|
{#each group.fields as field}
|
|
{@render renderField(field)}
|
|
{/each}
|
|
</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>
|