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: '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' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type ProjectEditorSectionId =
|
|||||||
| 'widgets'
|
| 'widgets'
|
||||||
| 'static-pages'
|
| 'static-pages'
|
||||||
| 'features'
|
| 'features'
|
||||||
|
| 'languages'
|
||||||
| 'preview';
|
| 'preview';
|
||||||
|
|
||||||
export interface ProjectEditorState {
|
export interface ProjectEditorState {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user