diff --git a/src/app/features/project-editor/components/project-editor-nav.component.ts b/src/app/features/project-editor/components/project-editor-nav.component.ts index 5c0c741..0d5e361 100644 --- a/src/app/features/project-editor/components/project-editor-nav.component.ts +++ b/src/app/features/project-editor/components/project-editor-nav.component.ts @@ -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' }, ]; } diff --git a/src/app/features/project-editor/facade/project-editor.facade.ts b/src/app/features/project-editor/facade/project-editor.facade.ts index d1fac23..da51d84 100644 --- a/src/app/features/project-editor/facade/project-editor.facade.ts +++ b/src/app/features/project-editor/facade/project-editor.facade.ts @@ -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({ 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, diff --git a/src/app/features/project-editor/models/project-editor.model.ts b/src/app/features/project-editor/models/project-editor.model.ts index 73f7e9e..9b7956e 100644 --- a/src/app/features/project-editor/models/project-editor.model.ts +++ b/src/app/features/project-editor/models/project-editor.model.ts @@ -10,6 +10,7 @@ export type ProjectEditorSectionId = | 'widgets' | 'static-pages' | 'features' + | 'languages' | 'preview'; export interface ProjectEditorState { diff --git a/src/app/features/project-editor/pages/project-editor-page.component.html b/src/app/features/project-editor/pages/project-editor-page.component.html index 9a6b85d..59d5cd2 100644 --- a/src/app/features/project-editor/pages/project-editor-page.component.html +++ b/src/app/features/project-editor/pages/project-editor-page.component.html @@ -23,6 +23,7 @@ @case ('widgets') { } @case ('static-pages') { } @case ('features') { } + @case ('languages') { } @case ('preview') { } } diff --git a/src/app/features/project-editor/pages/project-editor-page.component.ts b/src/app/features/project-editor/pages/project-editor-page.component.ts index 87d5dee..3773013 100644 --- a/src/app/features/project-editor/pages/project-editor-page.component.ts +++ b/src/app/features/project-editor/pages/project-editor-page.component.ts @@ -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', diff --git a/src/app/features/project-editor/sections/languages-section.component.html b/src/app/features/project-editor/sections/languages-section.component.html new file mode 100644 index 0000000..a31b5a0 --- /dev/null +++ b/src/app/features/project-editor/sections/languages-section.component.html @@ -0,0 +1,33 @@ +
+
+

{{ 'builder.languagesTab' | translate }}

+
+ +
+ + +
+ +
+ @for (code of locales(); track code) { +
+
+

{{ code }}

+
+ @if (code === defaultLocale()) { + {{ 'builder.defaultLanguageLabel' | translate }} + } @else { + + + } +
+
+
+ } +
+
diff --git a/src/app/features/project-editor/sections/languages-section.component.ts b/src/app/features/project-editor/sections/languages-section.component.ts new file mode 100644 index 0000000..276d4b7 --- /dev/null +++ b/src/app/features/project-editor/sections/languages-section.component.ts @@ -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); + } +} diff --git a/src/app/features/project-editor/services/locale-sync.service.ts b/src/app/features/project-editor/services/locale-sync.service.ts new file mode 100644 index 0000000..5c0b63d --- /dev/null +++ b/src/app/features/project-editor/services/locale-sync.service.ts @@ -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( + record: Record | undefined, + locale: string, + mode: SyncMode, + createEmpty: () => T + ): Record { + const next: Record = { ...(record ?? {}) }; + if (mode === 'add') { + if (!(locale in next)) { + next[locale] = createEmpty(); + } + } else { + delete next[locale]; + } + return next; + } +} diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 1ace371..175eed7 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -485,6 +485,11 @@ export const en: Translations = { searchSuggestions: 'Search Suggestions', searchHistory: 'Search History', livePreview: 'Live Preview', + languagesTab: 'Languages', + addLanguage: 'Add Language', + removeLanguage: 'Remove', + setDefaultLanguage: 'Set as Default', + defaultLanguageLabel: 'Default', exportBootstrap: 'Export Bootstrap', importBootstrap: 'Import Bootstrap', exportedBootstrap: 'Exported Bootstrap', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index e16b485..6e2dc32 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -485,6 +485,11 @@ export const hy: Translations = { searchSuggestions: 'Որոնման առաջարկներ', searchHistory: 'Որոնման պատմություն', livePreview: 'Կենդանի preview', + languagesTab: 'Լեզուներ', + addLanguage: 'Ավելացնել լեզու', + removeLanguage: 'Հեռացնել', + setDefaultLanguage: 'Դարձնել լռելյայն', + defaultLanguageLabel: 'Լռելյայն', exportBootstrap: 'Արտահանել bootstrap', importBootstrap: 'Ներմուծել bootstrap', exportedBootstrap: 'Արտահանված bootstrap', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index f8262c2..bea94ed 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -485,6 +485,11 @@ export const ru: Translations = { searchSuggestions: 'Поисковые подсказки', searchHistory: 'История поиска', livePreview: 'Живое превью', + languagesTab: 'Языки', + addLanguage: 'Добавить язык', + removeLanguage: 'Удалить', + setDefaultLanguage: 'Сделать языком по умолчанию', + defaultLanguageLabel: 'По умолчанию', exportBootstrap: 'Экспорт bootstrap', importBootstrap: 'Импорт bootstrap', exportedBootstrap: 'Экспортированный bootstrap', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index d7fa0cc..742dab3 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -483,6 +483,11 @@ export interface Translations { searchSuggestions: string; searchHistory: string; livePreview: string; + languagesTab: string; + addLanguage: string; + removeLanguage: string; + setDefaultLanguage: string; + defaultLanguageLabel: string; exportBootstrap: string; importBootstrap: string; exportedBootstrap: string;