feat: allow to edit remnawave panel settings in admin panel, ensure using i18n

This commit is contained in:
3252a8
2026-05-21 11:26:03 +03:00
parent b3f67a5398
commit 028f0680c6
6 changed files with 301 additions and 38 deletions
@@ -23,6 +23,7 @@
const themesStore = getContext("themesStore");
const APPEARANCE_SETTING_KEYS = new Set([
"WEBAPP_TITLE",
"SUBSCRIPTION_MINI_APP_URL",
"WEBAPP_PRIMARY_COLOR",
"WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
@@ -76,7 +77,7 @@
};
$: emojiFontItems = (fieldMap.get("WEBAPP_LOGO_EMOJI_FONT")?.choices || []).map((item) => ({
value: item.value,
label: item.label,
label: item.i18n_label_key ? adminText(item.i18n_label_key, {}, item.label) : item.label,
}));
$: dirtyCount = Object.keys(settingsDirty || {}).filter((key) =>
isAppearanceSettingKey(key)
@@ -135,6 +136,15 @@
return Boolean(value);
}
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 withLogoCacheBust(url) {
return withCacheBust(url, logoPreviewNonce);
}
@@ -80,9 +80,9 @@
}
function secretPlaceholder(field) {
if (settingsDirty[field.key]?.deleted) return field.placeholder || "••••••••";
if (settingsDirty[field.key]?.deleted) return fieldPlaceholderText(field) || "********";
if (field.has_value) return at("settings_secret_configured", {}, "Secret is set");
return field.placeholder || at("settings_secret_empty", {}, "Not set");
return fieldPlaceholderText(field) || at("settings_secret_empty", {}, "Not set");
}
function iconComponent(name) {
@@ -127,29 +127,45 @@
const groups = new Map();
for (const field of section.fields || []) {
const key = field.subsection || "_root";
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(field);
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, fields]) => ({
return Array.from(groups.entries()).map(([id, group]) => ({
id,
label: id === "_root" ? null : id,
fields,
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: at("admin_settings_section_general", {}, "Общие"),
appearance: at("admin_settings_section_appearance", {}, "Внешний вид"),
pricing: at("admin_settings_section_pricing", {}, "Тарифы и цены"),
payments: at("admin_settings_section_payments", {}, "Платёжные системы"),
trial: at("admin_settings_section_trial", {}, "Триал"),
referral: at("admin_settings_section_referral", {}, "Реферальная программа"),
notifications: at("admin_settings_section_notifications", {}, "Уведомления"),
support: at("admin_settings_section_support", {}, "Поддержка"),
devices: at("admin_settings_section_devices", {}, "Устройства"),
general: "Общие",
appearance: "Внешний вид",
pricing: "Тарифы и цены",
payments: "Платёжные системы",
trial: "Триал",
referral: "Реферальная программа",
notifications: "Уведомления",
support: "Поддержка",
devices: "Устройства",
};
return map[id] || id;
return adminText(`settings_section_${id}`, {}, map[id] || id);
}
function englishFieldLabelFallback(key, originalLabel) {
@@ -173,7 +189,33 @@
.toLowerCase()
.startsWith("en");
const fallback = isEnglish ? englishFieldLabelFallback(field.key, field.label) : field.label;
return field.i18n_label_key ? at(field.i18n_label_key, {}, fallback) : fallback;
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>
@@ -191,12 +233,8 @@
{/if}
</strong>
<code>{field.key}</code>
{#if field.description}
<small
>{field.i18n_description_key
? at(field.i18n_description_key, {}, field.description)
: field.description}</small
>
{#if fieldDescriptionText(field)}
<small>{fieldDescriptionText(field)}</small>
{/if}
</div>
<div class="admin-setting-control">
@@ -255,9 +293,9 @@
<AdminSelect
class="admin-setting-select"
value={valueFor(field) || ""}
items={field.choices}
items={choiceItems(field)}
ariaLabel={fieldLabelText(field)}
placeholder={field.placeholder || fieldLabelText(field)}
placeholder={fieldPlaceholderText(field) || fieldLabelText(field)}
onValueChange={(value) => settingsStore.markDirty(field.key, value)}
/>
{:else if field.type === "int" || field.type === "float"}
@@ -265,7 +303,7 @@
class="input"
type="number"
step={field.type === "float" ? "0.1" : "1"}
placeholder={field.placeholder}
placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
@@ -273,7 +311,7 @@
<textarea
class="admin-setting-textarea"
rows="4"
placeholder={field.placeholder}
placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
></textarea>
@@ -298,7 +336,7 @@
<input
class="input"
type="text"
placeholder={field.placeholder}
placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
@@ -402,7 +440,7 @@
<Accordion.Item value={group.id} class="admin-settings-subsection">
<Accordion.Header class="admin-accordion-header">
<Accordion.Trigger class="admin-settings-subsection-trigger">
<strong>{group.label}</strong>
<strong>{subsectionTitle(group)}</strong>
<span class="admin-settings-subsection-meta">
{at(
"settings_fields_count",
@@ -85,9 +85,9 @@ export function createSettingsStore({ api, onToast, at }) {
const summary = Object.entries(res.errors)
.map(([k, v]) => `${k}: ${v}`)
.join("; ");
onToast(`Ошибки: ${summary}`);
onToast(at("settings_validation_errors", { errors: summary }, `Ошибки: ${summary}`));
} else {
onToast(res?.error || "Ошибка");
onToast(at("settings_save_error", { error: res?.error || "" }, res?.error || "Ошибка"));
}
return false;
} finally {