feat(project-editor): add Languages tab with generic locale sync

This commit is contained in:
sdarbinyan
2026-07-13 08:10:00 +04:00
parent 74e5099b04
commit b166920ad7
12 changed files with 237 additions and 1 deletions

View File

@@ -23,6 +23,7 @@ export class ProjectEditorNavComponent {
{ id: 'widgets', label: 'builder.widgets' }, { id: 'widgets', label: 'builder.widgets' },
{ id: 'static-pages', label: 'builder.staticPages' }, { id: 'static-pages', label: 'builder.staticPages' },
{ id: 'features', label: 'builder.marketplaceFeatures' }, { id: 'features', label: 'builder.marketplaceFeatures' },
{ id: 'languages', label: 'builder.languagesTab' },
{ id: 'preview', label: 'builder.preview' }, { id: 'preview', label: 'builder.preview' },
]; ];
} }

View File

@@ -4,6 +4,7 @@ import { BootstrapConfig, DEFAULT_CATALOG_CONFIG, DEFAULT_HEADER_CONFIG, DEFAULT
import { ConfigService } from '../../../core/config/config.service'; import { ConfigService } from '../../../core/config/config.service';
import { ProjectEditorIoService } from '../services/project-editor-io.service'; import { ProjectEditorIoService } from '../services/project-editor-io.service';
import { ProjectEditorPreviewService } from '../services/project-editor-preview.service'; import { ProjectEditorPreviewService } from '../services/project-editor-preview.service';
import { LocaleSyncService } from '../services/locale-sync.service';
import { ProjectEditorState } from '../models/project-editor.model'; import { ProjectEditorState } from '../models/project-editor.model';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -11,6 +12,7 @@ export class ProjectEditorFacade {
private readonly configService = inject(ConfigService); private readonly configService = inject(ConfigService);
private readonly ioService = inject(ProjectEditorIoService); private readonly ioService = inject(ProjectEditorIoService);
private readonly previewService = inject(ProjectEditorPreviewService); private readonly previewService = inject(ProjectEditorPreviewService);
private readonly localeSync = inject(LocaleSyncService);
private readonly state = signal<ProjectEditorState>({ private readonly state = signal<ProjectEditorState>({
bootstrap: null, bootstrap: null,
@@ -70,6 +72,22 @@ export class ProjectEditorFacade {
this.state.update(current => ({ ...current, activeSection })); this.state.update(current => ({ ...current, activeSection }));
} }
addLocale(locale: string): void {
this.updateBootstrap(current => this.localeSync.addLocale(current, locale));
}
removeLocale(locale: string): void {
this.updateBootstrap(current => this.localeSync.removeLocale(current, locale));
}
setDefaultLocale(locale: string): void {
this.updateBootstrap(current => ({
...current,
tenant: { ...current.tenant, defaultLocale: locale },
localization: { ...current.localization, defaultLocale: locale },
}));
}
private normalize(config: BootstrapConfig): BootstrapConfig { private normalize(config: BootstrapConfig): BootstrapConfig {
return { return {
...config, ...config,

View File

@@ -10,6 +10,7 @@ export type ProjectEditorSectionId =
| 'widgets' | 'widgets'
| 'static-pages' | 'static-pages'
| 'features' | 'features'
| 'languages'
| 'preview'; | 'preview';
export interface ProjectEditorState { export interface ProjectEditorState {

View File

@@ -23,6 +23,7 @@
@case ('widgets') { <app-project-editor-widgets-section /> } @case ('widgets') { <app-project-editor-widgets-section /> }
@case ('static-pages') { <app-static-pages-editor /> } @case ('static-pages') { <app-static-pages-editor /> }
@case ('features') { <app-project-editor-features-section /> } @case ('features') { <app-project-editor-features-section /> }
@case ('languages') { <app-project-editor-languages-section /> }
@case ('preview') { <app-project-editor-preview-section /> } @case ('preview') { <app-project-editor-preview-section /> }
} }
</section> </section>

View File

@@ -12,13 +12,14 @@ import { ProjectEditorFooterSectionComponent } from '../sections/footer-section.
import { ProjectEditorHomepageSectionComponent } from '../sections/homepage-section.component'; import { ProjectEditorHomepageSectionComponent } from '../sections/homepage-section.component';
import { ProjectEditorWidgetsSectionComponent } from '../sections/widgets-section.component'; import { ProjectEditorWidgetsSectionComponent } from '../sections/widgets-section.component';
import { ProjectEditorFeaturesSectionComponent } from '../sections/features-section.component'; import { ProjectEditorFeaturesSectionComponent } from '../sections/features-section.component';
import { ProjectEditorLanguagesSectionComponent } from '../sections/languages-section.component';
import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component'; import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component';
import { TranslatePipe } from '../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../i18n/translate.pipe';
import { StaticPagesEditorComponent } from '../../content-management/components/static-pages-editor.component'; import { StaticPagesEditorComponent } from '../../content-management/components/static-pages-editor.component';
import { ProjectEditorSectionId } from '../models/project-editor.model'; import { ProjectEditorSectionId } from '../models/project-editor.model';
const KNOWN_SECTIONS: ProjectEditorSectionId[] = [ const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'preview' 'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'languages', 'preview'
]; ];
@Component({ @Component({
@@ -36,6 +37,7 @@ const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
ProjectEditorWidgetsSectionComponent, ProjectEditorWidgetsSectionComponent,
StaticPagesEditorComponent, StaticPagesEditorComponent,
ProjectEditorFeaturesSectionComponent, ProjectEditorFeaturesSectionComponent,
ProjectEditorLanguagesSectionComponent,
ProjectEditorPreviewSectionComponent, ProjectEditorPreviewSectionComponent,
], ],
templateUrl: './project-editor-page.component.html', templateUrl: './project-editor-page.component.html',

View File

@@ -0,0 +1,33 @@
<section class="editor-section-card">
<div class="editor-actions">
<h2>{{ 'builder.languagesTab' | translate }}</h2>
</div>
<div class="editor-actions">
<input
type="text"
[ngModel]="newLocale()"
(ngModelChange)="updateNewLocale($event)"
placeholder="de"
/>
<button type="button" (click)="addLocale()">{{ 'builder.addLanguage' | translate }}</button>
</div>
<div class="stack-list">
@for (code of locales(); track code) {
<article class="sub-card">
<div class="editor-actions">
<h3>{{ code }}</h3>
<div class="editor-actions">
@if (code === defaultLocale()) {
<span>{{ 'builder.defaultLanguageLabel' | translate }}</span>
} @else {
<button type="button" class="secondary" (click)="setDefault(code)">{{ 'builder.setDefaultLanguage' | translate }}</button>
<button type="button" class="secondary" (click)="removeLocale(code)">{{ 'builder.removeLanguage' | translate }}</button>
}
</div>
</div>
</article>
}
</div>
</section>

View File

@@ -0,0 +1,41 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe';
@Component({
selector: 'app-project-editor-languages-section',
standalone: true,
imports: [FormsModule, TranslatePipe],
templateUrl: './languages-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorLanguagesSectionComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly bootstrap = this.facade.bootstrap;
readonly locales = computed(() => this.bootstrap()?.localization.supportedLocales ?? []);
readonly defaultLocale = computed(() => this.bootstrap()?.localization.defaultLocale ?? '');
readonly newLocale = signal('');
updateNewLocale(value: string): void {
this.newLocale.set(value);
}
addLocale(): void {
const code = this.newLocale().trim();
if (!code) {
return;
}
this.facade.addLocale(code);
this.newLocale.set('');
}
removeLocale(code: string): void {
this.facade.removeLocale(code);
}
setDefault(code: string): void {
this.facade.setDefaultLocale(code);
}
}

View File

@@ -0,0 +1,119 @@
import { Injectable } from '@angular/core';
import { BootstrapConfig, FooterNavigationGroupConfig, NavigationItemConfig, NavigationLocalizedText } from '../../../shared/models/config';
type SyncMode = 'add' | 'remove';
@Injectable({ providedIn: 'root' })
export class LocaleSyncService {
addLocale(bootstrap: BootstrapConfig, locale: string): BootstrapConfig {
return this.sync(bootstrap, locale, 'add');
}
removeLocale(bootstrap: BootstrapConfig, locale: string): BootstrapConfig {
if (locale === bootstrap.localization.defaultLocale) {
return bootstrap;
}
return this.sync(bootstrap, locale, 'remove');
}
private sync(bootstrap: BootstrapConfig, rawLocale: string, mode: SyncMode): BootstrapConfig {
const locale = rawLocale.trim().toLowerCase();
if (!locale) {
return bootstrap;
}
const alreadyPresent = bootstrap.localization.supportedLocales.includes(locale);
if (mode === 'add' && alreadyPresent) {
return bootstrap;
}
if (mode === 'remove' && !alreadyPresent) {
return bootstrap;
}
const supportedLocales = mode === 'add'
? [...bootstrap.localization.supportedLocales, locale]
: bootstrap.localization.supportedLocales.filter(existing => existing !== locale);
return {
...bootstrap,
tenant: { ...bootstrap.tenant, supportedLocales },
localization: { ...bootstrap.localization, supportedLocales },
staticPages: this.syncStaticPages(bootstrap.staticPages, locale, mode),
navigation: {
...bootstrap.navigation,
header: this.syncNavItems(bootstrap.navigation.header, locale, mode),
footer: this.syncFooterNav(bootstrap.navigation.footer, locale, mode),
sidebar: bootstrap.navigation.sidebar ? this.syncNavItems(bootstrap.navigation.sidebar, locale, mode) : bootstrap.navigation.sidebar,
},
};
}
private syncStaticPages(staticPages: BootstrapConfig['staticPages'], locale: string, mode: SyncMode): BootstrapConfig['staticPages'] {
if (!staticPages || Array.isArray(staticPages)) {
return staticPages;
}
return Object.fromEntries(
Object.entries(staticPages).map(([id, page]) => [id, {
...page,
translations: this.syncRecord(page.translations, locale, mode, () => ({ title: '', html: '' })),
}])
);
}
private syncNavItems(items: NavigationItemConfig[], locale: string, mode: SyncMode): NavigationItemConfig[] {
return items.map(item => ({
...item,
label: this.syncLabel(item.label, locale, mode),
children: item.children ? this.syncNavItems(item.children, locale, mode) : item.children,
}));
}
private syncFooterNav(
footer: NavigationItemConfig[] | FooterNavigationGroupConfig[],
locale: string,
mode: SyncMode
): NavigationItemConfig[] | FooterNavigationGroupConfig[] {
if (footer.length === 0) {
return footer;
}
if ('items' in footer[0]) {
return (footer as FooterNavigationGroupConfig[]).map(group => ({
...group,
groupTitle: this.syncLabel(group.groupTitle, locale, mode),
items: group.items.map(item => ({ ...item, label: this.syncLabel(item.label, locale, mode) })),
}));
}
return this.syncNavItems(footer as NavigationItemConfig[], locale, mode);
}
private syncLabel(
label: string | NavigationLocalizedText | undefined,
locale: string,
mode: SyncMode
): string | NavigationLocalizedText | undefined {
if (label === undefined || typeof label === 'string') {
return label;
}
return this.syncRecord(label, locale, mode, () => Object.values(label)[0] ?? '');
}
private syncRecord<T>(
record: Record<string, T> | undefined,
locale: string,
mode: SyncMode,
createEmpty: () => T
): Record<string, T> {
const next: Record<string, T> = { ...(record ?? {}) };
if (mode === 'add') {
if (!(locale in next)) {
next[locale] = createEmpty();
}
} else {
delete next[locale];
}
return next;
}
}

View File

@@ -485,6 +485,11 @@ export const en: Translations = {
searchSuggestions: 'Search Suggestions', searchSuggestions: 'Search Suggestions',
searchHistory: 'Search History', searchHistory: 'Search History',
livePreview: 'Live Preview', livePreview: 'Live Preview',
languagesTab: 'Languages',
addLanguage: 'Add Language',
removeLanguage: 'Remove',
setDefaultLanguage: 'Set as Default',
defaultLanguageLabel: 'Default',
exportBootstrap: 'Export Bootstrap', exportBootstrap: 'Export Bootstrap',
importBootstrap: 'Import Bootstrap', importBootstrap: 'Import Bootstrap',
exportedBootstrap: 'Exported Bootstrap', exportedBootstrap: 'Exported Bootstrap',

View File

@@ -485,6 +485,11 @@ export const hy: Translations = {
searchSuggestions: 'Որոնման առաջարկներ', searchSuggestions: 'Որոնման առաջարկներ',
searchHistory: 'Որոնման պատմություն', searchHistory: 'Որոնման պատմություն',
livePreview: 'Կենդանի preview', livePreview: 'Կենդանի preview',
languagesTab: 'Լեզուներ',
addLanguage: 'Ավելացնել լեզու',
removeLanguage: 'Հեռացնել',
setDefaultLanguage: 'Դարձնել լռելյայն',
defaultLanguageLabel: 'Լռելյայն',
exportBootstrap: 'Արտահանել bootstrap', exportBootstrap: 'Արտահանել bootstrap',
importBootstrap: 'Ներմուծել bootstrap', importBootstrap: 'Ներմուծել bootstrap',
exportedBootstrap: 'Արտահանված bootstrap', exportedBootstrap: 'Արտահանված bootstrap',

View File

@@ -485,6 +485,11 @@ export const ru: Translations = {
searchSuggestions: 'Поисковые подсказки', searchSuggestions: 'Поисковые подсказки',
searchHistory: 'История поиска', searchHistory: 'История поиска',
livePreview: 'Живое превью', livePreview: 'Живое превью',
languagesTab: 'Языки',
addLanguage: 'Добавить язык',
removeLanguage: 'Удалить',
setDefaultLanguage: 'Сделать языком по умолчанию',
defaultLanguageLabel: 'По умолчанию',
exportBootstrap: 'Экспорт bootstrap', exportBootstrap: 'Экспорт bootstrap',
importBootstrap: 'Импорт bootstrap', importBootstrap: 'Импорт bootstrap',
exportedBootstrap: 'Экспортированный bootstrap', exportedBootstrap: 'Экспортированный bootstrap',

View File

@@ -483,6 +483,11 @@ export interface Translations {
searchSuggestions: string; searchSuggestions: string;
searchHistory: string; searchHistory: string;
livePreview: string; livePreview: string;
languagesTab: string;
addLanguage: string;
removeLanguage: string;
setDefaultLanguage: string;
defaultLanguageLabel: string;
exportBootstrap: string; exportBootstrap: string;
importBootstrap: string; importBootstrap: string;
exportedBootstrap: string; exportedBootstrap: string;