feat: add runtime locale overrides
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
Download,
|
||||
FileText,
|
||||
Globe2,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
LifeBuoy,
|
||||
Megaphone,
|
||||
@@ -38,6 +39,7 @@
|
||||
import SupportSection from "./sections/SupportSection.svelte";
|
||||
import TariffEditorModal from "./sections/TariffEditorModal.svelte";
|
||||
import TariffsSection from "./sections/TariffsSection.svelte";
|
||||
import TranslationsSection from "./sections/TranslationsSection.svelte";
|
||||
import AppearanceSection from "./sections/AppearanceSection.svelte";
|
||||
import UserDetailModal from "./sections/UserDetailModal.svelte";
|
||||
import UsersSection from "./sections/UsersSection.svelte";
|
||||
@@ -51,6 +53,7 @@
|
||||
import { createAdminSupportStore } from "../lib/admin/stores/supportStore.js";
|
||||
import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js";
|
||||
import { createThemesStore } from "../lib/admin/stores/themesStore.js";
|
||||
import { createTranslationsStore } from "../lib/admin/stores/translationsStore.js";
|
||||
import { createUsersStore } from "../lib/admin/stores/usersStore.js";
|
||||
import {
|
||||
fmtDate,
|
||||
@@ -83,6 +86,7 @@
|
||||
export let onSettingsSaved = () => {};
|
||||
export let onTariffsSaved = () => {};
|
||||
export let onThemesSaved = () => {};
|
||||
export let onTranslationsSaved = () => {};
|
||||
export let brand = {};
|
||||
export let brandTitle = "/minishop";
|
||||
export let appFaviconUrl = "";
|
||||
@@ -128,6 +132,7 @@
|
||||
items: [
|
||||
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
|
||||
{ id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush },
|
||||
{ id: "translations", label: at("nav_translations", {}, "Переводы"), icon: Languages },
|
||||
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
|
||||
],
|
||||
},
|
||||
@@ -178,6 +183,14 @@
|
||||
title: at("section_appearance_title", {}, "Внешний вид"),
|
||||
subtitle: at("section_appearance_subtitle", {}, "Логотип, темы и акцентные цвета Mini App"),
|
||||
},
|
||||
translations: {
|
||||
title: at("section_translations_title", {}, "Переводы"),
|
||||
subtitle: at(
|
||||
"section_translations_subtitle",
|
||||
{},
|
||||
"Оверрайды строк локализации из базы данных и data/locales-overrides.json"
|
||||
),
|
||||
},
|
||||
settings: {
|
||||
title: at("section_settings_title", {}, "Настройки приложения"),
|
||||
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
|
||||
@@ -223,6 +236,7 @@
|
||||
const supportStore = createAdminSupportStore({ api, onToast: flash, at });
|
||||
const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at });
|
||||
const themesStore = createThemesStore({ api, onThemesSaved, flash, at });
|
||||
const translationsStore = createTranslationsStore({ api, onToast: flash, at });
|
||||
const usersStore = createUsersStore({ api, onToast: flash, at });
|
||||
|
||||
setContext("promosStore", promosStore);
|
||||
@@ -236,13 +250,16 @@
|
||||
setContext("usersStore", usersStore);
|
||||
setContext("tariffsStore", tariffsStore);
|
||||
setContext("themesStore", themesStore);
|
||||
setContext("translationsStore", translationsStore);
|
||||
|
||||
$: usersStore.setActive(active);
|
||||
$: paymentsStore.setActive(active);
|
||||
$: supportStore.setActive(active);
|
||||
$: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length;
|
||||
$: translationsDirtyCount = Object.keys($translationsStore.translationsDirty || {}).length;
|
||||
$: syncBusy = $statsStore.syncBusy;
|
||||
$: settingsSaving = $settingsStore.settingsSaving;
|
||||
$: translationsSaving = $translationsStore.translationsSaving;
|
||||
$: meta = SECTION_META[active] || { title: active, subtitle: "" };
|
||||
$: currentLanguageOption =
|
||||
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
|
||||
@@ -676,6 +693,27 @@
|
||||
: at("btn_save", {}, "Сохранить")}
|
||||
</AdminButton>
|
||||
{/if}
|
||||
{#if active === "translations"}
|
||||
{#if translationsDirtyCount}
|
||||
<AdminBadge variant="warning"
|
||||
>{at(
|
||||
"settings_dirty_count",
|
||||
{ count: translationsDirtyCount },
|
||||
"Изменений: " + translationsDirtyCount
|
||||
)}</AdminBadge
|
||||
>
|
||||
{/if}
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
onclick={() => translationsStore.saveTranslations(onTranslationsSaved)}
|
||||
disabled={!translationsDirtyCount || translationsSaving}
|
||||
>
|
||||
<Save size={14} />
|
||||
{translationsSaving
|
||||
? at("btn_saving", {}, "Сохранение...")
|
||||
: at("btn_save", {}, "Сохранить")}
|
||||
</AdminButton>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -751,6 +789,10 @@
|
||||
{#if active === "settings"}
|
||||
<SettingsSection {at} {onSettingsSaved} {currentLang} />
|
||||
{/if}
|
||||
|
||||
{#if active === "translations"}
|
||||
<TranslationsSection {at} {onTranslationsSaved} />
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
<script>
|
||||
import { ChevronRight, Languages, Plus, Search, X } from "$components/ui/icons.js";
|
||||
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
|
||||
import { getContext, onDestroy, onMount } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
|
||||
export let at;
|
||||
export let onTranslationsSaved;
|
||||
|
||||
const translationsStore = getContext("translationsStore");
|
||||
const AUDIENCE_ORDER = ["user", "internal"];
|
||||
const AUDIENCE_FILTERS = ["all", ...AUDIENCE_ORDER];
|
||||
|
||||
$: ({
|
||||
translationGroups,
|
||||
translationLanguages,
|
||||
translationsLoading,
|
||||
translationsDirty,
|
||||
translationsSaving,
|
||||
translationsPath,
|
||||
} = $translationsStore);
|
||||
|
||||
let openGroups = [];
|
||||
let readyGroups = [];
|
||||
let openLocaleEditors = [];
|
||||
let closedLocaleEditors = [];
|
||||
let search = "";
|
||||
let audienceFilter = "all";
|
||||
let newLanguageCode = "";
|
||||
const readyTimers = new Map();
|
||||
|
||||
$: openGroupSet = new Set(openGroups);
|
||||
$: readyGroupSet = new Set(readyGroups);
|
||||
$: openLocaleEditorSet = new Set(openLocaleEditors);
|
||||
$: closedLocaleEditorSet = new Set(closedLocaleEditors);
|
||||
$: filteredTranslationGroups = filteredGroups(translationGroups, search, translationLanguages);
|
||||
$: audienceSections = buildAudienceSections(filteredTranslationGroups, audienceFilter);
|
||||
$: visibleGroupKeys = audienceSections.flatMap((section) =>
|
||||
section.groups.map((group) => groupPanelId(section.id, group.id))
|
||||
);
|
||||
$: allOpen =
|
||||
visibleGroupKeys.length > 0 && visibleGroupKeys.every((key) => openGroups.includes(key));
|
||||
$: scheduleReadyGroups(openGroups);
|
||||
|
||||
onMount(() => {
|
||||
translationsStore.loadTranslations();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
for (const timer of readyTimers.values()) clearTimeout(timer);
|
||||
readyTimers.clear();
|
||||
});
|
||||
|
||||
function dirtyKey(lang, key) {
|
||||
return `${lang}:${key}`;
|
||||
}
|
||||
|
||||
function dirtyFor(lang, key) {
|
||||
return translationsDirty[dirtyKey(lang, key)] || null;
|
||||
}
|
||||
|
||||
function defaultBaseValue(item) {
|
||||
for (const values of Object.values(item.values || {})) {
|
||||
if (values?.fallback) return values.fallback;
|
||||
}
|
||||
const baseLanguage = (translationLanguages || []).find((language) => language.base);
|
||||
const baseCode = baseLanguage?.code || translationLanguages?.[0]?.code || "";
|
||||
const values = item.values?.[baseCode] || {};
|
||||
return values.base || values.fallback || values.effective || "";
|
||||
}
|
||||
|
||||
function valueRecord(item, lang) {
|
||||
return (
|
||||
item.values?.[lang] || {
|
||||
base: "",
|
||||
fallback: defaultBaseValue(item),
|
||||
effective: defaultBaseValue(item),
|
||||
override: "",
|
||||
overridden: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function localeValue(item, lang, dirty = dirtyFor(lang, item.key)) {
|
||||
if (dirty?.deleted) return "";
|
||||
if (dirty) return dirty.value;
|
||||
return valueRecord(item, lang).override || "";
|
||||
}
|
||||
|
||||
function isOverridden(item, lang, dirty = dirtyFor(lang, item.key)) {
|
||||
return Boolean(valueRecord(item, lang).overridden) && !dirty?.deleted;
|
||||
}
|
||||
|
||||
function isDirty(item, lang, dirty = dirtyFor(lang, item.key)) {
|
||||
return Boolean(dirty);
|
||||
}
|
||||
|
||||
function baseValue(item, lang) {
|
||||
const values = valueRecord(item, lang);
|
||||
return values.base || values.fallback || "";
|
||||
}
|
||||
|
||||
function baseKind(item, lang) {
|
||||
return valueRecord(item, lang).base
|
||||
? at("translations_base_value", {}, "Base")
|
||||
: at("translations_fallback_value", {}, "Fallback");
|
||||
}
|
||||
|
||||
function effectiveValue(item, lang) {
|
||||
return valueRecord(item, lang).effective || baseValue(item, lang);
|
||||
}
|
||||
|
||||
function localePreview(item, lang, dirty = dirtyFor(lang, item.key)) {
|
||||
return (
|
||||
localeValue(item, lang, dirty) || effectiveValue(item, lang) || baseValue(item, lang) || "-"
|
||||
);
|
||||
}
|
||||
|
||||
function itemAudience(item, group = null) {
|
||||
return item.audience || group?.audience || "user";
|
||||
}
|
||||
|
||||
function audienceLabel(id) {
|
||||
if (id === "internal") {
|
||||
return at("translations_audience_internal", {}, "Admin/internal");
|
||||
}
|
||||
if (id === "user") {
|
||||
return at("translations_audience_user", {}, "User-visible");
|
||||
}
|
||||
return at("translations_audience_all", {}, "All");
|
||||
}
|
||||
|
||||
function audienceHint(id) {
|
||||
if (id === "internal") {
|
||||
return at("translations_audience_internal_hint", {}, "Admin panel, logs, and sync copy");
|
||||
}
|
||||
return at("translations_audience_user_hint", {}, "Mini App, bot, payment, and support copy");
|
||||
}
|
||||
|
||||
function groupPanelId(sectionId, groupId) {
|
||||
return `${sectionId}:${groupId}`;
|
||||
}
|
||||
|
||||
function localePanelId(key, lang) {
|
||||
return `${key}:${lang}`;
|
||||
}
|
||||
|
||||
function toggleLocaleEditor(item, lang) {
|
||||
const id = localePanelId(item.key, lang);
|
||||
const defaultOpen = isOverridden(item, lang) || isDirty(item, lang);
|
||||
const openByUser = openLocaleEditors.includes(id);
|
||||
const closedByUser = closedLocaleEditors.includes(id);
|
||||
const currentlyOpen = openByUser || (defaultOpen && !closedByUser);
|
||||
|
||||
if (currentlyOpen) {
|
||||
openLocaleEditors = openLocaleEditors.filter((itemId) => itemId !== id);
|
||||
if (!closedByUser) closedLocaleEditors = [...closedLocaleEditors, id];
|
||||
return;
|
||||
}
|
||||
|
||||
closedLocaleEditors = closedLocaleEditors.filter((itemId) => itemId !== id);
|
||||
if (!openByUser) openLocaleEditors = [...openLocaleEditors, id];
|
||||
}
|
||||
|
||||
function groupDirtyCount(
|
||||
group,
|
||||
dirtyState = translationsDirty,
|
||||
languages = translationLanguages
|
||||
) {
|
||||
return (group.items || []).reduce(
|
||||
(count, item) =>
|
||||
count +
|
||||
languages.filter((lang) => Boolean(dirtyState[dirtyKey(lang.code, item.key)])).length,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function groupOverrideCount(
|
||||
group,
|
||||
dirtyState = translationsDirty,
|
||||
languages = translationLanguages
|
||||
) {
|
||||
return (group.items || []).reduce(
|
||||
(count, item) =>
|
||||
count +
|
||||
languages.filter((lang) =>
|
||||
isOverridden(item, lang.code, dirtyState[dirtyKey(lang.code, item.key)])
|
||||
).length,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function itemHasOverride(item, dirtyState = translationsDirty, languages = translationLanguages) {
|
||||
return languages.some((lang) =>
|
||||
isOverridden(item, lang.code, dirtyState[dirtyKey(lang.code, item.key)])
|
||||
);
|
||||
}
|
||||
|
||||
function itemHasDirty(item, dirtyState = translationsDirty, languages = translationLanguages) {
|
||||
return languages.some((lang) => Boolean(dirtyState[dirtyKey(lang.code, item.key)]));
|
||||
}
|
||||
|
||||
function filteredGroups(groups, query, languages = translationLanguages) {
|
||||
const needle = String(query || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!needle) return groups || [];
|
||||
return (groups || [])
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: (group.items || []).filter((item) => itemMatches(item, group, needle, languages)),
|
||||
}))
|
||||
.filter((group) => group.items.length);
|
||||
}
|
||||
|
||||
function itemMatches(item, group, needle, languages = translationLanguages) {
|
||||
if (
|
||||
String(item.key || "")
|
||||
.toLowerCase()
|
||||
.includes(needle)
|
||||
)
|
||||
return true;
|
||||
if (audienceLabel(itemAudience(item, group)).toLowerCase().includes(needle)) return true;
|
||||
return languages.some((lang) => {
|
||||
const values = valueRecord(item, lang.code);
|
||||
return [values.base, values.fallback, values.override, values.effective]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(needle));
|
||||
});
|
||||
}
|
||||
|
||||
function buildAudienceSections(groups, filter) {
|
||||
return AUDIENCE_ORDER.map((audience) => ({
|
||||
id: audience,
|
||||
title: audienceLabel(audience),
|
||||
hint: audienceHint(audience),
|
||||
groups: (groups || [])
|
||||
.map((group) => ({
|
||||
...group,
|
||||
audience,
|
||||
items: (group.items || []).filter((item) => itemAudience(item, group) === audience),
|
||||
}))
|
||||
.filter((group) => group.items.length),
|
||||
})).filter((section) => (filter === "all" || section.id === filter) && section.groups.length);
|
||||
}
|
||||
|
||||
function toggleAllGroups() {
|
||||
openGroups = allOpen ? [] : visibleGroupKeys;
|
||||
}
|
||||
|
||||
function isGroupOpen(id) {
|
||||
return openGroups.includes(id);
|
||||
}
|
||||
|
||||
function toggleGroup(id) {
|
||||
if (isGroupOpen(id)) {
|
||||
openGroups = openGroups.filter((groupId) => groupId !== id);
|
||||
return;
|
||||
}
|
||||
openGroups = [...openGroups, id];
|
||||
queueGroupReady(id);
|
||||
}
|
||||
|
||||
function scheduleReadyGroups(groups) {
|
||||
const openSet = new Set(groups);
|
||||
const nextReady = readyGroups.filter((id) => openSet.has(id));
|
||||
if (nextReady.length !== readyGroups.length) readyGroups = nextReady;
|
||||
for (const [id, timer] of readyTimers.entries()) {
|
||||
if (!openSet.has(id)) {
|
||||
clearTimeout(timer);
|
||||
readyTimers.delete(id);
|
||||
}
|
||||
}
|
||||
for (const id of groups) {
|
||||
queueGroupReady(id);
|
||||
}
|
||||
}
|
||||
|
||||
function queueGroupReady(id) {
|
||||
if (readyGroups.includes(id) || readyTimers.has(id)) return;
|
||||
readyTimers.set(
|
||||
id,
|
||||
setTimeout(() => {
|
||||
readyTimers.delete(id);
|
||||
if (!readyGroups.includes(id)) {
|
||||
readyGroups = [...readyGroups, id];
|
||||
}
|
||||
}, 70)
|
||||
);
|
||||
}
|
||||
|
||||
function groupTitle(group) {
|
||||
return group.title_key ? at(group.title_key, {}, group.title) : group.title;
|
||||
}
|
||||
|
||||
function groupDescription(group) {
|
||||
return group.description_key
|
||||
? at(group.description_key, {}, group.description)
|
||||
: group.description;
|
||||
}
|
||||
|
||||
function canAddLanguage(code) {
|
||||
const normalized = String(code || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/_/g, "-");
|
||||
return (
|
||||
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(normalized) &&
|
||||
normalized.length >= 2 &&
|
||||
normalized.length <= 16 &&
|
||||
!translationLanguages.some((lang) => lang.code === normalized)
|
||||
);
|
||||
}
|
||||
|
||||
function addLanguage() {
|
||||
if (translationsStore.addTranslationLanguage(newLanguageCode)) {
|
||||
newLanguageCode = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderTranslationsSkeleton()}
|
||||
<div class="admin-translations-skeleton">
|
||||
<div class="admin-translations-toolbar">
|
||||
<span class="admin-skeleton admin-skeleton-line"></span>
|
||||
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span>
|
||||
</div>
|
||||
{#each Array(4) as _, index (index)}
|
||||
<div class="admin-card admin-translation-skeleton-card">
|
||||
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"></span>
|
||||
<span class="admin-skeleton admin-skeleton-line"></span>
|
||||
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-soft"></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet renderGroupSkeleton(group)}
|
||||
<div class="admin-translation-group-skeleton" aria-label={at("loading", {}, "Loading")}>
|
||||
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span>
|
||||
{#each Array(Math.min(3, Math.max(1, group.items.length))) as _, index (index)}
|
||||
<div class="admin-translation-row admin-translation-row-skeleton">
|
||||
<span>
|
||||
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"></span>
|
||||
<span class="admin-skeleton admin-skeleton-line"></span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="admin-skeleton admin-skeleton-line"></span>
|
||||
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-soft"></span>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet renderLocaleEditor(item, language)}
|
||||
{@const lang = language.code}
|
||||
{@const dirtyEntry = translationsDirty[dirtyKey(lang, item.key)] || null}
|
||||
{@const overridden = isOverridden(item, lang, dirtyEntry)}
|
||||
{@const dirty = isDirty(item, lang, dirtyEntry)}
|
||||
{@const localeId = localePanelId(item.key, lang)}
|
||||
{@const expanded =
|
||||
openLocaleEditorSet.has(localeId) ||
|
||||
((overridden || dirty) && !closedLocaleEditorSet.has(localeId))}
|
||||
<div
|
||||
class="admin-translation-locale"
|
||||
class:is-overridden={overridden}
|
||||
class:is-dirty={dirty}
|
||||
class:is-expanded={expanded}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-translation-locale-toggle"
|
||||
aria-expanded={expanded}
|
||||
onclick={() => toggleLocaleEditor(item, lang)}
|
||||
>
|
||||
<span class="admin-translation-locale-main">
|
||||
<strong>{language.label}</strong>
|
||||
<code>{lang}</code>
|
||||
</span>
|
||||
<span class="admin-translation-locale-badges">
|
||||
{#if !language.base}
|
||||
<AdminBadge variant="muted">{at("translations_language_custom", {}, "Custom")}</AdminBadge
|
||||
>
|
||||
{/if}
|
||||
{#if overridden}
|
||||
<AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge>
|
||||
{/if}
|
||||
{#if dirty}
|
||||
<AdminBadge variant="warning">{at("settings_badge_dirty", {}, "Dirty")}</AdminBadge>
|
||||
{/if}
|
||||
<ChevronRight size={14} class="admin-accordion-chev" />
|
||||
</span>
|
||||
<small>{localePreview(item, lang, dirtyEntry)}</small>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="admin-translation-locale-body" transition:slide={{ duration: 130 }}>
|
||||
<textarea
|
||||
class="admin-setting-textarea admin-translation-textarea"
|
||||
rows="3"
|
||||
spellcheck="false"
|
||||
placeholder={baseValue(item, lang)}
|
||||
value={localeValue(item, lang, dirtyEntry)}
|
||||
oninput={(event) =>
|
||||
translationsStore.markDirty(lang, item.key, event.currentTarget.value)}
|
||||
></textarea>
|
||||
<div class="admin-translation-base">
|
||||
<small>{baseKind(item, lang)}</small>
|
||||
<span title={baseValue(item, lang)}>{baseValue(item, lang) || "-"}</span>
|
||||
</div>
|
||||
{#if overridden || dirty}
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => translationsStore.resetField(lang, item.key, overridden)}
|
||||
>
|
||||
<X size={12} />
|
||||
{at("reset", {}, "Reset")}
|
||||
</AdminButton>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet renderTranslationItem(item, group)}
|
||||
{@const audience = itemAudience(item, group)}
|
||||
<div class="admin-translation-row">
|
||||
<div class="admin-setting-meta">
|
||||
<strong>
|
||||
{item.key}
|
||||
<AdminBadge variant={audience === "internal" ? "warning" : "success"}>
|
||||
{audienceLabel(audience)}
|
||||
</AdminBadge>
|
||||
{#if itemHasOverride(item, translationsDirty, translationLanguages)}
|
||||
<AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge>
|
||||
{/if}
|
||||
{#if itemHasDirty(item, translationsDirty, translationLanguages)}
|
||||
<AdminBadge variant="warning">{at("settings_badge_dirty", {}, "Dirty")}</AdminBadge>
|
||||
{/if}
|
||||
</strong>
|
||||
<code>{item.key}</code>
|
||||
<small>{effectiveValue(item, translationLanguages[0]?.code)}</small>
|
||||
</div>
|
||||
<div class="admin-translation-locales">
|
||||
{#each translationLanguages as language (language.code)}
|
||||
{@render renderLocaleEditor(item, language)}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if translationsLoading || !translationGroups.length}
|
||||
{@render renderTranslationsSkeleton()}
|
||||
{:else}
|
||||
<div class="admin-translations-toolbar">
|
||||
<label class="admin-translations-search">
|
||||
<Search size={15} />
|
||||
<input
|
||||
bind:value={search}
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={at("translations_search_placeholder", {}, "Search keys and text")}
|
||||
/>
|
||||
</label>
|
||||
<div class="admin-translations-actions">
|
||||
<AdminButton size="sm" variant="ghost" onclick={toggleAllGroups}>
|
||||
{allOpen ? at("collapse_all", {}, "Collapse all") : at("expand_all", {}, "Expand all")}
|
||||
</AdminButton>
|
||||
{#if Object.keys(translationsDirty).length > 0}
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onclick={() => translationsStore.saveTranslations(onTranslationsSaved)}
|
||||
disabled={translationsSaving}
|
||||
>
|
||||
{translationsSaving ? at("saving", {}, "Saving...") : at("save", {}, "Save")}
|
||||
</AdminButton>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-translations-language-panel">
|
||||
<div class="admin-translations-language-head">
|
||||
<Languages size={17} />
|
||||
<strong>{at("translations_languages_title", {}, "Languages")}</strong>
|
||||
<small>{at("translations_languages_hint", {}, "Override any locale code")}</small>
|
||||
</div>
|
||||
<div class="admin-translations-language-list">
|
||||
{#each translationLanguages as language (language.code)}
|
||||
<span class="admin-translations-language-chip" class:is-custom={!language.base}>
|
||||
<strong>{language.label}</strong>
|
||||
<code>{language.code}</code>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<form
|
||||
class="admin-translations-language-add"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
addLanguage();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
bind:value={newLanguageCode}
|
||||
class="input"
|
||||
type="text"
|
||||
inputmode="latin"
|
||||
placeholder={at("translations_language_placeholder", {}, "de, uk, pt-BR")}
|
||||
/>
|
||||
<AdminButton type="submit" size="sm" disabled={!canAddLanguage(newLanguageCode)}>
|
||||
<Plus size={14} />
|
||||
{at("add", {}, "Add")}
|
||||
</AdminButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="admin-translations-audience-tabs" role="tablist">
|
||||
{#each AUDIENCE_FILTERS as option (option)}
|
||||
<button
|
||||
type="button"
|
||||
class:is-active={audienceFilter === option}
|
||||
onclick={() => {
|
||||
audienceFilter = option;
|
||||
openGroups = [];
|
||||
}}
|
||||
>
|
||||
{audienceLabel(option)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="admin-muted admin-translations-path">
|
||||
{at(
|
||||
"translations_hint",
|
||||
{ path: translationsPath },
|
||||
`Overrides are stored in DB and mirrored to ${translationsPath}.`
|
||||
)}
|
||||
</p>
|
||||
|
||||
{#if audienceSections.length}
|
||||
<div class="admin-translations-accordion-root">
|
||||
{#each audienceSections as section (section.id)}
|
||||
<section class="admin-translations-audience-section">
|
||||
<div class="admin-translations-audience-head">
|
||||
<span>
|
||||
<strong>{section.title}</strong>
|
||||
<small>{section.hint}</small>
|
||||
</span>
|
||||
<AdminBadge variant={section.id === "internal" ? "warning" : "success"}>
|
||||
{section.groups.reduce((count, group) => count + group.items.length, 0)}
|
||||
</AdminBadge>
|
||||
</div>
|
||||
<div class="admin-accordion">
|
||||
{#each section.groups as group (groupPanelId(section.id, group.id))}
|
||||
{@const dirtyCount = groupDirtyCount(group, translationsDirty, translationLanguages)}
|
||||
{@const overrideCount = groupOverrideCount(
|
||||
group,
|
||||
translationsDirty,
|
||||
translationLanguages
|
||||
)}
|
||||
{@const panelId = groupPanelId(section.id, group.id)}
|
||||
{@const groupOpen = openGroupSet.has(panelId)}
|
||||
{@const groupReady = readyGroupSet.has(panelId)}
|
||||
<div
|
||||
class="admin-accordion-item admin-card"
|
||||
data-state={groupOpen ? "open" : "closed"}
|
||||
>
|
||||
<div class="admin-accordion-header">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-accordion-trigger"
|
||||
data-state={groupOpen ? "open" : "closed"}
|
||||
aria-expanded={groupOpen}
|
||||
onclick={() => toggleGroup(panelId)}
|
||||
>
|
||||
<span class="admin-accordion-title admin-translation-title-line">
|
||||
{groupTitle(group)}
|
||||
<AdminBadge variant={section.id === "internal" ? "warning" : "success"}>
|
||||
{section.title}
|
||||
</AdminBadge>
|
||||
</span>
|
||||
<span class="admin-accordion-meta">
|
||||
{at(
|
||||
"translations_keys_count",
|
||||
{ count: group.items.length },
|
||||
`${group.items.length} keys`
|
||||
)}{#if overrideCount}
|
||||
/ {at(
|
||||
"settings_overridden_count",
|
||||
{ count: overrideCount },
|
||||
`${overrideCount} override`
|
||||
)}{/if}{#if dirtyCount}
|
||||
/ {at(
|
||||
"settings_dirty_count",
|
||||
{ count: dirtyCount },
|
||||
`${dirtyCount} changed`
|
||||
)}
|
||||
{/if}
|
||||
</span>
|
||||
<ChevronRight size={16} class="admin-accordion-chev" />
|
||||
</button>
|
||||
</div>
|
||||
{#if groupOpen}
|
||||
<div
|
||||
class="admin-accordion-content"
|
||||
data-state="open"
|
||||
transition:slide={{ duration: 140 }}
|
||||
>
|
||||
{#if groupReady}
|
||||
{#if groupDescription(group)}
|
||||
<p class="admin-muted admin-translation-group-description">
|
||||
{groupDescription(group)}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="admin-translation-list">
|
||||
{#each group.items as item (item.key)}
|
||||
{@render renderTranslationItem(item, group)}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{@render renderGroupSkeleton(group)}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<AdminEmptyState>{at("translations_no_matches", {}, "No matching strings")}</AdminEmptyState>
|
||||
{/if}
|
||||
{/if}
|
||||
Reference in New Issue
Block a user