diff --git a/src/app/features/project-editor/components/live-preview/builder-live-preview.component.html b/src/app/features/project-editor/components/live-preview/builder-live-preview.component.html new file mode 100644 index 0000000..acc83e7 --- /dev/null +++ b/src/app/features/project-editor/components/live-preview/builder-live-preview.component.html @@ -0,0 +1,56 @@ +@if (bootstrap(); as bootstrap) { + +} @else { +

{{ 'builder.livePreviewEmpty' | translate }}

+} diff --git a/src/app/features/project-editor/components/live-preview/builder-live-preview.component.scss b/src/app/features/project-editor/components/live-preview/builder-live-preview.component.scss new file mode 100644 index 0000000..0b4b70d --- /dev/null +++ b/src/app/features/project-editor/components/live-preview/builder-live-preview.component.scss @@ -0,0 +1,160 @@ +.builder-live-preview { + display: flex; + flex-direction: column; + gap: var(--space-xs, 0.25rem); + border: 1px solid var(--border-color, #e4e4e7); + border-radius: var(--radius-lg, 10px); + overflow: hidden; + background: var(--bg-primary, #fff); +} + +.builder-live-preview__header { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + border-bottom: 1px solid var(--border-color, #e4e4e7); + transition: box-shadow var(--transition-fast, 120ms ease); +} + +.builder-live-preview__header--active { + box-shadow: inset 0 0 0 2px var(--primary-color, #2f6e5d); +} + +.builder-live-preview__logo { + width: 24px; + height: 24px; + border-radius: var(--radius-sm, 4px); + object-fit: contain; + transition: outline-color var(--transition-fast, 120ms ease); + outline: 2px solid transparent; + outline-offset: 2px; +} + +.builder-live-preview__logo--placeholder { + background: var(--bg-secondary, #f4f4f5); +} + +.builder-live-preview__logo--active { + outline-color: var(--primary-color, #2f6e5d); +} + +.builder-live-preview__title { + font-size: var(--font-size-sm, 0.8125rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #1f322d); + border-radius: var(--radius-sm, 4px); + transition: box-shadow var(--transition-fast, 120ms ease); +} + +.builder-live-preview__title--active { + box-shadow: inset 0 0 0 2px var(--primary-color, #2f6e5d); + padding: 0 var(--space-xs, 0.25rem); +} + +.builder-live-preview__hero { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-sm, 0.5rem); + padding: var(--space-lg, 1.5rem) var(--space-md, 1rem); + transition: box-shadow var(--transition-fast, 120ms ease); +} + +.builder-live-preview__hero--active { + box-shadow: inset 0 0 0 2px var(--primary-color, #2f6e5d); +} + +.builder-live-preview__hero-image { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 32px; + border-radius: var(--radius-md, 6px); + background: var(--bg-primary, #fff); + color: var(--text-secondary, #6b7280); + outline: 2px solid transparent; + outline-offset: 2px; +} + +.builder-live-preview__hero-image--active { + outline-color: var(--primary-color, #2f6e5d); +} + +.builder-live-preview__button { + padding: var(--space-xs, 0.25rem) var(--space-md, 1rem); + border-radius: var(--radius-md, 6px); + color: #fff; + font-size: var(--font-size-xs, 0.75rem); + font-weight: var(--font-weight-semibold, 600); + outline: 2px solid transparent; + outline-offset: 2px; + transition: outline-color var(--transition-fast, 120ms ease); +} + +.builder-live-preview__button--active { + outline-color: var(--primary-color, #2f6e5d); +} + +.builder-live-preview__blocks { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs, 0.25rem); + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + border-radius: var(--radius-md, 6px); + transition: box-shadow var(--transition-fast, 120ms ease); +} + +.builder-live-preview__blocks--active { + box-shadow: inset 0 0 0 2px var(--primary-color, #2f6e5d); +} + +.builder-live-preview__block { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 28px; + border-radius: var(--radius-sm, 4px); + background: var(--bg-secondary, #f4f4f5); + color: var(--text-secondary, #6b7280); + outline: 2px solid transparent; + outline-offset: 2px; + transition: outline-color var(--transition-fast, 120ms ease), opacity var(--transition-fast, 120ms ease); +} + +.builder-live-preview__block--hidden { + opacity: 0.35; +} + +.builder-live-preview__block--active { + outline-color: var(--primary-color, #2f6e5d); +} + +.builder-live-preview__footer { + height: 16px; + background: var(--bg-secondary, #f4f4f5); +} + +.builder-live-preview__empty { + margin: 0; + padding: var(--space-md, 1rem); + font-size: var(--font-size-sm, 0.8125rem); + color: var(--text-secondary, #6b7280); + text-align: center; +} + +@media (prefers-reduced-motion: reduce) { + .builder-live-preview__header, + .builder-live-preview__logo, + .builder-live-preview__title, + .builder-live-preview__hero, + .builder-live-preview__hero-image, + .builder-live-preview__button, + .builder-live-preview__blocks, + .builder-live-preview__block { + transition: none; + } +} diff --git a/src/app/features/project-editor/components/live-preview/builder-live-preview.component.ts b/src/app/features/project-editor/components/live-preview/builder-live-preview.component.ts new file mode 100644 index 0000000..2ce8891 --- /dev/null +++ b/src/app/features/project-editor/components/live-preview/builder-live-preview.component.ts @@ -0,0 +1,57 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ProjectEditorFacade } from '../../facade/project-editor.facade'; +import { PreviewHighlightService } from '../../../../shared/services/preview-highlight.service'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { IconComponent } from '../../../../shared/ui/icon/icon.component'; +import { AppIconName } from '../../../../shared/ui/icon/icon-registry'; + +const BLOCK_ICON_BY_TYPE: Record = { + hero: 'image', + categories: 'layoutGrid', + 'product-collection': 'package', + 'product-carousel': 'images', + 'recently-viewed': 'history', + banner: 'megaphone', + partners: 'verified', + html: 'code', +}; + +/** + * Schematic (not pixel-accurate) representation of the storefront homepage, + * built from the same bootstrap the section editors mutate. It exists so a + * field can highlight "the area it affects" (§ PreviewHighlightService) + * without re-navigating away like ProjectEditorPreviewService.preview() does. + */ +@Component({ + selector: 'app-builder-live-preview', + standalone: true, + imports: [TranslatePipe, IconComponent], + templateUrl: './builder-live-preview.component.html', + styleUrl: './builder-live-preview.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BuilderLivePreviewComponent { + private readonly facade = inject(ProjectEditorFacade); + private readonly highlight = inject(PreviewHighlightService); + + readonly bootstrap = this.facade.bootstrap; + readonly homepagePage = this.facade.homepagePage; + readonly activeHighlight = this.highlight.activeId; + + readonly blocks = computed(() => + [...(this.homepagePage()?.sections ?? [])] + .sort((a, b) => a.order - b.order) + .map(section => ({ + id: section.id, + type: section.type, + strategy: section.layout?.strategy ?? 'stack', + columns: section.layout?.columns ?? 1, + visible: section.visible !== false, + icon: BLOCK_ICON_BY_TYPE[section.type] ?? 'stop', + })) + ); + + isActive(id: string): boolean { + return this.activeHighlight() === id; + } +} 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 0db21a5..63905b5 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 @@ -82,27 +82,36 @@

{{ 'common.loading' | translate }}

} @else { -
- @if (canResetActiveSection()) { -
- {{ 'builder.resetSection' | translate }} -
+
+
+ @if (canResetActiveSection()) { +
+ {{ 'builder.resetSection' | translate }} +
+ } + @switch (activeSection()) { + @case ('general') { } + @case ('branding') { } + @case ('theme') { } + @case ('header') { } + @case ('footer') { } + @case ('homepage') { } + @case ('widgets') { } + @case ('static-pages') { } + @case ('features') { } + @case ('languages') { } + @case ('navigation') { } + @case ('preview') { } + } +
+ @if (showLivePreview()) { + } - @switch (activeSection()) { - @case ('general') { } - @case ('branding') { } - @case ('theme') { } - @case ('header') { } - @case ('footer') { } - @case ('homepage') { } - @case ('widgets') { } - @case ('static-pages') { } - @case ('features') { } - @case ('languages') { } - @case ('navigation') { } - @case ('preview') { } - } -
+ } diff --git a/src/app/features/project-editor/pages/project-editor-page.component.scss b/src/app/features/project-editor/pages/project-editor-page.component.scss index f1d48fd..e4e4ca4 100644 --- a/src/app/features/project-editor/pages/project-editor-page.component.scss +++ b/src/app/features/project-editor/pages/project-editor-page.component.scss @@ -277,6 +277,44 @@ justify-content: flex-end; } +.project-editor-work-area { + display: grid; + gap: 16px; + align-items: start; +} + +.project-editor-work-area--with-preview { + grid-template-columns: minmax(0, 1fr) 280px; +} + +@media (max-width: 960px) { + .project-editor-work-area--with-preview { + grid-template-columns: minmax(0, 1fr); + } +} + +.project-editor-live-preview { + position: sticky; + top: var(--space-md, 1rem); + padding: var(--space-md, 1rem); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg, 10px); + background: var(--bg-primary); +} + +.project-editor-live-preview__title { + margin: 0 0 var(--space-xs, 0.25rem); + font-size: var(--font-size-base, 0.875rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary); +} + +.project-editor-live-preview__hint { + margin: 0 0 var(--space-sm, 0.5rem); + font-size: var(--font-size-xs, 0.75rem); + color: var(--text-secondary); +} + .project-editor-empty { min-height: 240px; display: grid; 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 6bb6dda..878d028 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 @@ -25,6 +25,10 @@ import { StaticPagesEditorComponent } from '../../content-management/components/ import { ProjectEditorSaveBarComponent } from '../components/save-bar/project-editor-save-bar.component'; import { EDITOR_SECTION_BOOTSTRAP_KEYS, ProjectEditorSectionId } from '../models/project-editor.model'; import { BUILDER_GROUPS, SECTION_TO_GROUP } from '../builder/builder-groups.model'; +import { BuilderLivePreviewComponent } from '../components/live-preview/builder-live-preview.component'; + +/** Sections that currently expose highlight-mapped fields (see HighlightSourceDirective usages) - showing the panel elsewhere would just be an inert, unhighlightable diagram. */ +const SECTIONS_WITH_LIVE_PREVIEW: ProjectEditorSectionId[] = ['branding', 'theme', 'homepage']; const KNOWN_SECTIONS: ProjectEditorSectionId[] = [ 'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'languages', 'navigation', 'preview' @@ -68,6 +72,7 @@ const SECTION_LABEL_KEYS: Record = { IconComponent, ButtonComponent, ConfirmDialogComponent, + BuilderLivePreviewComponent, ], templateUrl: './project-editor-page.component.html', styleUrls: ['./project-editor-page.component.scss'], @@ -99,6 +104,8 @@ export class ProjectEditorPageComponent { readonly isModifiedActiveSection = computed(() => this.facade.modifiedSections().has(this.activeSection())); + readonly showLivePreview = computed(() => SECTIONS_WITH_LIVE_PREVIEW.includes(this.activeSection())); + readonly overviewRoute = computed(() => ['/', this.languageService.currentLanguage(), 'edit']); /** Persistent escape hatch back to the admin dashboard - section editors previously had no route to the backoffice at all. */ diff --git a/src/app/features/project-editor/sections/branding-section.component.html b/src/app/features/project-editor/sections/branding-section.component.html index 90932aa..9db47b7 100644 --- a/src/app/features/project-editor/sections/branding-section.component.html +++ b/src/app/features/project-editor/sections/branding-section.component.html @@ -2,7 +2,14 @@
- + @@ -14,7 +21,14 @@ - +
diff --git a/src/app/features/project-editor/sections/branding-section.component.ts b/src/app/features/project-editor/sections/branding-section.component.ts index 24495f3..fe88b2b 100644 --- a/src/app/features/project-editor/sections/branding-section.component.ts +++ b/src/app/features/project-editor/sections/branding-section.component.ts @@ -9,13 +9,14 @@ import { ButtonComponent } from '../../../shared/ui/button/button.component'; import { ImageFieldComponent } from '../../../shared/ui/image-field/image-field.component'; import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component'; import { BrandOverviewComponent } from './brand/brand-overview.component'; +import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive'; type BrandingImageField = 'logoUrl' | 'logoCompactUrl' | 'faviconUrl' | 'socialImageUrl'; @Component({ selector: 'app-project-editor-branding-section', standalone: true, - imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent, ButtonComponent, ImageFieldComponent, SectionCardComponent, BrandOverviewComponent], + imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent, ButtonComponent, ImageFieldComponent, SectionCardComponent, BrandOverviewComponent, HighlightSourceDirective], templateUrl: './branding-section.component.html', styleUrls: ['./section.shared.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/app/features/project-editor/sections/homepage-section.component.html b/src/app/features/project-editor/sections/homepage-section.component.html index 12ddb89..d17db18 100644 --- a/src/app/features/project-editor/sections/homepage-section.component.html +++ b/src/app/features/project-editor/sections/homepage-section.component.html @@ -17,7 +17,7 @@ -
+
@@ -31,20 +31,28 @@

{{ blockDescription(section.type) }}

- - +
diff --git a/src/app/features/project-editor/sections/homepage-section.component.ts b/src/app/features/project-editor/sections/homepage-section.component.ts index 2379eda..07a93db 100644 --- a/src/app/features/project-editor/sections/homepage-section.component.ts +++ b/src/app/features/project-editor/sections/homepage-section.component.ts @@ -13,6 +13,9 @@ import { SectionConfig } from '../../../shared/models/config'; import { IconComponent } from '../../../shared/ui/icon/icon.component'; import { AppIconName } from '../../../shared/ui/icon/icon-registry'; import { ConfirmDialogComponent } from '../../../shared/ui/confirm-dialog/confirm-dialog.component'; +import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component'; +import { VisualLayoutPickerComponent, VisualLayoutOption } from '../../../shared/ui/visual-layout-picker/visual-layout-picker.component'; +import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive'; export interface LayoutStrategyOption { value: 'stack' | 'grid' | 'hero' | 'carousel' | 'split'; @@ -45,7 +48,7 @@ const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry])); @Component({ selector: 'app-project-editor-homepage-section', standalone: true, - imports: [DragDropModule, FormsModule, TranslatePipe, InputComponent, SectionCardComponent, ToggleComponent, EmptyStateComponent, HomepageOverviewComponent, IconComponent, ConfirmDialogComponent], + imports: [DragDropModule, FormsModule, TranslatePipe, InputComponent, SectionCardComponent, ToggleComponent, EmptyStateComponent, HomepageOverviewComponent, IconComponent, ConfirmDialogComponent, FormFieldComponent, VisualLayoutPickerComponent, HighlightSourceDirective], templateUrl: './homepage-section.component.html', styleUrls: ['./section.shared.scss', './homepage-section.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -70,9 +73,21 @@ export class ProjectEditorHomepageSectionComponent { { value: 'split', labelKey: 'builder.layoutStrategySplit', descKey: 'builder.layoutStrategySplitDesc' }, ]; - layoutStrategyDescKey(strategy: string | undefined): string { - return this.layoutStrategyOptions.find(option => option.value === strategy)?.descKey ?? ''; - } + /** Card icons for the visual layout picker - purely decorative shorthand for each strategy's shape. */ + private readonly layoutStrategyIcons: Record = { + stack: 'list', + grid: 'layoutGrid', + hero: 'image', + carousel: 'images', + split: 'table', + }; + + readonly layoutStrategyPickerOptions: VisualLayoutOption[] = this.layoutStrategyOptions.map(option => ({ + value: option.value, + labelKey: option.labelKey, + descKey: option.descKey, + icon: this.layoutStrategyIcons[option.value], + })); blockLabel(type: string): string { const entry = BLOCK_BY_TYPE.get(type); diff --git a/src/app/features/project-editor/sections/theme-section.component.html b/src/app/features/project-editor/sections/theme-section.component.html index 000a1aa..9428427 100644 --- a/src/app/features/project-editor/sections/theme-section.component.html +++ b/src/app/features/project-editor/sections/theme-section.component.html @@ -1,9 +1,23 @@ @if (bootstrap(); as bootstrap) {
- + - + diff --git a/src/app/features/project-editor/sections/theme-section.component.ts b/src/app/features/project-editor/sections/theme-section.component.ts index 5cce9ea..e932399 100644 --- a/src/app/features/project-editor/sections/theme-section.component.ts +++ b/src/app/features/project-editor/sections/theme-section.component.ts @@ -8,6 +8,7 @@ import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.com import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component'; import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component'; import { ColorPickerComponent } from '../../../shared/ui/color-picker/color-picker.component'; +import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive'; export interface ThemeModeOption { value: 'light' | 'dark' | 'system'; @@ -24,7 +25,7 @@ export interface SiteLayoutOption { @Component({ selector: 'app-project-editor-theme-section', standalone: true, - imports: [FormsModule, TranslatePipe, FormFieldComponent, SectionCardComponent, SelectComponent, ColorPickerComponent], + imports: [FormsModule, TranslatePipe, FormFieldComponent, SectionCardComponent, SelectComponent, ColorPickerComponent, HighlightSourceDirective], templateUrl: './theme-section.component.html', styleUrls: ['./section.shared.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 3ab258c..61ac50f 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -868,6 +868,18 @@ export const en: Translations = { homepageEmptyTitle: 'No homepage configured', homepageEmptyDesc: 'Your marketplace does not have a homepage set up yet. Contact support to get one created.', homepageBlocksHint: 'These are the blocks customers see on your homepage, top to bottom. Drag the handle to reorder.', + livePreviewTitle: 'Live preview', + livePreviewHint: 'Hover a field to see what it changes.', + livePreviewEmpty: 'Nothing to preview yet.', + livePreviewButtonLabel: 'Shop now', + usedByLabel: 'Used by:', + usedByHeader: 'Header', + usedByFooter: 'Footer', + usedBySeo: 'Search results', + usedByButtons: 'Buttons', + usedByLinks: 'Links', + usedByBadges: 'Badges', + usedByPageBackground: 'Page background', addBlockHint: 'Add a block to your homepage:', dragToReorder: 'Drag to reorder', blockRemoveConfirm: 'Remove this block from the homepage?', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index c8e52a4..5775e5e 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -868,6 +868,18 @@ export const hy: Translations = { homepageEmptyTitle: 'Գլխավոր էջը կարգավորված չէ', homepageEmptyDesc: 'Ձեր խանութն դեռ չունի կարգավորված գլխավոր էջ։ Ստեղծելու համար դիմեք աջակցության թիմին։', homepageBlocksHint: 'Սրանք են բլոկները, որոնք գնորդները տեսնում են գլխավոր էջում վերևից ներքև։ Քաշեք բռնակից՝ կարգը փոխելու համար։', + livePreviewTitle: 'Կենդանի նախադիտում', + livePreviewHint: 'Անցկացրեք դաշտի վրա՝ տեսնելու համար, թե ինչ է այն փոխում։', + livePreviewEmpty: 'Դեռ ցուցադրելու բան չկա։', + livePreviewButtonLabel: 'Գնումներ', + usedByLabel: 'Օգտագործվում է՝', + usedByHeader: 'Վերնագիր', + usedByFooter: 'Ստորագիր', + usedBySeo: 'Որոնման արդյունքներ', + usedByButtons: 'Կոճակներ', + usedByLinks: 'Հղումներ', + usedByBadges: 'Կրծքանշաններ', + usedByPageBackground: 'Էջի ֆոն', addBlockHint: 'Ավելացնել բլոկ գլխավոր էջում.', dragToReorder: 'Քաշեք՝ կարգը փոխելու համար', blockRemoveConfirm: 'Հեռացնե՞լ այս բլոկը գլխավոր էջից։', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 62418c5..29fbb8c 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -868,6 +868,18 @@ export const ru: Translations = { homepageEmptyTitle: 'Главная страница не настроена', homepageEmptyDesc: 'В вашем магазине пока не настроена главная страница. Обратитесь в поддержку, чтобы её создать.', homepageBlocksHint: 'Это блоки, которые покупатели видят на главной странице сверху вниз. Перетаскивайте за ручку, чтобы изменить порядок.', + livePreviewTitle: 'Живой предпросмотр', + livePreviewHint: 'Наведите курсор на поле, чтобы увидеть, что оно меняет.', + livePreviewEmpty: 'Пока нечего показать.', + livePreviewButtonLabel: 'В магазин', + usedByLabel: 'Используется в:', + usedByHeader: 'Шапка сайта', + usedByFooter: 'Подвал сайта', + usedBySeo: 'Результаты поиска', + usedByButtons: 'Кнопки', + usedByLinks: 'Ссылки', + usedByBadges: 'Значки', + usedByPageBackground: 'Фон страницы', addBlockHint: 'Добавить блок на главную страницу:', dragToReorder: 'Перетащите, чтобы изменить порядок', blockRemoveConfirm: 'Удалить этот блок с главной страницы?', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 714149f..44b4fd3 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -869,6 +869,18 @@ export interface Translations { homepageBlocksHint: string; addBlockHint: string; dragToReorder: string; + livePreviewTitle: string; + livePreviewHint: string; + livePreviewEmpty: string; + livePreviewButtonLabel: string; + usedByLabel: string; + usedByHeader: string; + usedByFooter: string; + usedBySeo: string; + usedByButtons: string; + usedByLinks: string; + usedByBadges: string; + usedByPageBackground: string; blockRemoveConfirm: string; blockHero: string; blockHeroDesc: string; diff --git a/src/app/shared/services/preview-highlight.service.ts b/src/app/shared/services/preview-highlight.service.ts new file mode 100644 index 0000000..9e44712 --- /dev/null +++ b/src/app/shared/services/preview-highlight.service.ts @@ -0,0 +1,17 @@ +import { Injectable, signal } from '@angular/core'; + +/** + * Cross-component hover/focus bridge between an editor field and the schematic + * live preview. A field marks itself with `appHighlightSource="logo"` and the + * preview marks the matching element with `appHighlightTarget="logo"`; this + * service is the shared signal both sides read/write so they don't need a + * direct reference to each other. + */ +@Injectable({ providedIn: 'root' }) +export class PreviewHighlightService { + readonly activeId = signal(null); + + set(id: string | null): void { + this.activeId.set(id); + } +} diff --git a/src/app/shared/ui/form-field/form-field.component.html b/src/app/shared/ui/form-field/form-field.component.html index 04d604c..bd8aa46 100644 --- a/src/app/shared/ui/form-field/form-field.component.html +++ b/src/app/shared/ui/form-field/form-field.component.html @@ -17,4 +17,15 @@ } @else if (hint()) {

{{ hint() }}

} + + @if (usedBy(); as targets) { + @if (targets.length) { +

+ @if (usedByLabel()) { + {{ usedByLabel() }} + } + {{ targets.join(', ') }} +

+ } + }
diff --git a/src/app/shared/ui/form-field/form-field.component.scss b/src/app/shared/ui/form-field/form-field.component.scss index 8b2a3c0..a4693e0 100644 --- a/src/app/shared/ui/form-field/form-field.component.scss +++ b/src/app/shared/ui/form-field/form-field.component.scss @@ -30,3 +30,14 @@ font-size: var(--font-size-sm, 0.8125rem); color: var(--error-color, #c0392b); } + +.app-form-field__used-by { + margin: 0; + font-size: var(--font-size-xs, 0.75rem); + color: var(--text-secondary, #6b7280); +} + +.app-form-field__used-by-label { + font-weight: var(--font-weight-semibold, 600); + margin-right: 0.25rem; +} diff --git a/src/app/shared/ui/form-field/form-field.component.ts b/src/app/shared/ui/form-field/form-field.component.ts index 3603568..1e32f47 100644 --- a/src/app/shared/ui/form-field/form-field.component.ts +++ b/src/app/shared/ui/form-field/form-field.component.ts @@ -24,10 +24,20 @@ export class FormFieldComponent implements FormFieldContext { readonly hint = input(null); readonly error = input(null); readonly required = input(false); + /** Optional "Where is this used?" list, e.g. ['Buttons', 'Links', 'Badges']. Already-translated strings. */ + readonly usedBy = input(null); + readonly usedByLabel = input(null); readonly fieldId = `app-form-field-${++nextFormFieldId}`; protected readonly hintId = `${this.fieldId}-hint`; protected readonly errorId = `${this.fieldId}-error`; + protected readonly usedById = `${this.fieldId}-used-by`; - readonly describedBy = computed(() => this.error() ? this.errorId : (this.hint() ? this.hintId : null)); + readonly describedBy = computed(() => { + const ids = [ + this.error() ? this.errorId : (this.hint() ? this.hintId : null), + this.usedBy()?.length ? this.usedById : null, + ].filter((id): id is string => !!id); + return ids.length ? ids.join(' ') : null; + }); } diff --git a/src/app/shared/ui/highlight-source/highlight-source.directive.ts b/src/app/shared/ui/highlight-source/highlight-source.directive.ts new file mode 100644 index 0000000..4f73e8e --- /dev/null +++ b/src/app/shared/ui/highlight-source/highlight-source.directive.ts @@ -0,0 +1,31 @@ +import { Directive, HostListener, inject, input } from '@angular/core'; +import { PreviewHighlightService } from '../../services/preview-highlight.service'; + +/** + * Attach to any field/control wrapper: `