feat(builder): redesign marketplace builder experience and navigation
Renamed the experience consistently to Marketplace Builder everywhere visible (page title/subtitle, breadcrumbs, sidebar, dashboard quick action/shortcut copy) - internal ProjectEditor class/selector names kept as-is to avoid regressions. builder.title/subtitle no longer say 'bootstrap-config editing surface' to end users. New business-oriented IA (builder-groups.model.ts): 12 existing ProjectEditorSectionIds regrouped into 8 groups (Marketplace, Branding and Design, Homepage, Content, Languages, Marketplace Features, Navigation and Search, Preview) - every existing section mapped to exactly one group, none dropped, no group points at a page that doesn't exist. New Builder landing page at /edit (was a redirect straight into the General form): readiness percent, recommended next step, one overview card per group (purpose + real complete/in-progress/not-started/unknown status, icon+text never color-alone), a Marketplace Readiness checklist, quick links. All derived from real facade/schema signals (required-field fill state, modifiedSections, staticPages, catalog counts via BackofficeDataService) or explicitly marked unknown (the "preview reviewed" check has no tracking - shown as unknown, never guessed). Section pages (/edit/:section) now share one consistent header: breadcrumb (Builder > Group > Section), draft/published + modified badges, and a contextual help panel (what is this / where visible / what happens if you skip it) sourced per group. Sidebar nav rewritten as grouped, icon-led sections with per-section and per-group status indicators; layout converted from a top pill-tab bar to a responsive sidebar (desktop 280px, tablet 72px icon rail, mobile drawer with Escape-to-close), matching the Sprint 1 admin shell pattern. Section form components themselves untouched. Routes: 'edit' is now its own landing route instead of redirecting to 'edit/general'; /builder and /project-editor redirect to 'edit'. Sprint 1/2 destinations that pointed at edit/general now point at the new edit landing page. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
122
src/app/features/project-editor/builder/builder-groups.model.ts
Normal file
122
src/app/features/project-editor/builder/builder-groups.model.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||
|
||||
export type BuilderGroupId =
|
||||
| 'marketplace'
|
||||
| 'branding'
|
||||
| 'homepage'
|
||||
| 'content'
|
||||
| 'languages'
|
||||
| 'features'
|
||||
| 'navigation'
|
||||
| 'preview';
|
||||
|
||||
export interface BuilderGroup {
|
||||
id: BuilderGroupId;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
descriptionKey: string;
|
||||
purposeKey: string;
|
||||
whereKey: string;
|
||||
skipKey: string;
|
||||
sections: ProjectEditorSectionId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The Builder's business-oriented information architecture (Sprint 3). Maps
|
||||
* every existing ProjectEditorSectionId to exactly one group - no section is
|
||||
* dropped, no group points at a section that doesn't exist yet. `static-pages`
|
||||
* routes here as "Content" (it's the same StaticPagesEditorComponent already
|
||||
* wired at that section id, just relabeled for a non-technical audience).
|
||||
*/
|
||||
export const BUILDER_GROUPS: BuilderGroup[] = [
|
||||
{
|
||||
id: 'marketplace',
|
||||
icon: 'pi-shop',
|
||||
labelKey: 'builder.groupMarketplaceLabel',
|
||||
descriptionKey: 'builder.groupMarketplaceDescription',
|
||||
purposeKey: 'builder.groupMarketplacePurpose',
|
||||
whereKey: 'builder.groupMarketplaceWhere',
|
||||
skipKey: 'builder.groupMarketplaceSkip',
|
||||
sections: ['general'],
|
||||
},
|
||||
{
|
||||
id: 'branding',
|
||||
icon: 'pi-palette',
|
||||
labelKey: 'builder.groupBrandingLabel',
|
||||
descriptionKey: 'builder.groupBrandingDescription',
|
||||
purposeKey: 'builder.groupBrandingPurpose',
|
||||
whereKey: 'builder.groupBrandingWhere',
|
||||
skipKey: 'builder.groupBrandingSkip',
|
||||
sections: ['branding', 'theme', 'header', 'footer'],
|
||||
},
|
||||
{
|
||||
id: 'homepage',
|
||||
icon: 'pi-home',
|
||||
labelKey: 'builder.groupHomepageLabel',
|
||||
descriptionKey: 'builder.groupHomepageDescription',
|
||||
purposeKey: 'builder.groupHomepagePurpose',
|
||||
whereKey: 'builder.groupHomepageWhere',
|
||||
skipKey: 'builder.groupHomepageSkip',
|
||||
sections: ['homepage', 'widgets'],
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
icon: 'pi-file-edit',
|
||||
labelKey: 'builder.groupContentLabel',
|
||||
descriptionKey: 'builder.groupContentDescription',
|
||||
purposeKey: 'builder.groupContentPurpose',
|
||||
whereKey: 'builder.groupContentWhere',
|
||||
skipKey: 'builder.groupContentSkip',
|
||||
sections: ['static-pages'],
|
||||
},
|
||||
{
|
||||
id: 'languages',
|
||||
icon: 'pi-globe',
|
||||
labelKey: 'builder.groupLanguagesLabel',
|
||||
descriptionKey: 'builder.groupLanguagesDescription',
|
||||
purposeKey: 'builder.groupLanguagesPurpose',
|
||||
whereKey: 'builder.groupLanguagesWhere',
|
||||
skipKey: 'builder.groupLanguagesSkip',
|
||||
sections: ['languages'],
|
||||
},
|
||||
{
|
||||
id: 'features',
|
||||
icon: 'pi-sliders-h',
|
||||
labelKey: 'builder.groupFeaturesLabel',
|
||||
descriptionKey: 'builder.groupFeaturesDescription',
|
||||
purposeKey: 'builder.groupFeaturesPurpose',
|
||||
whereKey: 'builder.groupFeaturesWhere',
|
||||
skipKey: 'builder.groupFeaturesSkip',
|
||||
sections: ['features'],
|
||||
},
|
||||
{
|
||||
id: 'navigation',
|
||||
icon: 'pi-compass',
|
||||
labelKey: 'builder.groupNavigationLabel',
|
||||
descriptionKey: 'builder.groupNavigationDescription',
|
||||
purposeKey: 'builder.groupNavigationPurpose',
|
||||
whereKey: 'builder.groupNavigationWhere',
|
||||
skipKey: 'builder.groupNavigationSkip',
|
||||
sections: ['navigation'],
|
||||
},
|
||||
{
|
||||
id: 'preview',
|
||||
icon: 'pi-desktop',
|
||||
labelKey: 'builder.groupPreviewLabel',
|
||||
descriptionKey: 'builder.groupPreviewDescription',
|
||||
purposeKey: 'builder.groupPreviewPurpose',
|
||||
whereKey: 'builder.groupPreviewWhere',
|
||||
skipKey: 'builder.groupPreviewSkip',
|
||||
sections: ['preview'],
|
||||
},
|
||||
];
|
||||
|
||||
export const SECTION_TO_GROUP: Record<ProjectEditorSectionId, BuilderGroupId> = BUILDER_GROUPS.reduce(
|
||||
(map, group) => {
|
||||
for (const section of group.sections) {
|
||||
map[section] = group.id;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
{} as Record<ProjectEditorSectionId, BuilderGroupId>,
|
||||
);
|
||||
@@ -1,10 +1,32 @@
|
||||
<nav class="editor-nav" [attr.aria-label]="'builder.title' | translate">
|
||||
@for (section of sections; track section.id) {
|
||||
<a
|
||||
[routerLink]="['/edit', section.id] | langRoute"
|
||||
routerLinkActive="active"
|
||||
[ariaCurrentWhenActive]="'page'"
|
||||
class="editor-nav-link"
|
||||
>{{ section.label | translate }}@if (issueCount(section.id); as count) {<span class="editor-nav-badge" [attr.aria-label]="count + ' ' + ('builder.title' | translate)">{{ count }}</span>} @else if (isModified(section.id)) {<span class="editor-nav-dot" [attr.aria-label]="'builder.unsavedChanges' | translate"></span>}</a>
|
||||
<nav class="editor-nav" [attr.aria-label]="'builder.appName' | translate">
|
||||
@for (item of groups(); track item.group.id) {
|
||||
<div class="editor-nav-group">
|
||||
<div class="editor-nav-group__header">
|
||||
<span class="pi {{ item.group.icon }} editor-nav-group__icon" aria-hidden="true"></span>
|
||||
<span class="editor-nav-group__label">{{ item.group.labelKey | translate }}</span>
|
||||
<span class="pi {{ item.statusIcon }} editor-nav-group__status editor-nav-group__status--{{ item.status }}" [attr.aria-label]="item.statusLabelKey | translate"></span>
|
||||
</div>
|
||||
<ul class="editor-nav-group__list">
|
||||
@for (section of item.sections; track section.id) {
|
||||
<li>
|
||||
<a
|
||||
[routerLink]="['/edit', section.id] | langRoute"
|
||||
routerLinkActive="active"
|
||||
[ariaCurrentWhenActive]="'page'"
|
||||
class="editor-nav-link"
|
||||
(click)="onLinkClick()"
|
||||
>
|
||||
<span class="pi {{ section.statusIcon }} editor-nav-link__status editor-nav-link__status--{{ section.status }}" [attr.aria-label]="section.statusLabelKey | translate"></span>
|
||||
<span class="editor-nav-link__label">{{ section.labelKey | translate }}</span>
|
||||
@if (section.issueCount > 0) {
|
||||
<span class="editor-nav-badge" [attr.aria-label]="section.issueCount + ' ' + ('builder.title' | translate)">{{ section.issueCount }}</span>
|
||||
} @else if (section.modified) {
|
||||
<span class="editor-nav-dot" [attr.aria-label]="'builder.unsavedChanges' | translate"></span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</nav>
|
||||
|
||||
@@ -1,79 +1,144 @@
|
||||
.editor-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm, 10px);
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.editor-nav-group__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 0 var(--space-sm);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.editor-nav-group__icon {
|
||||
color: var(--primary-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.editor-nav-group__label {
|
||||
flex: 1 1 auto;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.editor-nav-group__status {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.editor-nav-group__status--complete {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.editor-nav-group__status--in-progress {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.editor-nav-group__status--not-started {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.editor-nav-group__status--unknown {
|
||||
color: var(--text-light);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.editor-nav-group__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.editor-nav-link {
|
||||
min-height: 40px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
border: 1px solid var(--border-color, #d3dad9);
|
||||
background: var(--bg-primary, #fff);
|
||||
color: var(--text-primary, #1e3c38);
|
||||
padding: 0 var(--space-md, 14px);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-height: 38px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
padding: 0 var(--space-sm);
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
line-height: 38px;
|
||||
transition: background-color var(--transition-fast, 120ms ease),
|
||||
border-color var(--transition-fast, 120ms ease),
|
||||
color var(--transition-fast, 120ms ease);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color, #2f6e5d);
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color, #2f6e5d);
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary-color, #2f6e5d);
|
||||
background: var(--primary-color, #2f6e5d);
|
||||
color: var(--bg-primary, #fff);
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-nav-link__status {
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-nav-link__status--complete {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.editor-nav-link__status--in-progress {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.editor-nav-link__status--not-started {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.editor-nav-link__status--unknown {
|
||||
color: var(--text-light);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.editor-nav-link__label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editor-nav-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
margin-left: 6px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--error-color, #ef4444);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--error-color);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.editor-nav-link.active .editor-nav-badge {
|
||||
background: #fff;
|
||||
color: var(--error-color, #ef4444);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-nav-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-left: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--warning-color, #f59e0b);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.editor-nav-link.active .editor-nav-dot {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.editor-nav-link {
|
||||
transition: none;
|
||||
}
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--warning-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,57 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||
import { LangRoutePipe } from '../../../pipes/lang-route.pipe';
|
||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||
import { ProjectEditorSectionId, BuilderSectionStatus } from '../models/project-editor.model';
|
||||
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||||
import { BUILDER_GROUPS, BuilderGroup } from '../builder/builder-groups.model';
|
||||
|
||||
const STATUS_ICON: Record<BuilderSectionStatus, string> = {
|
||||
complete: 'pi-check-circle',
|
||||
'in-progress': 'pi-circle-fill',
|
||||
'not-started': 'pi-circle',
|
||||
unknown: 'pi-question-circle',
|
||||
};
|
||||
|
||||
const STATUS_LABEL_KEY: Record<BuilderSectionStatus, string> = {
|
||||
complete: 'builder.statusComplete',
|
||||
'in-progress': 'builder.statusInProgress',
|
||||
'not-started': 'builder.statusNotStarted',
|
||||
unknown: 'builder.statusUnknown',
|
||||
};
|
||||
|
||||
interface NavSectionItem {
|
||||
id: ProjectEditorSectionId;
|
||||
labelKey: string;
|
||||
status: BuilderSectionStatus;
|
||||
statusIcon: string;
|
||||
statusLabelKey: string;
|
||||
modified: boolean;
|
||||
issueCount: number;
|
||||
}
|
||||
|
||||
interface NavGroupItem {
|
||||
group: BuilderGroup;
|
||||
status: BuilderSectionStatus;
|
||||
statusIcon: string;
|
||||
statusLabelKey: string;
|
||||
sections: NavSectionItem[];
|
||||
}
|
||||
|
||||
const SECTION_LABEL_KEYS: Record<ProjectEditorSectionId, string> = {
|
||||
general: 'builder.general',
|
||||
branding: 'builder.branding',
|
||||
theme: 'builder.theme',
|
||||
header: 'builder.header',
|
||||
footer: 'builder.footer',
|
||||
homepage: 'builder.homepage',
|
||||
widgets: 'builder.widgets',
|
||||
'static-pages': 'builder.staticPages',
|
||||
features: 'builder.marketplaceFeatures',
|
||||
languages: 'builder.languagesTab',
|
||||
navigation: 'builder.navigationTab',
|
||||
preview: 'builder.preview',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-editor-nav',
|
||||
@@ -11,35 +59,40 @@ import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||||
imports: [TranslatePipe, RouterLink, RouterLinkActive, LangRoutePipe],
|
||||
templateUrl: './project-editor-nav.component.html',
|
||||
styleUrls: ['./project-editor-nav.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProjectEditorNavComponent {
|
||||
private readonly facade = inject(ProjectEditorFacade);
|
||||
|
||||
readonly linkClicked = output<void>();
|
||||
|
||||
private readonly issuesBySection = this.facade.issuesBySection;
|
||||
private readonly modifiedSections = this.facade.modifiedSections;
|
||||
private readonly sectionStatus = this.facade.sectionStatus;
|
||||
private readonly groupStatus = this.facade.groupStatus;
|
||||
|
||||
/** Count of blocking issues in a section, for the nav badge. */
|
||||
issueCount(sectionId: ProjectEditorSectionId): number {
|
||||
return this.issuesBySection().get(sectionId) ?? 0;
|
||||
readonly groups = computed<NavGroupItem[]>(() =>
|
||||
BUILDER_GROUPS.map(group => ({
|
||||
group,
|
||||
status: this.groupStatus().get(group.id) ?? 'unknown',
|
||||
statusIcon: STATUS_ICON[this.groupStatus().get(group.id) ?? 'unknown'],
|
||||
statusLabelKey: STATUS_LABEL_KEY[this.groupStatus().get(group.id) ?? 'unknown'],
|
||||
sections: group.sections.map(id => {
|
||||
const status = this.sectionStatus().get(id) ?? 'unknown';
|
||||
return {
|
||||
id,
|
||||
labelKey: SECTION_LABEL_KEYS[id],
|
||||
status,
|
||||
statusIcon: STATUS_ICON[status],
|
||||
statusLabelKey: STATUS_LABEL_KEY[status],
|
||||
modified: this.modifiedSections().has(id),
|
||||
issueCount: this.issuesBySection().get(id) ?? 0,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
);
|
||||
|
||||
onLinkClick(): void {
|
||||
this.linkClicked.emit();
|
||||
}
|
||||
|
||||
/** Whether a section has unsaved modified fields, for the nav dot. */
|
||||
isModified(sectionId: ProjectEditorSectionId): boolean {
|
||||
return this.modifiedSections().has(sectionId);
|
||||
}
|
||||
|
||||
readonly sections: Array<{ id: ProjectEditorSectionId; label: string }> = [
|
||||
{ id: 'general', label: 'builder.general' },
|
||||
{ id: 'branding', label: 'builder.branding' },
|
||||
{ id: 'theme', label: 'builder.theme' },
|
||||
{ id: 'header', label: 'builder.header' },
|
||||
{ id: 'footer', label: 'builder.footer' },
|
||||
{ id: 'homepage', label: 'builder.homepage' },
|
||||
{ id: 'widgets', label: 'builder.widgets' },
|
||||
{ id: 'static-pages', label: 'builder.staticPages' },
|
||||
{ id: 'features', label: 'builder.marketplaceFeatures' },
|
||||
{ id: 'languages', label: 'builder.languagesTab' },
|
||||
{ id: 'navigation', label: 'builder.navigationTab' },
|
||||
{ id: 'preview', label: 'builder.preview' },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,9 +11,24 @@ import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.s
|
||||
import { ProjectValidator, ProjectValidationIssue } from '../services/project-validator.service';
|
||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||
import { EditorSchemaService } from '../schema/editor-schema.service';
|
||||
import { FieldSchema } from '../schema/field-schema.model';
|
||||
import { History, commit as commitHistory, emptyHistory, redo as redoHistory, undo as undoHistory } from '../schema/history.util';
|
||||
import { ProjectEditorDraftStorageService } from '../services/project-editor-draft-storage.service';
|
||||
import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model';
|
||||
import { EDITOR_SECTION_BOOTSTRAP_KEYS, BuilderSectionStatus } from '../models/project-editor.model';
|
||||
import { BUILDER_GROUPS, BuilderGroupId } from '../builder/builder-groups.model';
|
||||
|
||||
function isFilled(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const HISTORY_DEBOUNCE_MS = 300;
|
||||
|
||||
@@ -134,6 +149,81 @@ export class ProjectEditorFacade {
|
||||
}
|
||||
return sections;
|
||||
});
|
||||
/** Required-field completeness per section (Sprint 3 Builder IA) - real schema data only, never guessed. */
|
||||
readonly sectionStatus = computed<Map<ProjectEditorSectionId, BuilderSectionStatus>>(() => {
|
||||
const current = this.bootstrap();
|
||||
const map = new Map<ProjectEditorSectionId, BuilderSectionStatus>();
|
||||
if (!current) {
|
||||
return map;
|
||||
}
|
||||
const modified = this.modifiedSections();
|
||||
const bySection = new Map<ProjectEditorSectionId, FieldSchema[]>();
|
||||
for (const field of this.schema.all()) {
|
||||
const list = bySection.get(field.section) ?? [];
|
||||
list.push(field);
|
||||
bySection.set(field.section, list);
|
||||
}
|
||||
for (const [section, fields] of bySection) {
|
||||
const required = fields.filter(f => f.required);
|
||||
if (required.length === 0) {
|
||||
map.set(section, modified.has(section) ? 'in-progress' : 'unknown');
|
||||
continue;
|
||||
}
|
||||
const filled = required.filter(f => isFilled(this.schema.getByPath(current, f.key))).length;
|
||||
map.set(section, filled === required.length ? 'complete' : filled === 0 ? 'not-started' : 'in-progress');
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/** Rolls sectionStatus up to the Builder's business-oriented groups. */
|
||||
readonly groupStatus = computed<Map<BuilderGroupId, BuilderSectionStatus>>(() => {
|
||||
const sections = this.sectionStatus();
|
||||
const result = new Map<BuilderGroupId, BuilderSectionStatus>();
|
||||
for (const group of BUILDER_GROUPS) {
|
||||
const statuses = group.sections.map(id => sections.get(id) ?? 'unknown');
|
||||
if (statuses.some(s => s === 'in-progress') || (statuses.some(s => s === 'not-started') && statuses.some(s => s === 'complete'))) {
|
||||
result.set(group.id, 'in-progress');
|
||||
} else if (statuses.every(s => s === 'not-started')) {
|
||||
result.set(group.id, 'not-started');
|
||||
} else {
|
||||
const known = statuses.filter(s => s !== 'unknown');
|
||||
result.set(group.id, known.length > 0 && known.every(s => s === 'complete') ? 'complete' : 'unknown');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
/** % of schema-required fields filled across the whole config - the Builder overview's readiness number. Real data only. */
|
||||
readonly readinessPercent = computed<number | null>(() => {
|
||||
const current = this.bootstrap();
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
const required = this.schema.all().filter(f => f.required);
|
||||
if (required.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const filled = required.filter(f => isFilled(this.schema.getByPath(current, f.key))).length;
|
||||
return Math.round((filled / required.length) * 100);
|
||||
});
|
||||
|
||||
/** First section with a required field still missing, in group display order - the overview's "recommended next step". */
|
||||
readonly nextRecommendedSection = computed<ProjectEditorSectionId | null>(() => {
|
||||
if (!this.bootstrap()) {
|
||||
return null;
|
||||
}
|
||||
const statuses = this.sectionStatus();
|
||||
for (const group of BUILDER_GROUPS) {
|
||||
for (const section of group.sections) {
|
||||
const status = statuses.get(section);
|
||||
if (status === 'not-started' || status === 'in-progress') {
|
||||
return section;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
/** Per-field before/after diff vs the originally loaded/published config, for the pre-publish preview. */
|
||||
readonly changeSummary = computed<ChangeSummaryRow[]>(() => {
|
||||
const current = this.bootstrap();
|
||||
|
||||
@@ -39,6 +39,15 @@ export const EDITOR_SECTION_BOOTSTRAP_KEYS: Partial<Record<ProjectEditorSectionI
|
||||
navigation: ['navigation'],
|
||||
};
|
||||
|
||||
/**
|
||||
* complete/in-progress/not-started are derived strictly from the schema's
|
||||
* `required` fields for that section (real data, never guessed). Sections
|
||||
* with no required fields (e.g. theme, preview) report 'unknown' unless the
|
||||
* user has actively touched them this session (modifiedSections), which
|
||||
* counts as real evidence of progress too.
|
||||
*/
|
||||
export type BuilderSectionStatus = 'complete' | 'in-progress' | 'not-started' | 'unknown';
|
||||
|
||||
export interface ProjectEditorWidgetPreset {
|
||||
id: string;
|
||||
type: string;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<main class="builder-overview">
|
||||
<header class="builder-overview__hero">
|
||||
<p class="builder-overview__eyebrow">{{ 'builder.appName' | translate }}</p>
|
||||
<h1 class="builder-overview__title">{{ 'builder.overviewTitle' | translate }}</h1>
|
||||
<p class="builder-overview__subtitle">{{ 'builder.overviewSubtitle' | translate }}</p>
|
||||
|
||||
@if (!bootstrap()) {
|
||||
<div class="builder-overview__readiness" aria-hidden="true">
|
||||
<div class="builder-overview__readiness-bar"><div class="builder-overview__readiness-fill" style="width: 0%"></div></div>
|
||||
<p>{{ 'common.loading' | translate }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="builder-overview__readiness">
|
||||
@if (readinessPercent(); as percent) {
|
||||
<div class="builder-overview__readiness-bar">
|
||||
<div class="builder-overview__readiness-fill" [style.width.%]="percent"></div>
|
||||
</div>
|
||||
<p class="builder-overview__readiness-label">{{ 'builder.overviewReadinessLabel' | translate: { percent } }}</p>
|
||||
<p class="builder-overview__readiness-hint">{{ 'builder.overviewReadinessHint' | translate }}</p>
|
||||
} @else {
|
||||
<p class="builder-overview__readiness-label">{{ 'builder.statusUnknown' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="builder-overview__next-step">
|
||||
<span class="pi pi-arrow-right-circle" aria-hidden="true"></span>
|
||||
<div>
|
||||
<p class="builder-overview__next-step-label">{{ 'builder.overviewNextStepLabel' | translate }}</p>
|
||||
@if (nextStepLabelKey(); as labelKey) {
|
||||
<a [routerLink]="nextStepRoute()" class="builder-overview__next-step-link">{{ labelKey | translate }}</a>
|
||||
} @else {
|
||||
<p class="builder-overview__next-step-done">{{ 'builder.overviewNextStepFallback' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (bootstrap()) {
|
||||
<section class="builder-overview__section" aria-labelledby="builder-overview-sections-title">
|
||||
<h2 id="builder-overview-sections-title">{{ 'builder.overviewSectionsTitle' | translate }}</h2>
|
||||
<div class="builder-overview__cards">
|
||||
@for (group of groups; track group.id) {
|
||||
<a class="builder-overview__card" [routerLink]="groupSectionRoute(group.sections[0])">
|
||||
<div class="builder-overview__card-header">
|
||||
<span class="pi {{ group.icon }} builder-overview__card-icon" aria-hidden="true"></span>
|
||||
<span
|
||||
class="pi {{ statusIcon[groupStatus().get(group.id) ?? 'unknown'] }} builder-overview__card-status builder-overview__card-status--{{ groupStatus().get(group.id) ?? 'unknown' }}"
|
||||
[attr.aria-label]="statusLabelKey[groupStatus().get(group.id) ?? 'unknown'] | translate"
|
||||
></span>
|
||||
</div>
|
||||
<h3 class="builder-overview__card-title">{{ group.labelKey | translate }}</h3>
|
||||
<p class="builder-overview__card-description">{{ group.descriptionKey | translate }}</p>
|
||||
<span class="builder-overview__card-cta">{{ 'builder.overviewOpenSection' | translate }} →</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="builder-overview__section" aria-labelledby="builder-overview-readiness-title">
|
||||
<h2 id="builder-overview-readiness-title">{{ 'builder.overviewReadinessTitle' | translate }}</h2>
|
||||
<ul class="builder-overview__checklist">
|
||||
@for (check of readinessChecklist(); track check.labelKey) {
|
||||
<li class="builder-overview__check builder-overview__check--{{ check.state }}">
|
||||
@switch (check.state) {
|
||||
@case ('true') { <span class="pi pi-check-circle" aria-hidden="true"></span> }
|
||||
@case ('false') { <span class="pi pi-circle" aria-hidden="true"></span> }
|
||||
@case ('loading') { <span class="pi pi-spin pi-spinner" aria-hidden="true"></span> }
|
||||
@default { <span class="pi pi-question-circle" aria-hidden="true"></span> }
|
||||
}
|
||||
<span>{{ check.labelKey | translate }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="builder-overview__section" aria-labelledby="builder-overview-links-title">
|
||||
<h2 id="builder-overview-links-title">{{ 'builder.overviewQuickLinksTitle' | translate }}</h2>
|
||||
<div class="builder-overview__quick-links">
|
||||
<a class="builder-overview__quick-link" [routerLink]="groupSectionRoute('preview')">
|
||||
<span class="pi pi-desktop" aria-hidden="true"></span>{{ 'builder.preview' | translate }}
|
||||
</a>
|
||||
<a class="builder-overview__quick-link" [routerLink]="groupSectionRoute('general')">
|
||||
<span class="pi pi-shop" aria-hidden="true"></span>{{ 'builder.general' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
@@ -0,0 +1,280 @@
|
||||
.builder-overview {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.builder-overview__hero {
|
||||
padding: var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--gradient-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.builder-overview__eyebrow {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.builder-overview__title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.builder-overview__subtitle {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.builder-overview__readiness {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.builder-overview__readiness-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.builder-overview__readiness-fill {
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.builder-overview__readiness-label {
|
||||
margin: var(--space-xs) 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.builder-overview__readiness-hint {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.builder-overview__next-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
|
||||
.pi {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.builder-overview__next-step-label {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.builder-overview__next-step-link {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.builder-overview__next-step-done {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.builder-overview__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.builder-overview__cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.builder-overview__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.builder-overview__card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.builder-overview__card-icon {
|
||||
font-size: 1.375rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.builder-overview__card-status {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.builder-overview__card-status--complete {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.builder-overview__card-status--in-progress {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.builder-overview__card-status--not-started {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.builder-overview__card-status--unknown {
|
||||
color: var(--text-light);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.builder-overview__card-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.builder-overview__card-description {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.builder-overview__card-cta {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.builder-overview__checklist {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.builder-overview__check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.builder-overview__check--true .pi {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.builder-overview__check--false .pi {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.builder-overview__check--unknown .pi,
|
||||
.builder-overview__check--loading .pi {
|
||||
color: var(--text-light);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.builder-overview__quick-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.builder-overview__quick-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.builder-overview__card,
|
||||
.builder-overview__readiness-fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.builder-overview {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.builder-overview__cards,
|
||||
.builder-overview__checklist {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../services/language.service';
|
||||
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||||
import { BackofficeDataService } from '../../../core/backoffice/backoffice-data.service';
|
||||
import { BUILDER_GROUPS } from '../builder/builder-groups.model';
|
||||
import { BuilderSectionStatus } from '../models/project-editor.model';
|
||||
|
||||
const STATUS_ICON: Record<BuilderSectionStatus, string> = {
|
||||
complete: 'pi-check-circle',
|
||||
'in-progress': 'pi-circle-fill',
|
||||
'not-started': 'pi-circle',
|
||||
unknown: 'pi-question-circle',
|
||||
};
|
||||
|
||||
const STATUS_LABEL_KEY: Record<BuilderSectionStatus, string> = {
|
||||
complete: 'builder.statusComplete',
|
||||
'in-progress': 'builder.statusInProgress',
|
||||
'not-started': 'builder.statusNotStarted',
|
||||
unknown: 'builder.statusUnknown',
|
||||
};
|
||||
|
||||
type ReadinessState = 'loading' | 'true' | 'false' | 'unknown';
|
||||
|
||||
interface ReadinessItem {
|
||||
labelKey: string;
|
||||
state: ReadinessState;
|
||||
}
|
||||
|
||||
const SECTION_LABEL_KEYS: Record<string, string> = {
|
||||
general: 'builder.general',
|
||||
branding: 'builder.branding',
|
||||
theme: 'builder.theme',
|
||||
header: 'builder.header',
|
||||
footer: 'builder.footer',
|
||||
homepage: 'builder.homepage',
|
||||
widgets: 'builder.widgets',
|
||||
'static-pages': 'builder.staticPages',
|
||||
features: 'builder.marketplaceFeatures',
|
||||
languages: 'builder.languagesTab',
|
||||
navigation: 'builder.navigationTab',
|
||||
preview: 'builder.preview',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-builder-overview-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink, TranslatePipe],
|
||||
templateUrl: './builder-overview-page.component.html',
|
||||
styleUrl: './builder-overview-page.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BuilderOverviewPageComponent {
|
||||
readonly facade = inject(ProjectEditorFacade);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly backofficeData = inject(BackofficeDataService);
|
||||
|
||||
readonly groups = BUILDER_GROUPS;
|
||||
readonly sectionLabelKeys = SECTION_LABEL_KEYS;
|
||||
|
||||
readonly bootstrap = this.facade.bootstrap;
|
||||
readonly readinessPercent = this.facade.readinessPercent;
|
||||
readonly nextRecommendedSection = this.facade.nextRecommendedSection;
|
||||
readonly groupStatus = this.facade.groupStatus;
|
||||
readonly modifiedSections = this.facade.modifiedSections;
|
||||
readonly dirty = this.facade.dirty;
|
||||
readonly status = this.facade.status;
|
||||
|
||||
private readonly catalogCounts = signal<{ categories: number; products: number } | 'loading' | 'error'>('loading');
|
||||
|
||||
readonly nextStepLabelKey = computed(() => {
|
||||
const section = this.nextRecommendedSection();
|
||||
return section ? SECTION_LABEL_KEYS[section] : null;
|
||||
});
|
||||
|
||||
readonly nextStepRoute = computed(() => {
|
||||
const section = this.nextRecommendedSection();
|
||||
return section ? ['/', this.languageService.currentLanguage(), 'edit', section] : null;
|
||||
});
|
||||
|
||||
readonly readinessChecklist = computed<ReadinessItem[]>(() => {
|
||||
const current = this.bootstrap();
|
||||
const counts = this.catalogCounts();
|
||||
const item = (labelKey: string, state: ReadinessState): ReadinessItem => ({ labelKey, state });
|
||||
|
||||
if (!current) {
|
||||
return [
|
||||
item('dashboard.healthProductsCount', 'loading'),
|
||||
];
|
||||
}
|
||||
|
||||
const staticPages = current.staticPages;
|
||||
const staticPagesCount = staticPages ? (Array.isArray(staticPages) ? staticPages.length : Object.keys(staticPages).length) : 0;
|
||||
|
||||
return [
|
||||
item('builder.readinessLogoUploaded', current.branding?.logoUrl ? 'true' : 'false'),
|
||||
item('builder.readinessThemeConfigured', current.theme?.themeId ? 'true' : 'false'),
|
||||
item('builder.readinessHomepageConfigured', this.facade.homepageWidgets().length > 0 ? 'true' : 'false'),
|
||||
item('builder.readinessLanguagesConfigured', (current.localization?.supportedLocales?.length ?? 0) > 0 ? 'true' : 'false'),
|
||||
item('builder.readinessContentPagesAdded', staticPagesCount > 0 ? 'true' : 'false'),
|
||||
item('builder.readinessCategoriesExist', counts === 'loading' ? 'loading' : counts === 'error' ? 'unknown' : counts.categories > 0 ? 'true' : 'false'),
|
||||
item('builder.readinessProductsExist', counts === 'loading' ? 'loading' : counts === 'error' ? 'unknown' : counts.products > 0 ? 'true' : 'false'),
|
||||
item('builder.readinessPreviewReviewed', 'unknown'),
|
||||
item('builder.readinessPublished', this.facade.lastPublishedAt() !== null ? 'true' : 'false'),
|
||||
];
|
||||
});
|
||||
|
||||
readonly statusIcon = STATUS_ICON;
|
||||
readonly statusLabelKey = STATUS_LABEL_KEY;
|
||||
|
||||
constructor() {
|
||||
this.facade.loadBootstrap();
|
||||
forkJoin({
|
||||
categories: this.backofficeData.loadCategories(),
|
||||
products: this.backofficeData.loadProducts(),
|
||||
}).pipe(take(1)).subscribe({
|
||||
next: ({ categories, products }) => this.catalogCounts.set({ categories: categories.length, products: products.length }),
|
||||
error: () => this.catalogCounts.set('error'),
|
||||
});
|
||||
}
|
||||
|
||||
groupSectionRoute(sectionId: string): string[] {
|
||||
return ['/', this.languageService.currentLanguage(), 'edit', sectionId];
|
||||
}
|
||||
|
||||
formatTimestamp(timestamp: number | null): string | null {
|
||||
return timestamp ? new Date(timestamp).toLocaleString(this.languageService.currentLanguage()) : null;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,104 @@
|
||||
<main class="project-editor-page">
|
||||
<header class="project-editor-hero">
|
||||
<div>
|
||||
<h1>{{ 'builder.title' | translate }}</h1>
|
||||
<p>{{ 'builder.subtitle' | translate }}</p>
|
||||
</div>
|
||||
<app-project-editor-nav />
|
||||
</header>
|
||||
|
||||
@if (!bootstrap()) {
|
||||
<section class="project-editor-empty">
|
||||
<p>{{ 'common.loading' | translate }}</p>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="project-editor-stack">
|
||||
@if (canResetActiveSection()) {
|
||||
<div class="project-editor-section-actions">
|
||||
<button type="button" (click)="resetActiveSection()">{{ 'builder.resetSection' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
@switch (activeSection()) {
|
||||
@case ('general') { <app-project-editor-general-section /> }
|
||||
@case ('branding') { <app-project-editor-branding-section /> }
|
||||
@case ('theme') { <app-project-editor-theme-section /> }
|
||||
@case ('header') { <app-project-editor-header-section /> }
|
||||
@case ('footer') { <app-project-editor-footer-section /> }
|
||||
@case ('homepage') { <app-project-editor-homepage-section /> }
|
||||
@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 ('navigation') { <app-project-editor-navigation-section /> }
|
||||
@case ('preview') { <app-project-editor-preview-section /> }
|
||||
}
|
||||
</section>
|
||||
<app-project-editor-save-bar />
|
||||
<div class="project-editor-layout" [class.project-editor-layout--drawer-open]="mobileDrawerOpen()">
|
||||
@if (mobileDrawerOpen()) {
|
||||
<div class="project-editor-layout__backdrop" (click)="closeMobileDrawer()"></div>
|
||||
}
|
||||
</main>
|
||||
|
||||
<aside class="project-editor-layout__sidebar">
|
||||
<a class="project-editor-layout__brand" [routerLink]="overviewRoute()">
|
||||
<span class="pi pi-arrow-left" aria-hidden="true"></span>
|
||||
{{ 'builder.appName' | translate }}
|
||||
</a>
|
||||
<app-project-editor-nav (linkClicked)="closeMobileDrawer()" />
|
||||
</aside>
|
||||
|
||||
<main class="project-editor-layout__content">
|
||||
<header class="project-editor-layout__header">
|
||||
<button
|
||||
type="button"
|
||||
class="project-editor-layout__menu-toggle"
|
||||
[attr.aria-expanded]="mobileDrawerOpen()"
|
||||
[attr.aria-label]="(mobileDrawerOpen() ? 'builder.closeSidebar' : 'builder.openSidebar') | translate"
|
||||
(click)="toggleMobileDrawer()"
|
||||
>
|
||||
<span class="pi pi-bars" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<div class="project-editor-layout__heading">
|
||||
<nav class="project-editor-layout__breadcrumb" [attr.aria-label]="'builder.breadcrumbRoot' | translate">
|
||||
<ol>
|
||||
<li><a [routerLink]="overviewRoute()">{{ 'builder.breadcrumbRoot' | translate }}</a></li>
|
||||
@if (activeGroup(); as group) {
|
||||
<li><span class="project-editor-layout__breadcrumb-sep" aria-hidden="true">/</span>{{ group.labelKey | translate }}</li>
|
||||
}
|
||||
<li><span class="project-editor-layout__breadcrumb-sep" aria-hidden="true">/</span><span aria-current="page">{{ sectionTitleKey() | translate }}</span></li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="project-editor-layout__title-row">
|
||||
<h1>{{ sectionTitleKey() | translate }}</h1>
|
||||
@if (facade.status() === 'draft') {
|
||||
<span class="project-editor-layout__badge project-editor-layout__badge--draft">{{ 'builder.draftBadge' | translate }}</span>
|
||||
} @else {
|
||||
<span class="project-editor-layout__badge project-editor-layout__badge--published">{{ 'builder.publishedBadge' | translate }}</span>
|
||||
}
|
||||
@if (isModifiedActiveSection()) {
|
||||
<span class="project-editor-layout__badge project-editor-layout__badge--modified">{{ 'builder.modifiedBadge' | translate }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (activeGroup(); as group) {
|
||||
<p class="project-editor-layout__description">{{ group.descriptionKey | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button type="button" class="project-editor-layout__help-toggle" [attr.aria-expanded]="helpOpen()" (click)="toggleHelp()">
|
||||
<span class="pi pi-question-circle" aria-hidden="true"></span>
|
||||
{{ (helpOpen() ? 'builder.helpToggleHide' : 'builder.helpToggleShow') | translate }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (helpOpen() && activeGroup(); as group) {
|
||||
<section class="project-editor-layout__help" aria-label="{{ 'builder.helpToggleShow' | translate }}">
|
||||
<div>
|
||||
<h2>{{ 'builder.helpWhatIsThis' | translate }}</h2>
|
||||
<p>{{ group.purposeKey | translate }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>{{ 'builder.helpWhereVisible' | translate }}</h2>
|
||||
<p>{{ group.whereKey | translate }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>{{ 'builder.helpWhatIfSkip' | translate }}</h2>
|
||||
<p>{{ group.skipKey | translate }}</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (!bootstrap()) {
|
||||
<section class="project-editor-empty">
|
||||
<p>{{ 'common.loading' | translate }}</p>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="project-editor-stack">
|
||||
@if (canResetActiveSection()) {
|
||||
<div class="project-editor-section-actions">
|
||||
<button type="button" (click)="resetActiveSection()">{{ 'builder.resetSection' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
@switch (activeSection()) {
|
||||
@case ('general') { <app-project-editor-general-section /> }
|
||||
@case ('branding') { <app-project-editor-branding-section /> }
|
||||
@case ('theme') { <app-project-editor-theme-section /> }
|
||||
@case ('header') { <app-project-editor-header-section /> }
|
||||
@case ('footer') { <app-project-editor-footer-section /> }
|
||||
@case ('homepage') { <app-project-editor-homepage-section /> }
|
||||
@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 ('navigation') { <app-project-editor-navigation-section /> }
|
||||
@case ('preview') { <app-project-editor-preview-section /> }
|
||||
}
|
||||
</section>
|
||||
<app-project-editor-save-bar />
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,227 @@
|
||||
.project-editor-page {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
:host {
|
||||
--builder-sidebar-width: 280px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.project-editor-layout {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: var(--builder-sidebar-width) 1fr;
|
||||
align-items: start;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.project-editor-hero {
|
||||
.project-editor-layout__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 30;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.project-editor-layout__sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-primary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.project-editor-layout__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.project-editor-layout__content {
|
||||
min-width: 0;
|
||||
padding: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.project-editor-layout__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.project-editor-layout__menu-toggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.project-editor-layout__heading {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-editor-layout__breadcrumb {
|
||||
ol {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
margin: 0 0 4px;
|
||||
padding: 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-light);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
[aria-current='page'] {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.project-editor-layout__breadcrumb-sep {
|
||||
margin: 0 var(--space-xs);
|
||||
}
|
||||
|
||||
.project-editor-layout__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.project-editor-layout__badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.project-editor-layout__badge--draft {
|
||||
background: color-mix(in srgb, var(--warning-color) 15%, transparent);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.project-editor-layout__badge--published {
|
||||
background: color-mix(in srgb, var(--success-color) 15%, transparent);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.project-editor-layout__badge--modified {
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.project-editor-layout__description {
|
||||
margin: var(--space-xs) 0 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
max-width: 65ch;
|
||||
}
|
||||
|
||||
.project-editor-layout__help-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.project-editor-layout__help {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
|
||||
.project-editor-hero h1,
|
||||
.project-editor-hero p {
|
||||
margin: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.project-editor-hero p {
|
||||
color: #697777;
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.project-editor-stack {
|
||||
@@ -83,8 +287,65 @@
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.project-editor-page {
|
||||
padding: 16px;
|
||||
/* Tablet: icon-only collapsed sidebar */
|
||||
@media (max-width: 1023px) and (min-width: 768px) {
|
||||
.project-editor-layout.project-editor-layout {
|
||||
grid-template-columns: 72px 1fr;
|
||||
}
|
||||
|
||||
.project-editor-layout__sidebar {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.project-editor-layout__brand {
|
||||
justify-content: center;
|
||||
|
||||
span:last-child:not(.pi) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: drawer */
|
||||
@media (max-width: 767px) {
|
||||
.project-editor-layout.project-editor-layout {
|
||||
grid-template-columns: 0 1fr;
|
||||
}
|
||||
|
||||
.project-editor-layout__menu-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.project-editor-layout__content {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.project-editor-layout__sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
width: min(var(--builder-sidebar-width), 84vw);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.project-editor-layout--drawer-open {
|
||||
.project-editor-layout__sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.project-editor-layout__backdrop {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.project-editor-layout__header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.project-editor-layout__sidebar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChangeDetectionStrategy, Component, HostListener, effect, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ChangeDetectionStrategy, Component, HostListener, computed, effect, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||||
@@ -17,18 +17,36 @@ import { ProjectEditorNavigationSectionComponent } from '../sections/navigation-
|
||||
import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component';
|
||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../i18n/translate.service';
|
||||
import { LanguageService } from '../../../services/language.service';
|
||||
import { StaticPagesEditorComponent } from '../../content-management/components/static-pages-editor.component';
|
||||
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';
|
||||
|
||||
const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
|
||||
'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'languages', 'navigation', 'preview'
|
||||
];
|
||||
|
||||
const SECTION_LABEL_KEYS: Record<ProjectEditorSectionId, string> = {
|
||||
general: 'builder.general',
|
||||
branding: 'builder.branding',
|
||||
theme: 'builder.theme',
|
||||
header: 'builder.header',
|
||||
footer: 'builder.footer',
|
||||
homepage: 'builder.homepage',
|
||||
widgets: 'builder.widgets',
|
||||
'static-pages': 'builder.staticPages',
|
||||
features: 'builder.marketplaceFeatures',
|
||||
languages: 'builder.languagesTab',
|
||||
navigation: 'builder.navigationTab',
|
||||
preview: 'builder.preview',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-editor-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
TranslatePipe,
|
||||
ProjectEditorNavComponent,
|
||||
ProjectEditorGeneralSectionComponent,
|
||||
@@ -53,21 +71,38 @@ export class ProjectEditorPageComponent {
|
||||
readonly facade = inject(ProjectEditorFacade);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
readonly bootstrap = this.facade.bootstrap;
|
||||
readonly activeSection = this.facade.activeSection;
|
||||
readonly canResetActiveSection = () => !!EDITOR_SECTION_BOOTSTRAP_KEYS[this.activeSection()];
|
||||
|
||||
readonly mobileDrawerOpen = signal(false);
|
||||
readonly helpOpen = signal(false);
|
||||
|
||||
private readonly routeSection = toSignal(
|
||||
this.route.paramMap.pipe(map(params => params.get('section') as ProjectEditorSectionId | null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
readonly activeGroup = computed(() => {
|
||||
const groupId = SECTION_TO_GROUP[this.activeSection()];
|
||||
return BUILDER_GROUPS.find(g => g.id === groupId) ?? null;
|
||||
});
|
||||
|
||||
readonly sectionTitleKey = computed(() => SECTION_LABEL_KEYS[this.activeSection()]);
|
||||
|
||||
readonly isModifiedActiveSection = computed(() => this.facade.modifiedSections().has(this.activeSection()));
|
||||
|
||||
readonly overviewRoute = computed(() => ['/', this.languageService.currentLanguage(), 'edit']);
|
||||
|
||||
constructor() {
|
||||
this.facade.loadBootstrap();
|
||||
effect(() => {
|
||||
const section = this.routeSection();
|
||||
if (section && KNOWN_SECTIONS.includes(section)) {
|
||||
this.facade.setActiveSection(section);
|
||||
this.helpOpen.set(false);
|
||||
this.mobileDrawerOpen.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -78,6 +113,18 @@ export class ProjectEditorPageComponent {
|
||||
}
|
||||
}
|
||||
|
||||
toggleHelp(): void {
|
||||
this.helpOpen.update(open => !open);
|
||||
}
|
||||
|
||||
toggleMobileDrawer(): void {
|
||||
this.mobileDrawerOpen.update(open => !open);
|
||||
}
|
||||
|
||||
closeMobileDrawer(): void {
|
||||
this.mobileDrawerOpen.set(false);
|
||||
}
|
||||
|
||||
@HostListener('window:beforeunload', ['$event'])
|
||||
warnBeforeUnload(event: BeforeUnloadEvent): void {
|
||||
if (this.facade.dirty()) {
|
||||
@@ -93,6 +140,10 @@ export class ProjectEditorPageComponent {
|
||||
*/
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
handleUndoRedoShortcut(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape' && this.mobileDrawerOpen()) {
|
||||
this.mobileDrawerOpen.set(false);
|
||||
return;
|
||||
}
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user