feat(project-editor): add Languages tab with generic locale sync
This commit is contained in:
@@ -23,6 +23,7 @@ export class ProjectEditorNavComponent {
|
||||
{ id: 'widgets', label: 'builder.widgets' },
|
||||
{ id: 'static-pages', label: 'builder.staticPages' },
|
||||
{ id: 'features', label: 'builder.marketplaceFeatures' },
|
||||
{ id: 'languages', label: 'builder.languagesTab' },
|
||||
{ id: 'preview', label: 'builder.preview' },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BootstrapConfig, DEFAULT_CATALOG_CONFIG, DEFAULT_HEADER_CONFIG, DEFAULT
|
||||
import { ConfigService } from '../../../core/config/config.service';
|
||||
import { ProjectEditorIoService } from '../services/project-editor-io.service';
|
||||
import { ProjectEditorPreviewService } from '../services/project-editor-preview.service';
|
||||
import { LocaleSyncService } from '../services/locale-sync.service';
|
||||
import { ProjectEditorState } from '../models/project-editor.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -11,6 +12,7 @@ export class ProjectEditorFacade {
|
||||
private readonly configService = inject(ConfigService);
|
||||
private readonly ioService = inject(ProjectEditorIoService);
|
||||
private readonly previewService = inject(ProjectEditorPreviewService);
|
||||
private readonly localeSync = inject(LocaleSyncService);
|
||||
|
||||
private readonly state = signal<ProjectEditorState>({
|
||||
bootstrap: null,
|
||||
@@ -70,6 +72,22 @@ export class ProjectEditorFacade {
|
||||
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 {
|
||||
return {
|
||||
...config,
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ProjectEditorSectionId =
|
||||
| 'widgets'
|
||||
| 'static-pages'
|
||||
| 'features'
|
||||
| 'languages'
|
||||
| 'preview';
|
||||
|
||||
export interface ProjectEditorState {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
@case ('widgets') { <app-project-editor-widgets-section /> }
|
||||
@case ('static-pages') { <app-static-pages-editor /> }
|
||||
@case ('features') { <app-project-editor-features-section /> }
|
||||
@case ('languages') { <app-project-editor-languages-section /> }
|
||||
@case ('preview') { <app-project-editor-preview-section /> }
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -12,13 +12,14 @@ import { ProjectEditorFooterSectionComponent } from '../sections/footer-section.
|
||||
import { ProjectEditorHomepageSectionComponent } from '../sections/homepage-section.component';
|
||||
import { ProjectEditorWidgetsSectionComponent } from '../sections/widgets-section.component';
|
||||
import { ProjectEditorFeaturesSectionComponent } from '../sections/features-section.component';
|
||||
import { ProjectEditorLanguagesSectionComponent } from '../sections/languages-section.component';
|
||||
import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component';
|
||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||
import { StaticPagesEditorComponent } from '../../content-management/components/static-pages-editor.component';
|
||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||
|
||||
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({
|
||||
@@ -36,6 +37,7 @@ const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
|
||||
ProjectEditorWidgetsSectionComponent,
|
||||
StaticPagesEditorComponent,
|
||||
ProjectEditorFeaturesSectionComponent,
|
||||
ProjectEditorLanguagesSectionComponent,
|
||||
ProjectEditorPreviewSectionComponent,
|
||||
],
|
||||
templateUrl: './project-editor-page.component.html',
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
119
src/app/features/project-editor/services/locale-sync.service.ts
Normal file
119
src/app/features/project-editor/services/locale-sync.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user