add drag and drop builder

This commit is contained in:
Krrish Ghimire
2026-07-01 01:14:40 +05:45
parent 65f38d68c5
commit 77b32b4d32
31 changed files with 2187 additions and 1 deletions

View File

@@ -51,6 +51,7 @@
],
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"node_modules/leaflet/dist/leaflet.css",
"src/styles.css"
],
"scripts": []

View File

@@ -18,6 +18,7 @@
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"leaflet": "^1.9.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.3"
@@ -27,6 +28,7 @@
"@angular/cli": "^17.3.17",
"@angular/compiler-cli": "^17.3.0",
"@types/jasmine": "~5.1.0",
"@types/leaflet": "^1.9.21",
"autoprefixer": "^10.5.2",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
@@ -4563,6 +4565,12 @@
"@types/send": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -4590,6 +4598,15 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -8628,6 +8645,11 @@
"shell-quote": "^1.8.4"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="
},
"node_modules/less": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz",

View File

@@ -20,6 +20,7 @@
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"leaflet": "^1.9.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.3"
@@ -29,6 +30,7 @@
"@angular/cli": "^17.3.17",
"@angular/compiler-cli": "^17.3.0",
"@types/jasmine": "~5.1.0",
"@types/leaflet": "^1.9.21",
"autoprefixer": "^10.5.2",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",

View File

@@ -20,5 +20,10 @@ export const routes: Routes = [
loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
canActivate: [AuthGuard],
},
{
path: 'builder/:siteId',
loadComponent: () => import('./builder/builder.component').then(m => m.BuilderComponent),
canActivate: [AuthGuard],
},
{ path: '**', redirectTo: '/dashboard' },
];

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { PageWithComponentsResponse, SaveComponentsRequest, ComponentResponse } from '../core/models/component';
@Injectable({ providedIn: 'root' })
export class BuilderApiService {
constructor(private http: HttpClient) {}
getPagesWithComponents(siteId: string): Observable<PageWithComponentsResponse[]> {
return this.http.get<PageWithComponentsResponse[]>(`/api/builder/sites/${siteId}/pages`);
}
saveComponents(siteId: string, pageId: string, request: SaveComponentsRequest): Observable<ComponentResponse[]> {
return this.http.put<ComponentResponse[]>(`/api/builder/sites/${siteId}/pages/${pageId}/components`, request);
}
getTheme(siteId: string): Observable<Record<string, unknown>> {
return this.http.get<Record<string, unknown>>(`/api/builder/sites/${siteId}/theme`);
}
updateTheme(siteId: string, theme: Record<string, unknown>): Observable<Record<string, unknown>> {
return this.http.put<Record<string, unknown>>(`/api/builder/sites/${siteId}/theme`, theme);
}
}

View File

@@ -0,0 +1,265 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { NgIf, NgFor, NgSwitch, NgSwitchCase, AsyncPipe } from '@angular/common';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTabsModule } from '@angular/material/tabs';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CdkDropListGroup } from '@angular/cdk/drag-drop';
import { Subject, Subscription } from 'rxjs';
import { BuilderApiService } from './builder-api.service';
import { BuilderStateService } from './services/builder-state.service';
import { AutoSaveService } from './services/auto-save.service';
import { ComponentPalette } from './component-palette/component-palette.component';
import { CanvasComponent } from './canvas/canvas.component';
import { PropertyPanelComponent } from './property-panel/property-panel.component';
import { ThemeEditorComponent } from './theme-editor/theme-editor.component';
import { PreviewComponent } from './preview/preview.component';
import { AuthService } from '../core/services/auth.service';
import { SiteService } from '../core/services/site.service';
import { PageService } from '../core/services/page.service';
import { CreatePageDialog } from './create-page-dialog/create-page-dialog';
type RightPanel = 'properties' | 'theme' | 'preview';
@Component({
selector: 'app-builder',
standalone: true,
imports: [
NgIf, NgFor, NgSwitch, NgSwitchCase, AsyncPipe,
CdkDropListGroup,
MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule, MatTooltipModule,
MatTabsModule, MatProgressSpinnerModule, MatSnackBarModule, MatDialogModule,
ComponentPalette, CanvasComponent, PropertyPanelComponent,
ThemeEditorComponent, PreviewComponent,
],
template: `
<div class="h-screen flex flex-col bg-gray-50">
<!-- Toolbar -->
<mat-toolbar color="primary" class="h-14 flex items-center justify-between px-4">
<div class="flex items-center gap-3">
<button mat-icon-button (click)="backToDashboard()">
<mat-icon>arrow_back</mat-icon>
</button>
<span class="text-lg font-bold">{{ siteName }}</span>
<span class="text-xs bg-white/20 px-2 py-0.5 rounded">{{ siteStatus }}</span>
<span class="text-xs ml-2" [class.text-green-300]="saveStatus === 'saved'"
[class.text-yellow-300]="saveStatus === 'saving'"
[class.text-gray-400]="saveStatus === 'idle'">
<ng-container [ngSwitch]="saveStatus">
<span *ngSwitchCase="'saving'">Saving...</span>
<span *ngSwitchCase="'saved'">Saved</span>
<span *ngSwitchCase="'idle'"></span>
</ng-container>
</span>
</div>
<div class="flex items-center gap-2">
<button mat-icon-button [disabled]="!canUndo" (click)="undo()" matTooltip="Undo (Ctrl+Z)">
<mat-icon>undo</mat-icon>
</button>
<button mat-icon-button [disabled]="!canRedo" (click)="redo()" matTooltip="Redo (Ctrl+Shift+Z)">
<mat-icon>redo</mat-icon>
</button>
<span class="w-px h-6 bg-white/30 mx-1"></span>
<button mat-icon-button (click)="addPage()" matTooltip="Add Page">
<mat-icon>add_circle_outline</mat-icon>
</button>
<button mat-icon-button [matMenuTriggerFor]="pageMenu" matTooltip="Switch Page">
<mat-icon>description</mat-icon>
</button>
<mat-menu #pageMenu="matMenu">
<button mat-menu-item *ngFor="let page of pages" (click)="selectPage(page.id)">
<mat-icon>article</mat-icon>
<span>{{ page.title }}</span>
<span class="text-xs text-gray-400 ml-2">{{ page.slug }}</span>
</button>
</mat-menu>
<span class="w-px h-6 bg-white/30 mx-1"></span>
<button mat-icon-button [matMenuTriggerFor]="viewMenu" matTooltip="View">
<mat-icon>visibility</mat-icon>
</button>
<mat-menu #viewMenu="matMenu">
<button mat-menu-item (click)="setRightPanel('properties')">
<mat-icon>tune</mat-icon>
<span>Properties</span>
</button>
<button mat-menu-item (click)="setRightPanel('theme')">
<mat-icon>palette</mat-icon>
<span>Theme</span>
</button>
<button mat-menu-item (click)="setRightPanel('preview')">
<mat-icon>visibility</mat-icon>
<span>Preview</span>
</button>
</mat-menu>
</div>
</mat-toolbar>
<!-- Loading overlay -->
<div *ngIf="loading$ | async" class="flex items-center justify-center py-4 bg-blue-50 text-blue-600 text-sm">
<mat-spinner diameter="20" class="mr-2"></mat-spinner>
Loading...
</div>
<!-- 3-panel layout -->
<div class="flex-1 flex overflow-hidden" cdkDropListGroup>
<!-- Left: Palette -->
<div class="w-64 border-r bg-white overflow-y-auto flex-shrink-0">
<app-component-palette></app-component-palette>
</div>
<!-- Center: Canvas -->
<div class="flex-1 overflow-hidden">
<app-canvas></app-canvas>
</div>
<!-- Right: Properties / Theme / Preview -->
<div class="w-96 border-l bg-white overflow-y-auto flex-shrink-0">
<div *ngIf="rightPanel === 'properties'">
<app-property-panel></app-property-panel>
</div>
<div *ngIf="rightPanel === 'theme'">
<app-theme-editor [theme]="themeConfig" (themeChange)="onThemeChange($event)"></app-theme-editor>
</div>
<div *ngIf="rightPanel === 'preview'">
<app-preview [components]="selectedPageComponents" [theme]="themeConfig"></app-preview>
</div>
</div>
</div>
</div>
`,
})
export class BuilderComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private api = inject(BuilderApiService);
private state = inject(BuilderStateService);
private autoSave = inject(AutoSaveService);
private authService = inject(AuthService);
private siteService = inject(SiteService);
private pageService = inject(PageService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
rightPanel: RightPanel = 'properties';
siteName = '';
siteStatus = '';
saveStatus: 'saving' | 'saved' | 'idle' = 'idle';
loading$ = this.state.loading$;
private saveSub: Subscription;
private destroy$ = new Subject<void>();
get pages() { return this.state.pages; }
get themeConfig() { return this.state.themeConfig; }
get selectedPageComponents() { return this.state.selectedPage?.components ?? []; }
get canUndo() { return this.state.canUndo(); }
get canRedo() { return this.state.canRedo(); }
constructor() {
this.saveSub = this.autoSave.saveStatus.subscribe(status => {
this.saveStatus = status;
});
}
ngOnInit(): void {
this.state.setLoading(true);
const siteId = this.route.snapshot.paramMap.get('siteId');
if (!siteId) {
this.router.navigate(['/dashboard']);
return;
}
this.loadSite(siteId);
}
private loadSite(siteId: string): void {
this.siteService.get(siteId).subscribe({
next: (site) => {
this.siteName = site.name;
this.siteStatus = site.status;
this.state.init(siteId, []);
if (site.themeConfig) {
this.state.setThemeConfig(site.themeConfig as Record<string, unknown>);
}
this.loadPages(siteId);
},
error: () => {
this.snackBar.open('Failed to load site', 'Close', { duration: 3000 });
this.router.navigate(['/dashboard']);
},
});
}
private loadPages(siteId: string): void {
this.api.getPagesWithComponents(siteId).subscribe({
next: (pages) => {
this.state.init(siteId, pages);
this.state.setLoading(false);
},
error: () => {
this.snackBar.open('Failed to load pages', 'Close', { duration: 3000 });
this.state.setLoading(false);
},
});
}
selectPage(pageId: string): void {
this.state.selectPage(pageId);
}
setRightPanel(panel: RightPanel): void {
this.rightPanel = panel;
}
onThemeChange(theme: Record<string, unknown>): void {
this.state.updateThemeConfig(theme);
const siteId = this.state.siteId;
if (siteId) {
this.api.updateTheme(siteId, theme).subscribe();
}
}
addPage(): void {
const siteId = this.state.siteId;
if (!siteId) return;
const dialogRef = this.dialog.open(CreatePageDialog, {
width: '400px',
data: { siteId },
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.loadPages(siteId);
}
});
}
undo(): void {
this.state.undo();
}
redo(): void {
this.state.redo();
}
backToDashboard(): void {
this.router.navigate(['/dashboard']);
}
ngOnDestroy(): void {
this.saveSub?.unsubscribe();
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,133 @@
import { Component } from '@angular/core';
import { NgFor, NgIf, NgSwitch, NgSwitchCase } from '@angular/common';
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
import { MatIconModule } from '@angular/material/icon';
import { BuilderStateService } from '../services/builder-state.service';
import { ComponentItem, ComponentType } from '../../core/models/component';
import { HeadingRenderer } from '../renderer/heading-renderer.component';
import { TextRenderer } from '../renderer/text-renderer.component';
import { ImageRenderer } from '../renderer/image-renderer.component';
import { ButtonRenderer } from '../renderer/button-renderer.component';
import { DividerRenderer } from '../renderer/divider-renderer.component';
import { GalleryRenderer } from '../renderer/gallery-renderer.component';
import { VideoRenderer } from '../renderer/video-renderer.component';
import { ContactFormRenderer } from '../renderer/contact-form-renderer.component';
import { MapRenderer } from '../renderer/map-renderer.component';
import { CustomHtmlRenderer } from '../renderer/custom-html-renderer.component';
@Component({
selector: 'app-canvas',
standalone: true,
imports: [
NgFor, NgIf, NgSwitch, NgSwitchCase, MatIconModule,
CdkDropList, CdkDrag,
HeadingRenderer, TextRenderer, ImageRenderer, ButtonRenderer, DividerRenderer,
GalleryRenderer, VideoRenderer, ContactFormRenderer, MapRenderer, CustomHtmlRenderer,
],
template: `
<div class="flex flex-col h-full">
<div class="flex items-center justify-between px-4 py-2 border-b bg-white">
<h3 class="text-sm font-medium text-gray-700">
{{ selectedPage?.title || 'Select a page' }}
</h3>
<span class="text-xs text-gray-400">{{ components.length }} components</span>
</div>
<div class="flex-1 overflow-y-auto p-4 bg-gray-50">
<div cdkDropList
class="space-y-2 min-h-full"
[cdkDropListData]="components"
(cdkDropListDropped)="onDrop($event)"
[cdkDropListDisabled]="!selectedPage">
<div *ngIf="components.length === 0"
class="flex flex-col items-center justify-center h-64 text-gray-400">
<mat-icon style="font-size: 48px; width: 48px; height: 48px;" class="mb-3">add_circle_outline</mat-icon>
<p class="text-sm">Drag components here from the palette</p>
</div>
<div *ngFor="let comp of components; let i = index"
cdkDrag
[cdkDragData]="comp"
(click)="selectComponent(comp.id)"
class="relative rounded-lg border-2 transition-all cursor-pointer"
[class.border-blue-500]="selectedComponentId === comp.id"
[class.border-transparent]="selectedComponentId !== comp.id"
[class.hover:border-blue-300]="selectedComponentId !== comp.id"
[class.bg-white]="true"
[class.shadow-sm]="true">
<div class="absolute top-1 right-1 z-10 flex gap-1 opacity-0 hover:opacity-100 transition-opacity">
<button mat-icon-button (click)="$event.stopPropagation(); removeComponent(comp.id)"
class="w-6 h-6 flex items-center justify-center bg-red-500 text-white rounded-full text-xs">
<mat-icon style="font-size: 14px; width: 14px; height: 14px;">close</mat-icon>
</button>
</div>
<div class="p-3" [class.pointer-events-none]="true">
<ng-container [ngSwitch]="comp.type">
<app-heading-renderer *ngSwitchCase="'HEADING'" [config]="comp.config" [styles]="comp.styles"></app-heading-renderer>
<app-text-renderer *ngSwitchCase="'TEXT'" [config]="comp.config" [styles]="comp.styles"></app-text-renderer>
<app-image-renderer *ngSwitchCase="'IMAGE'" [config]="comp.config" [styles]="comp.styles"></app-image-renderer>
<app-button-renderer *ngSwitchCase="'BUTTON'" [config]="comp.config" [styles]="comp.styles"></app-button-renderer>
<app-divider-renderer *ngSwitchCase="'DIVIDER'" [config]="comp.config" [styles]="comp.styles"></app-divider-renderer>
<app-gallery-renderer *ngSwitchCase="'GALLERY'" [config]="comp.config" [styles]="comp.styles"></app-gallery-renderer>
<app-video-renderer *ngSwitchCase="'VIDEO'" [config]="comp.config" [styles]="comp.styles"></app-video-renderer>
<app-contact-form-renderer *ngSwitchCase="'CONTACT_FORM'" [config]="comp.config" [styles]="comp.styles"></app-contact-form-renderer>
<app-map-renderer *ngSwitchCase="'MAP'" [config]="comp.config" [styles]="comp.styles"></app-map-renderer>
<app-custom-html-renderer *ngSwitchCase="'CUSTOM_HTML'" [config]="comp.config" [styles]="comp.styles"></app-custom-html-renderer>
</ng-container>
</div>
<!-- drag handle -->
<div class="absolute top-1 left-1 text-gray-300 cursor-grab opacity-0 hover:opacity-100 transition-opacity"
*cdkDragHandle>
<mat-icon style="font-size: 16px; width: 16px; height: 16px;">drag_indicator</mat-icon>
</div>
<!-- type badge -->
<div class="absolute bottom-1 left-1 text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">
{{ comp.type }}
</div>
</div>
</div>
</div>
</div>
`,
styles: [`
:host { display: flex; flex-direction: column; height: 100%; }
.cdk-drag-preview {
@apply bg-white shadow-lg rounded-lg border border-blue-200 p-3;
box-sizing: border-box;
}
.cdk-drag-placeholder { opacity: 0; }
.cdk-drag-animating { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }
.cdk-drop-list-dragging .cdk-drag { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }
`],
})
export class CanvasComponent {
constructor(private state: BuilderStateService) {}
get selectedPage() { return this.state.selectedPage; }
get components(): ComponentItem[] { return this.state.selectedPage?.components ?? []; }
get selectedComponentId(): string | null { return this.state.selectedComponentId; }
selectComponent(id: string): void {
this.state.selectComponent(id);
}
removeComponent(id: string): void {
this.state.removeComponent(id);
}
onDrop(event: CdkDragDrop<ComponentItem[], any>): void {
if (event.previousContainer === event.container) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
const pageId = this.selectedPage?.id;
if (pageId) {
this.state.reorderComponents(pageId, event.container.data);
}
} else {
const data = event.previousContainer.data[event.previousIndex];
this.state.addComponent(data as any);
}
}
}

View File

@@ -0,0 +1,81 @@
import { Component } from '@angular/core';
import { NgFor } from '@angular/common';
import { CdkDrag, CdkDropList } from '@angular/cdk/drag-drop';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { ComponentType, getComponentLabel, getComponentIcon } from '../../core/models/component';
import { BuilderStateService } from '../services/builder-state.service';
const CATEGORIES: { label: string; types: ComponentType[] }[] = [
{
label: 'Content',
types: ['HEADING', 'TEXT', 'DIVIDER'],
},
{
label: 'Media',
types: ['IMAGE', 'GALLERY', 'VIDEO'],
},
{
label: 'Interactive',
types: ['BUTTON', 'CONTACT_FORM', 'MAP'],
},
{
label: 'Advanced',
types: ['CUSTOM_HTML'],
},
];
@Component({
selector: 'app-component-palette',
standalone: true,
imports: [NgFor, CdkDropList, CdkDrag, MatIconModule, MatListModule],
template: `
<div class="p-3">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">Components</h3>
<div cdkDropList [cdkDropListData]="allTypes" (cdkDropListDropped)="noop()" class="space-y-4">
<div *ngFor="let cat of categories" class="mb-4">
<p class="text-xs text-gray-400 uppercase px-2 mb-2">{{ cat.label }}</p>
<div class="space-y-1">
<div *ngFor="let type of cat.types"
cdkDrag
[cdkDragData]="type"
class="flex items-center gap-2 px-3 py-2 rounded-md cursor-grab
text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600
transition-colors border border-transparent hover:border-blue-200"
(click)="addComponent(type)">
<mat-icon class="text-lg" style="width: 20px; height: 20px; font-size: 18px;">{{ getIcon(type) }}</mat-icon>
<span>{{ getLabel(type) }}</span>
</div>
</div>
</div>
</div>
</div>
`,
styles: [`
:host { display: block; }
.cdk-drag-preview {
@apply bg-white shadow-lg rounded-md border border-blue-200 px-3 py-2 text-sm;
}
.cdk-drag-placeholder { opacity: 0; }
`],
})
export class ComponentPalette {
categories = CATEGORIES;
allTypes: ComponentType[] = CATEGORIES.flatMap(c => c.types);
constructor(private state: BuilderStateService) {}
getLabel(type: ComponentType): string {
return getComponentLabel(type);
}
getIcon(type: ComponentType): string {
return getComponentIcon(type);
}
addComponent(type: ComponentType): void {
this.state.addComponent(type);
}
noop(): void {}
}

View File

@@ -0,0 +1,74 @@
import { Component, Inject, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { NgIf } from '@angular/common';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { PageService } from '../../core/services/page.service';
@Component({
selector: 'app-create-page-dialog',
standalone: true,
imports: [NgIf, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, ReactiveFormsModule],
template: `
<h2 mat-dialog-title>Add Page</h2>
<mat-dialog-content>
<form [formGroup]="form" class="flex flex-col gap-4 pt-2">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Page Title</mat-label>
<input matInput formControlName="title" placeholder="About" />
<mat-error>Title is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Slug</mat-label>
<input matInput formControlName="slug" placeholder="about" />
<mat-error *ngIf="form.get('slug')?.hasError('required')">Slug is required</mat-error>
<mat-error *ngIf="form.get('slug')?.hasError('pattern')">Lowercase letters, numbers, and hyphens only</mat-error>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button mat-flat-button color="primary" [disabled]="form.invalid || loading" (click)="onCreate()">
{{ loading ? 'Creating...' : 'Create' }}
</button>
</mat-dialog-actions>
`,
})
export class CreatePageDialog {
private fb = inject(FormBuilder);
private dialogRef = inject(MatDialogRef<CreatePageDialog>);
private pageService = inject(PageService);
constructor() {
const data: { siteId: string } = inject(MAT_DIALOG_DATA);
this.siteId = data.siteId;
}
siteId: string;
form = this.fb.group({
title: ['', Validators.required],
slug: ['', [Validators.pattern(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/)]],
});
loading = false;
onCreate(): void {
if (this.form.invalid) return;
this.loading = true;
this.pageService.create(this.siteId, {
title: this.form.value.title || '',
slug: this.form.value.slug || '',
orderIndex: 0,
}).subscribe({
next: (page) => {
this.dialogRef.close(page);
},
error: () => {
this.loading = false;
},
});
}
}

View File

@@ -0,0 +1,194 @@
import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/core';
import { NgFor, NgIf } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIconModule } from '@angular/material/icon';
import { ComponentItem, ComponentType } from '../../core/models/component';
type DeviceType = 'desktop' | 'tablet' | 'mobile';
const DEVICE_WIDTHS: Record<DeviceType, string> = {
desktop: '100%',
tablet: '768px',
mobile: '375px',
};
@Component({
selector: 'app-preview',
standalone: true,
imports: [NgIf, NgFor, MatButtonToggleModule, MatIconModule],
template: `
<div class="flex flex-col h-full">
<div class="flex items-center justify-between px-4 py-2 border-b bg-white">
<h3 class="text-sm font-medium text-gray-700">Preview</h3>
<mat-button-toggle-group [(value)]="device" (change)="onDeviceChange()" class="scale-75 origin-right">
<mat-button-toggle value="desktop" aria-label="Desktop">
<mat-icon>desktop_windows</mat-icon>
</mat-button-toggle>
<mat-button-toggle value="tablet" aria-label="Tablet">
<mat-icon>tablet_mac</mat-icon>
</mat-button-toggle>
<mat-button-toggle value="mobile" aria-label="Mobile">
<mat-icon>phone_iphone</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
<div class="flex-1 overflow-auto bg-gray-200 p-4">
<div class="bg-white shadow-lg transition-all duration-300 min-h-[600px]"
[style.width]="deviceWidth"
style="margin: 0 auto;">
<div *ngIf="!previewHtml" class="flex items-center justify-center h-64 text-gray-400 text-sm">
No components to preview
</div>
<div *ngIf="previewHtml" [innerHTML]="previewHtml"></div>
</div>
</div>
</div>
`,
})
export class PreviewComponent implements OnChanges {
@Input() components: ComponentItem[] = [];
@Input() theme: Record<string, unknown> = {};
private sanitizer = inject(DomSanitizer);
device: DeviceType = 'desktop';
previewHtml: SafeHtml = '';
get deviceWidth(): string {
return DEVICE_WIDTHS[this.device];
}
ngOnChanges(changes: SimpleChanges): void {
this.renderPreview();
}
onDeviceChange(): void {
// width change is handled by deviceWidth getter
}
private renderPreview(): void {
const html = this.buildHtml();
this.previewHtml = this.sanitizer.bypassSecurityTrustHtml(html);
}
private buildHtml(): string {
const tc = this.theme;
const styleVars = `
--primary-color: ${tc['primaryColor'] || '#1976d2'};
--secondary-color: ${tc['secondaryColor'] || '#dc004e'};
--background-color: ${tc['backgroundColor'] || '#ffffff'};
--text-color: ${tc['textColor'] || '#1a1a1a'};
--heading-font: ${tc['headingFont'] || 'Roboto'};
--body-font: ${tc['bodyFont'] || 'Roboto'};
--border-radius: ${tc['borderRadius'] || '4px'};
--spacing: ${tc['spacing'] || '16px'};
`;
const componentHtml = this.components.map(c => this.renderComponent(c)).join('\n');
return `
<div style="font-family: var(--body-font); color: var(--text-color); background: var(--background-color); padding: var(--spacing); ${styleVars}">
<style>
img, video, iframe, embed { max-width: 100%; height: auto; }
@media (max-width: 640px) {
h1, h2, h3, h4, h5, h6 { word-break: break-word; }
}
</style>
${componentHtml}
</div>
`;
}
private renderComponent(comp: ComponentItem): string {
const s = this.styleObjToStr(comp.styles);
return this.renderByType(comp.type, comp.config, s, comp.styles);
}
private renderByType(type: ComponentType, config: Record<string, unknown>, styles: string, styleObj: Record<string, unknown> = {}): string {
switch (type) {
case 'HEADING': {
const level = (config['level'] as string) || 'h2';
const text = config['text'] || '';
return `<${level} style="font-family: var(--heading-font); ${styles}">${text}</${level}>`;
}
case 'TEXT':
return `<div style="${styles}">${config['content'] || ''}</div>`;
case 'IMAGE': {
const src = config['src'] || '';
const alt = config['alt'] || '';
const link = config['linkTo'] as string;
const img = `<img src="${src}" alt="${alt}" style="${styles}" />`;
return link ? `<a href="${link}" target="_blank">${img}</a>` : img;
}
case 'BUTTON': {
const text = config['text'] || 'Button';
const link = config['link'] || '#';
return `<a href="${link}" style="display: inline-block; text-decoration: none; cursor: pointer; font-family: var(--body-font); ${styles}">${text}</a>`;
}
case 'DIVIDER': {
const borderStyle = (config['style'] as string) || 'solid';
const color = (styleObj['color'] as string) || '#e0e0e0';
const thickness = (styleObj['thickness'] as string) || '1px';
const margin = (styleObj['margin'] as string) || '1.5rem 0';
return `<hr style="border: none; border-top: ${thickness} ${borderStyle} ${color}; margin: ${margin};" />`;
}
case 'GALLERY': {
const images = (config['images'] as any[]) || [];
const cols = (styleObj['columns'] as string) || '3';
const gap = (styleObj['gap'] as string) || '8px';
const br = (styleObj['borderRadius'] as string) || '4px';
const items = images.map((img: any) =>
`<img src="${img.src}" alt="${img.alt || ''}" style="border-radius: ${br}; width: 100%; height: auto; object-fit: cover;" />`
).join('');
return `<div style="display: grid; grid-template-columns: repeat(${cols}, 1fr); gap: ${gap};">${items}</div>`;
}
case 'VIDEO': {
const src = config['src'] as string;
const controls = config['controls'] !== false ? 'controls' : '';
const autoplay = config['autoplay'] ? 'autoplay' : '';
const w = (styleObj['width'] as string) || '100%';
const mw = (styleObj['maxWidth'] as string) || '800px';
const br2 = (styleObj['borderRadius'] as string) || '4px';
return `<video src="${src}" ${controls} ${autoplay} style="width: ${w}; max-width: ${mw}; border-radius: ${br2}; max-width: 100%;"></video>`;
}
case 'CONTACT_FORM': {
const fields = (config['fields'] as string[]) || ['name', 'email', 'message'];
const submitText = config['submitText'] || 'Send';
const bg = (styleObj['backgroundColor'] as string) || '#f5f5f5';
const pad = (styleObj['padding'] as string) || '2rem';
const br3 = (styleObj['borderRadius'] as string) || '8px';
const inputs = fields.map(f =>
f === 'message'
? `<div style="margin-bottom: 1rem;"><label style="display: block; font-size: 0.875rem; margin-bottom: 0.25rem; text-transform: capitalize;">${f}</label><textarea rows="4" placeholder="${f}" style="width: 100%; border: 1px solid #d1d5db; border-radius: 4px; padding: 0.5rem; font-size: 0.875rem;"></textarea></div>`
: `<div style="margin-bottom: 1rem;"><label style="display: block; font-size: 0.875rem; margin-bottom: 0.25rem; text-transform: capitalize;">${f}</label><input type="${f === 'email' ? 'email' : 'text'}" placeholder="${f}" style="width: 100%; border: 1px solid #d1d5db; border-radius: 4px; padding: 0.5rem; font-size: 0.875rem;" /></div>`
).join('');
return `<form style="background: ${bg}; padding: ${pad}; border-radius: ${br3};">${inputs}<button type="submit" style="background-color: var(--primary-color); color: white; padding: 0.5rem 1.5rem; border: none; border-radius: 4px; cursor: pointer;">${submitText}</button></form>`;
}
case 'MAP': {
const addr = (config['address'] as string) || 'New York, NY';
const h = (styleObj['height'] as string) || '400px';
const br4 = (styleObj['borderRadius'] as string) || '4px';
const encoded = encodeURIComponent(addr);
return `<div style="display: flex; align-items: center; justify-content: center; width: 100%; height: ${h}; border-radius: ${br4}; background: #e8f4f8; color: #333; font-family: sans-serif;">
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 2rem; margin-bottom: 0.5rem;">📍</div>
<p style="margin: 0 0 0.5rem; font-weight: 600;">${addr}</p>
<a href="https://www.openstreetmap.org/search?query=${encoded}" target="_blank" style="font-size: 0.85rem; color: #1976d2; text-decoration: underline;">View on OpenStreetMap</a>
</div>
</div>`;
}
case 'CUSTOM_HTML':
return `<div style="${styles}">${(config['html'] as string) || ''}</div>`;
default:
return '';
}
}
private styleObjToStr(styles: Record<string, unknown>): string {
return Object.entries(styles)
.map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`)
.join('; ');
}
}

View File

@@ -0,0 +1,270 @@
import { Component, OnDestroy } from '@angular/core';
import { NgIf, NgFor, NgSwitch, NgSwitchCase, KeyValuePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { Subscription } from 'rxjs';
import { BuilderStateService } from '../services/builder-state.service';
import { ComponentItem, getComponentLabel } from '../../core/models/component';
@Component({
selector: 'app-property-panel',
standalone: true,
imports: [
NgIf, NgFor, NgSwitch, NgSwitchCase, KeyValuePipe, FormsModule,
MatExpansionModule, MatFormFieldModule, MatInputModule, MatSelectModule,
MatButtonModule, MatIconModule, MatSlideToggleModule,
],
template: `
<div class="p-3 h-full overflow-y-auto">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">Properties</h3>
<div *ngIf="!component" class="flex flex-col items-center justify-center h-40 text-gray-400">
<mat-icon style="font-size: 36px; width: 36px; height: 36px;" class="mb-2">tune</mat-icon>
<p class="text-xs">Select a component to edit</p>
</div>
<div *ngIf="component">
<div class="mb-4 px-2">
<p class="text-sm font-medium text-gray-700">{{ componentLabel }}</p>
<p class="text-xs text-gray-400">{{ component.type }}</p>
</div>
<mat-accordion>
<mat-expansion-panel expanded>
<mat-expansion-panel-header>
<mat-panel-title class="text-sm font-medium">Content</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [ngSwitch]="component.type">
<div *ngSwitchCase="'HEADING'" class="space-y-3">
<mat-form-field appearance="outline">
<mat-label>Text</mat-label>
<input matInput [(ngModel)]="component.config['text']" (ngModelChange)="onConfigChange()" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Level</mat-label>
<mat-select [(value)]="component.config['level']" (selectionChange)="onConfigChange()">
<mat-option value="h1">H1</mat-option>
<mat-option value="h2">H2</mat-option>
<mat-option value="h3">H3</mat-option>
<mat-option value="h4">H4</mat-option>
<mat-option value="h5">H5</mat-option>
<mat-option value="h6">H6</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *ngSwitchCase="'TEXT'" class="space-y-3">
<mat-form-field appearance="outline">
<mat-label>Content</mat-label>
<textarea matInput rows="4" [(ngModel)]="component.config['content']" (ngModelChange)="onConfigChange()"></textarea>
</mat-form-field>
</div>
<div *ngSwitchCase="'IMAGE'" class="space-y-3">
<mat-form-field appearance="outline">
<mat-label>Image URL</mat-label>
<input matInput [(ngModel)]="component.config['src']" (ngModelChange)="onConfigChange()" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Alt Text</mat-label>
<input matInput [(ngModel)]="component.config['alt']" (ngModelChange)="onConfigChange()" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Link To (optional)</mat-label>
<input matInput [(ngModel)]="component.config['linkTo']" (ngModelChange)="onConfigChange()" />
</mat-form-field>
</div>
<div *ngSwitchCase="'BUTTON'" class="space-y-3">
<mat-form-field appearance="outline">
<mat-label>Button Text</mat-label>
<input matInput [(ngModel)]="component.config['text']" (ngModelChange)="onConfigChange()" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Link</mat-label>
<input matInput [(ngModel)]="component.config['link']" (ngModelChange)="onConfigChange()" />
</mat-form-field>
</div>
<div *ngSwitchCase="'DIVIDER'" class="space-y-3">
<mat-form-field appearance="outline">
<mat-label>Style</mat-label>
<mat-select [(value)]="component.config['style']" (selectionChange)="onConfigChange()">
<mat-option value="solid">Solid</mat-option>
<mat-option value="dashed">Dashed</mat-option>
<mat-option value="dotted">Dotted</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *ngSwitchCase="'VIDEO'" class="space-y-3">
<mat-form-field appearance="outline">
<mat-label>Video URL</mat-label>
<input matInput [(ngModel)]="component.config['src']" (ngModelChange)="onConfigChange()" />
</mat-form-field>
<div class="flex items-center gap-2">
<mat-slide-toggle [(ngModel)]="component.config['autoplay']" (ngModelChange)="onConfigChange()">
Autoplay
</mat-slide-toggle>
<mat-slide-toggle [(ngModel)]="component.config['controls']" (ngModelChange)="onConfigChange()">
Controls
</mat-slide-toggle>
</div>
</div>
<div *ngSwitchCase="'CONTACT_FORM'" class="space-y-3">
<mat-form-field appearance="outline">
<mat-label>Submit Button Text</mat-label>
<input matInput [(ngModel)]="component.config['submitText']" (ngModelChange)="onConfigChange()" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Email To</mat-label>
<input matInput [(ngModel)]="component.config['emailTo']" (ngModelChange)="onConfigChange()" />
</mat-form-field>
</div>
<div *ngSwitchCase="'MAP'" class="space-y-3">
<mat-form-field appearance="outline">
<mat-label>Address</mat-label>
<input matInput [(ngModel)]="component.config['address']" (ngModelChange)="onConfigChange()" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Zoom Level</mat-label>
<mat-select [(value)]="component.config['zoom']" (selectionChange)="onConfigChange()">
<mat-option *ngFor="let z of [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]" [value]="z">{{ z }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *ngSwitchCase="'GALLERY'" class="space-y-3">
<div *ngFor="let img of getImages(); let i = index" class="flex items-center gap-2 p-2 border rounded">
<span class="text-xs truncate flex-1">{{ img.src }}</span>
<button mat-icon-button type="button" (click)="removeImage(i)" class="w-6 h-6 flex items-center justify-center">
<mat-icon style="font-size: 14px;">close</mat-icon>
</button>
</div>
<div class="flex gap-2">
<mat-form-field appearance="outline" class="flex-1">
<mat-label>Image URL</mat-label>
<input matInput [(ngModel)]="newImageUrl" placeholder="https://..." (keydown.enter)="addImage()" />
</mat-form-field>
<button mat-stroked-button type="button" (click)="addImage()">Add</button>
</div>
<p *ngIf="getImages().length === 0" class="text-xs text-gray-400 px-1">
No images. Add image URLs above.
</p>
</div>
<div *ngSwitchCase="'CUSTOM_HTML'" class="space-y-3">
<mat-form-field appearance="outline">
<mat-label>HTML</mat-label>
<textarea matInput rows="6" [(ngModel)]="component.config['html']" (ngModelChange)="onConfigChange()"></textarea>
</mat-form-field>
</div>
</ng-container>
</mat-expansion-panel>
<mat-expansion-panel expanded>
<mat-expansion-panel-header>
<mat-panel-title class="text-sm font-medium">Styles</mat-panel-title>
</mat-expansion-panel-header>
<div class="space-y-3" *ngIf="component">
<div *ngFor="let entry of styleKeys">
<mat-form-field appearance="outline">
<mat-label>{{ formatKey(entry) }}</mat-label>
<input matInput [value]="component.styles[entry]"
(input)="onStyleChange(entry, $any($event.target).value)" />
</mat-form-field>
</div>
</div>
</mat-expansion-panel>
</mat-accordion>
<div class="mt-4 px-2">
<button mat-stroked-button color="warn" size="small" (click)="removeComponent()" class="w-full">
<mat-icon class="mr-1">delete</mat-icon>
Remove Component
</button>
</div>
</div>
</div>
`,
})
export class PropertyPanelComponent implements OnDestroy {
private sub: Subscription;
component: ComponentItem | null = null;
newImageUrl = '';
constructor(private state: BuilderStateService) {
this.sub = this.state.selectedComponentId$.subscribe(() => {
this.component = this.state.selectedComponent ? { ...this.state.selectedComponent } : null;
this.newImageUrl = '';
});
}
get componentLabel(): string {
return this.component ? getComponentLabel(this.component.type) : '';
}
get styleKeys(): string[] {
return this.component ? Object.keys(this.component.styles) : [];
}
formatKey(key: string): string {
return key.replace(/[A-Z]/g, c => ' ' + c.toLowerCase()).replace(/^./, s => s.toUpperCase());
}
getImages(): { src: string; alt?: string }[] {
return (this.component?.config['images'] as any[]) || [];
}
addImage(): void {
const url = this.newImageUrl?.trim();
if (!url || !this.component) return;
const images = [...this.getImages(), { src: url, alt: '' }];
this.component.config['images'] = images;
this.onConfigChange();
this.newImageUrl = '';
}
removeImage(index: number): void {
if (!this.component) return;
const images = this.getImages().filter((_, i) => i !== index);
this.component.config['images'] = images;
this.onConfigChange();
}
onConfigChange(): void {
if (this.component) {
this.state.updateComponent(this.component.id, { config: { ...this.component.config } });
}
}
onStyleChange(key: string, value: string): void {
if (this.component) {
const updatedStyles = { ...this.component.styles, [key]: value };
this.component = { ...this.component, styles: updatedStyles };
this.state.updateComponent(this.component.id, { styles: updatedStyles });
}
}
removeComponent(): void {
if (this.component) {
this.state.removeComponent(this.component.id);
}
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
}

View File

@@ -0,0 +1,19 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-button-renderer',
standalone: true,
template: `
<a [href]="config['link'] || '#'" class="inline-block text-center no-underline cursor-pointer"
[style]="styleStr">
{{ config['text'] }}
</a>
`,
})
export class ButtonRenderer {
@Input() config: Record<string, unknown> = {};
@Input() styles: Record<string, unknown> = {};
get styleStr(): string {
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
}
}

View File

@@ -0,0 +1,37 @@
import { Component, Input } from '@angular/core';
import { NgFor, NgIf } from '@angular/common';
@Component({
selector: 'app-contact-form-renderer',
standalone: true,
imports: [NgFor, NgIf],
template: `
<form [style]="styleStr" class="space-y-4">
<div *ngFor="let field of fields">
<label class="block text-sm font-medium text-gray-700 mb-1 capitalize">{{ field }}</label>
<input *ngIf="field !== 'message'" [type]="field === 'email' ? 'email' : 'text'"
[placeholder]="field"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm" />
<textarea *ngIf="field === 'message'" rows="4" placeholder="Message"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm"></textarea>
</div>
<button type="submit" [style]="btnStyle">{{ config['submitText'] || 'Send' }}</button>
</form>
`,
})
export class ContactFormRenderer {
@Input() config: Record<string, unknown> = {};
@Input() styles: Record<string, unknown> = {};
get fields(): string[] {
return (this.config['fields'] as string[]) || ['name', 'email', 'message'];
}
get styleStr(): string {
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
}
get btnStyle(): string {
return 'background-color: #1976d2; color: white; padding: 0.5rem 1.5rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem;';
}
}

View File

@@ -0,0 +1,22 @@
import { Component, Input } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Component({
selector: 'app-custom-html-renderer',
standalone: true,
template: `<div [style]="styleStr" [innerHTML]="safeHtml"></div>`,
})
export class CustomHtmlRenderer {
@Input() config: Record<string, unknown> = {};
@Input() styles: Record<string, unknown> = {};
constructor(private sanitizer: DomSanitizer) {}
get safeHtml(): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml((this.config['html'] as string) || '');
}
get styleStr(): string {
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
}
}

View File

@@ -0,0 +1,18 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-divider-renderer',
standalone: true,
template: `<hr [style]="styleStr" />`,
})
export class DividerRenderer {
@Input() config: Record<string, unknown> = {};
@Input() styles: Record<string, unknown> = {};
get styleStr(): string {
const borderStyle = this.config['style'] || 'solid';
const color = this.styles['color'] || '#e0e0e0';
const thickness = this.styles['thickness'] || '1px';
const margin = this.styles['margin'] || '1.5rem 0';
return `border: none; border-top: ${thickness} ${borderStyle} ${color}; margin: ${margin};`;
}
}

View File

@@ -0,0 +1,33 @@
import { Component, Input } from '@angular/core';
import { NgFor, NgIf } from '@angular/common';
@Component({
selector: 'app-gallery-renderer',
standalone: true,
imports: [NgFor, NgIf],
template: `
<div [style]="gridStyle">
<img *ngFor="let img of images" [src]="img.src" [alt]="img.alt || ''"
[style]="imgStyle" class="w-full h-auto object-cover" />
</div>
`,
})
export class GalleryRenderer {
@Input() config: Record<string, unknown> = {};
@Input() styles: Record<string, unknown> = {};
get images(): { src: string; alt?: string }[] {
return (this.config['images'] as any[]) || [];
}
get gridStyle(): string {
const cols = this.styles['columns'] || '3';
const gap = this.styles['gap'] || '8px';
return `display: grid; grid-template-columns: repeat(${cols}, 1fr); gap: ${gap};`;
}
get imgStyle(): string {
const br = this.styles['borderRadius'] || '4px';
return `border-radius: ${br};`;
}
}

View File

@@ -0,0 +1,23 @@
import { Component, Input } from '@angular/core';
import { NgIf } from '@angular/common';
@Component({
selector: 'app-heading-renderer',
standalone: true,
imports: [NgIf],
template: `
<h1 *ngIf="config['level'] === 'h1'" [style]="styleStr">{{ config['text'] }}</h1>
<h2 *ngIf="config['level'] === 'h2' || !config['level']" [style]="styleStr">{{ config['text'] }}</h2>
<h3 *ngIf="config['level'] === 'h3'" [style]="styleStr">{{ config['text'] }}</h3>
<h4 *ngIf="config['level'] === 'h4'" [style]="styleStr">{{ config['text'] }}</h4>
<h5 *ngIf="config['level'] === 'h5'" [style]="styleStr">{{ config['text'] }}</h5>
<h6 *ngIf="config['level'] === 'h6'" [style]="styleStr">{{ config['text'] }}</h6>
`,
})
export class HeadingRenderer {
@Input() config: Record<string, unknown> = {};
@Input() styles: Record<string, unknown> = {};
get styleStr(): string {
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
}
}

View File

@@ -0,0 +1,21 @@
import { Component, Input } from '@angular/core';
import { NgIf } from '@angular/common';
@Component({
selector: 'app-image-renderer',
standalone: true,
imports: [NgIf],
template: `
<a *ngIf="config['linkTo']" [href]="config['linkTo']" target="_blank">
<img [src]="config['src'] || ''" [alt]="config['alt'] || ''" [style]="styleStr" />
</a>
<img *ngIf="!config['linkTo']" [src]="config['src'] || ''" [alt]="config['alt'] || ''" [style]="styleStr" />
`,
})
export class ImageRenderer {
@Input() config: Record<string, unknown> = {};
@Input() styles: Record<string, unknown> = {};
get styleStr(): string {
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
}
}

View File

@@ -0,0 +1,75 @@
import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core';
import * as L from 'leaflet';
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)['_getIconUrl'];
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
@Component({
selector: 'app-map-renderer',
standalone: true,
template: `
<div [style]="styleStr" class="h-full">
<div #mapContainer class="w-full" [style.height]="mapHeight"></div>
</div>
`,
})
export class MapRenderer implements AfterViewInit, OnDestroy {
@Input() config: Record<string, unknown> = {};
@Input() styles: Record<string, unknown> = {};
@ViewChild('mapContainer') mapContainer!: ElementRef;
private map: L.Map | null = null;
get mapHeight(): string {
return (this.styles['height'] as string) || '400px';
}
ngAfterViewInit(): void {
this.initMap();
}
ngOnDestroy(): void {
this.map?.remove();
}
private async initMap(): Promise<void> {
const address = (this.config['address'] as string) || 'New York, NY';
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1`
);
const data = await res.json();
if (data?.length > 0) {
const lat = parseFloat(data[0].lat);
const lon = parseFloat(data[0].lon);
this.createMap(lat, lon);
} else {
this.createMap(40.7128, -74.006);
}
} catch {
this.createMap(40.7128, -74.006);
}
}
private createMap(lat: number, lon: number): void {
if (this.map || !this.mapContainer) return;
const el = this.mapContainer.nativeElement;
this.map = L.map(el, { zoomControl: true }).setView([lat, lon], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(this.map);
L.marker([lat, lon]).addTo(this.map);
}
get styleStr(): string {
return Object.entries(this.styles)
.map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`)
.join('; ');
}
}

View File

@@ -0,0 +1,22 @@
import { Component, Input } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Component({
selector: 'app-text-renderer',
standalone: true,
template: `<div [style]="styleStr" [innerHTML]="safeContent"></div>`,
})
export class TextRenderer {
@Input() config: Record<string, unknown> = {};
@Input() styles: Record<string, unknown> = {};
constructor(private sanitizer: DomSanitizer) {}
get safeContent(): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml((this.config['content'] as string) || '');
}
get styleStr(): string {
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
}
}

View File

@@ -0,0 +1,42 @@
import { Component, Input } from '@angular/core';
import { NgIf } from '@angular/common';
@Component({
selector: 'app-video-renderer',
standalone: true,
imports: [NgIf],
template: `
<div *ngIf="isEmbed" [style]="styleStr" class="aspect-video">
<iframe [src]="embedUrl" class="w-full h-full border-0" allowfullscreen></iframe>
</div>
<video *ngIf="!isEmbed" [src]="config['src']" [style]="styleStr"
[autoplay]="config['autoplay'] || false"
[controls]="config['controls'] !== false" class="max-w-full"></video>
`,
})
export class VideoRenderer {
@Input() config: Record<string, unknown> = {};
@Input() styles: Record<string, unknown> = {};
get isEmbed(): boolean {
const src = (this.config['src'] as string) || '';
return src.includes('youtube') || src.includes('vimeo');
}
get embedUrl(): string {
const src = (this.config['src'] as string) || '';
if (src.includes('youtube') || src.includes('youtu.be')) {
const match = src.match(/(?:v=|youtu\.be\/)([\w-]+)/);
return match ? `https://www.youtube.com/embed/${match[1]}` : src;
}
if (src.includes('vimeo')) {
const match = src.match(/vimeo\.com\/(\d+)/);
return match ? `https://player.vimeo.com/video/${match[1]}` : src;
}
return src;
}
get styleStr(): string {
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
}
}

View File

@@ -0,0 +1,60 @@
import { Injectable, OnDestroy, inject } from '@angular/core';
import { Subject, Subscription, debounceTime, tap, switchMap } from 'rxjs';
import { BuilderApiService } from '../builder-api.service';
import { BuilderStateService } from './builder-state.service';
@Injectable({ providedIn: 'root' })
export class AutoSaveService implements OnDestroy {
private api = inject(BuilderApiService);
private state = inject(BuilderStateService);
private saveSubject = new Subject<void>();
private subscription: Subscription;
saveStatus = new Subject<'saving' | 'saved' | 'idle'>();
private currentStatus: 'saving' | 'saved' | 'idle' = 'idle';
constructor() {
this.subscription = this.saveSubject.pipe(
debounceTime(2000),
tap(() => {
this.state.setSaving(true);
this.setStatus('saving');
}),
switchMap(() => this.performSave()),
).subscribe({
next: () => {
this.state.setSaving(false);
this.setStatus('saved');
},
error: () => {
this.state.setSaving(false);
this.setStatus('idle');
},
});
}
triggerSave(): void {
this.saveSubject.next();
}
private performSave(): Promise<void> {
const siteId = this.state.siteId;
if (!siteId) return Promise.resolve();
const pages = this.state.pages;
const saves = pages.map(page => {
const req = this.state.toSaveRequest(page.id);
return this.api.saveComponents(siteId, req.pageId, { components: req.components }).toPromise();
});
return Promise.all(saves).then(() => {});
}
private setStatus(status: 'saving' | 'saved' | 'idle'): void {
this.currentStatus = status;
this.saveStatus.next(status);
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
this.saveSubject.complete();
this.saveStatus.complete();
}
}

View File

@@ -0,0 +1,225 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { ComponentItem, ComponentType, getDefaultConfig, getDefaultStyles, PageWithComponentsResponse } from '../../core/models/component';
interface Snapshot {
pages: PageState[];
selectedPageId: string | null;
selectedComponentId: string | null;
}
interface PageState {
id: string;
title: string;
slug: string;
components: ComponentItem[];
}
interface HistoryEntry {
snapshot: Snapshot;
label: string;
}
const MAX_HISTORY = 50;
@Injectable({ providedIn: 'root' })
export class BuilderStateService {
private pagesSubject = new BehaviorSubject<PageState[]>([]);
private selectedPageIdSubject = new BehaviorSubject<string | null>(null);
private selectedComponentIdSubject = new BehaviorSubject<string | null>(null);
private themeConfigSubject = new BehaviorSubject<Record<string, unknown>>({});
private loadingSubject = new BehaviorSubject<boolean>(false);
private savingSubject = new BehaviorSubject<boolean>(false);
private siteIdSubject = new BehaviorSubject<string | null>(null);
pages$ = this.pagesSubject.asObservable();
selectedPageId$ = this.selectedPageIdSubject.asObservable();
selectedComponentId$ = this.selectedComponentIdSubject.asObservable();
themeConfig$ = this.themeConfigSubject.asObservable();
loading$ = this.loadingSubject.asObservable();
saving$ = this.savingSubject.asObservable();
private undoStack: HistoryEntry[] = [];
private redoStack: HistoryEntry[] = [];
private skipHistory = false;
get pages(): PageState[] { return this.pagesSubject.value; }
get selectedPageId(): string | null { return this.selectedPageIdSubject.value; }
get selectedComponentId(): string | null { return this.selectedComponentIdSubject.value; }
get selectedPage(): PageState | null {
return this.pages.find(p => p.id === this.selectedPageId) ?? null;
}
get selectedComponent(): ComponentItem | null {
const page = this.selectedPage;
if (!page || !this.selectedComponentId) return null;
return page.components.find(c => c.id === this.selectedComponentId) ?? null;
}
get themeConfig(): Record<string, unknown> { return this.themeConfigSubject.value; }
get siteId(): string | null { return this.siteIdSubject.value; }
init(siteId: string, pages: PageWithComponentsResponse[]): void {
this.siteIdSubject.next(siteId);
const pageStates = pages.map(p => ({
id: p.id,
title: p.title,
slug: p.slug,
components: p.components.map(c => ({
id: c.id,
pageId: c.pageId,
type: c.type,
config: c.config,
styles: c.styles,
orderIndex: c.orderIndex,
})),
}));
this.pagesSubject.next(pageStates);
if (pageStates.length > 0) {
this.selectedPageIdSubject.next(pageStates[0].id);
}
this.selectedComponentIdSubject.next(null);
this.undoStack = [];
this.redoStack = [];
}
setThemeConfig(theme: Record<string, unknown>): void {
this.themeConfigSubject.next(theme);
}
updateThemeConfig(partial: Record<string, unknown>): void {
const current = { ...this.themeConfigSubject.value, ...partial };
this.themeConfigSubject.next(current);
}
selectPage(pageId: string): void {
this.selectedPageIdSubject.next(pageId);
this.selectedComponentIdSubject.next(null);
}
selectComponent(componentId: string | null): void {
this.selectedComponentIdSubject.next(componentId);
}
addComponent(type: ComponentType, pageId?: string): void {
this.pushHistory('Add ' + type);
const targetPageId = pageId || this.selectedPageId;
if (!targetPageId) return;
const pages = this.pagesSubject.value.map(p => {
if (p.id !== targetPageId) return p;
const newComponent: ComponentItem = {
id: crypto.randomUUID(),
type,
config: getDefaultConfig(type),
styles: getDefaultStyles(type),
orderIndex: p.components.length,
};
return { ...p, components: [...p.components, newComponent] };
});
this.pagesSubject.next(pages);
}
removeComponent(componentId: string): void {
this.pushHistory('Remove component');
const pages = this.pagesSubject.value.map(p => ({
...p,
components: p.components.filter(c => c.id !== componentId).map((c, i) => ({ ...c, orderIndex: i })),
}));
this.pagesSubject.next(pages);
if (this.selectedComponentId === componentId) {
this.selectedComponentIdSubject.next(null);
}
}
updateComponent(componentId: string, changes: Partial<ComponentItem>): void {
if (!this.skipHistory) this.pushHistory('Update component');
const pages = this.pagesSubject.value.map(p => ({
...p,
components: p.components.map(c => c.id === componentId ? { ...c, ...changes } : c),
}));
this.pagesSubject.next(pages);
}
reorderComponents(pageId: string, components: ComponentItem[]): void {
this.pushHistory('Reorder');
const reordered = components.map((c, i) => ({ ...c, orderIndex: i }));
const pages = this.pagesSubject.value.map(p =>
p.id === pageId ? { ...p, components: reordered } : p
);
this.pagesSubject.next(pages);
}
moveComponentToPage(componentId: string, targetPageId: string): void {
this.pushHistory('Move component');
const component = this.selectedComponent;
if (!component) return;
const pages = this.pagesSubject.value.map(p => {
if (p.id === this.selectedPageId) {
return { ...p, components: p.components.filter(c => c.id !== componentId) };
}
if (p.id === targetPageId) {
return { ...p, components: [...p.components, { ...component, pageId: targetPageId, orderIndex: p.components.length }] };
}
return p;
});
this.pagesSubject.next(pages);
this.selectedComponentIdSubject.next(null);
}
pushHistory(label: string): void {
const snapshot = this.takeSnapshot();
this.undoStack.push({ snapshot, label });
if (this.undoStack.length > MAX_HISTORY) this.undoStack.shift();
this.redoStack = [];
}
undo(): void {
if (this.undoStack.length === 0) return;
const current = this.takeSnapshot();
this.redoStack.push({ snapshot: current, label: 'Redo' });
const entry = this.undoStack.pop()!;
this.restoreSnapshot(entry.snapshot);
}
redo(): void {
if (this.redoStack.length === 0) return;
const current = this.takeSnapshot();
this.undoStack.push({ snapshot: current, label: 'Undo' });
const entry = this.redoStack.pop()!;
this.restoreSnapshot(entry.snapshot);
}
canUndo(): boolean { return this.undoStack.length > 0; }
canRedo(): boolean { return this.redoStack.length > 0; }
private takeSnapshot(): Snapshot {
return {
pages: JSON.parse(JSON.stringify(this.pagesSubject.value)),
selectedPageId: this.selectedPageIdSubject.value,
selectedComponentId: this.selectedComponentIdSubject.value,
};
}
private restoreSnapshot(snapshot: Snapshot): void {
this.skipHistory = true;
this.pagesSubject.next(snapshot.pages);
this.selectedPageIdSubject.next(snapshot.selectedPageId);
this.selectedComponentIdSubject.next(snapshot.selectedComponentId);
this.skipHistory = false;
}
setLoading(loading: boolean): void { this.loadingSubject.next(loading); }
setSaving(saving: boolean): void { this.savingSubject.next(saving); }
toSaveRequest(pageId: string): { pageId: string; components: { id?: string; type: ComponentType; config: Record<string, unknown>; styles: Record<string, unknown>; orderIndex: number }[] } {
const page = this.pages.find(p => p.id === pageId);
return {
pageId,
components: (page?.components ?? []).map(c => ({
id: c.id,
type: c.type,
config: c.config,
styles: c.styles,
orderIndex: c.orderIndex,
})),
};
}
}

View File

@@ -0,0 +1,136 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { NgFor, NgIf, KeyValuePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button';
const DEFAULT_THEME: Record<string, any> = {
primaryColor: '#1976d2',
secondaryColor: '#dc004e',
backgroundColor: '#ffffff',
textColor: '#1a1a1a',
headingFont: 'Roboto',
bodyFont: 'Roboto',
borderRadius: '4px',
spacing: '16px',
};
const FONTS = ['Roboto', 'Inter', 'Open Sans', 'Lato', 'Montserrat', 'Poppins', 'Playfair Display', 'Source Sans Pro'];
@Component({
selector: 'app-theme-editor',
standalone: true,
imports: [
NgFor, NgIf, KeyValuePipe, FormsModule,
MatExpansionModule, MatFormFieldModule, MatInputModule, MatSelectModule,
MatButtonModule,
],
template: `
<div class="p-3 h-full overflow-y-auto">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">Theme</h3>
<mat-accordion>
<mat-expansion-panel expanded>
<mat-expansion-panel-header>
<mat-panel-title class="text-sm font-medium">Colors</mat-panel-title>
</mat-expansion-panel-header>
<div class="space-y-3">
<div class="flex items-center gap-3">
<label class="text-xs text-gray-500 w-24">Primary</label>
<div class="flex items-center gap-2 flex-1">
<input type="color" [value]="theme['primaryColor'] || ''"
(input)="updateKey('primaryColor', $any($event.target).value)"
class="w-8 h-8 rounded cursor-pointer border" />
<input class="flex-1 border rounded px-2 py-1 text-xs font-mono"
[value]="theme['primaryColor'] || ''"
(input)="updateKey('primaryColor', $any($event.target).value)" />
</div>
</div>
<div class="flex items-center gap-3">
<label class="text-xs text-gray-500 w-24">Secondary</label>
<div class="flex items-center gap-2 flex-1">
<input type="color" [value]="theme['secondaryColor'] || ''"
(input)="updateKey('secondaryColor', $any($event.target).value)"
class="w-8 h-8 rounded cursor-pointer border" />
<input class="flex-1 border rounded px-2 py-1 text-xs font-mono"
[value]="theme['secondaryColor'] || ''"
(input)="updateKey('secondaryColor', $any($event.target).value)" />
</div>
</div>
<div class="flex items-center gap-3">
<label class="text-xs text-gray-500 w-24">Background</label>
<div class="flex items-center gap-2 flex-1">
<input type="color" [value]="theme['backgroundColor'] || ''"
(input)="updateKey('backgroundColor', $any($event.target).value)"
class="w-8 h-8 rounded cursor-pointer border" />
<input class="flex-1 border rounded px-2 py-1 text-xs font-mono"
[value]="theme['backgroundColor'] || ''"
(input)="updateKey('backgroundColor', $any($event.target).value)" />
</div>
</div>
<div class="flex items-center gap-3">
<label class="text-xs text-gray-500 w-24">Text</label>
<div class="flex items-center gap-2 flex-1">
<input type="color" [value]="theme['textColor'] || ''"
(input)="updateKey('textColor', $any($event.target).value)"
class="w-8 h-8 rounded cursor-pointer border" />
<input class="flex-1 border rounded px-2 py-1 text-xs font-mono"
[value]="theme['textColor'] || ''"
(input)="updateKey('textColor', $any($event.target).value)" />
</div>
</div>
</div>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title class="text-sm font-medium">Typography</mat-panel-title>
</mat-expansion-panel-header>
<div class="space-y-3">
<mat-form-field appearance="outline">
<mat-label>Heading Font</mat-label>
<mat-select [value]="theme['headingFont']" (selectionChange)="updateKey('headingFont', $event.value)">
<mat-option *ngFor="let font of fonts" [value]="font">{{ font }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Body Font</mat-label>
<mat-select [value]="theme['bodyFont']" (selectionChange)="updateKey('bodyFont', $event.value)">
<mat-option *ngFor="let font of fonts" [value]="font">{{ font }}</mat-option>
</mat-select>
</mat-form-field>
</div>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title class="text-sm font-medium">Layout</mat-panel-title>
</mat-expansion-panel-header>
<div class="space-y-3">
<mat-form-field appearance="outline">
<mat-label>Border Radius</mat-label>
<input matInput [value]="theme['borderRadius']" (input)="updateKey('borderRadius', $any($event.target).value)" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Spacing</mat-label>
<input matInput [value]="theme['spacing']" (input)="updateKey('spacing', $any($event.target).value)" />
</mat-form-field>
</div>
</mat-expansion-panel>
</mat-accordion>
</div>
`,
})
export class ThemeEditorComponent {
@Input() theme: Record<string, unknown> = { ...DEFAULT_THEME };
@Output() themeChange = new EventEmitter<Record<string, unknown>>();
fonts = FONTS;
updateKey(key: string, value: string): void {
this.theme = { ...this.theme, [key]: value };
this.themeChange.emit(this.theme);
}
}

View File

@@ -0,0 +1,105 @@
export type ComponentType =
| 'HEADING' | 'TEXT' | 'IMAGE' | 'BUTTON' | 'DIVIDER'
| 'GALLERY' | 'VIDEO' | 'CONTACT_FORM' | 'MAP' | 'CUSTOM_HTML';
export interface ComponentItem {
id: string;
pageId?: string;
type: ComponentType;
config: Record<string, unknown>;
styles: Record<string, unknown>;
orderIndex: number;
}
export interface ComponentResponse {
id: string;
pageId: string;
type: ComponentType;
config: Record<string, unknown>;
styles: Record<string, unknown>;
orderIndex: number;
}
export interface PageWithComponentsResponse {
id: string;
siteId: string;
title: string;
slug: string;
seoTitle: string;
seoDescription: string;
orderIndex: number;
createdAt: string;
components: ComponentResponse[];
}
export interface SaveComponentsRequest {
components: {
id?: string;
type: ComponentType;
config: Record<string, unknown>;
styles: Record<string, unknown>;
orderIndex: number;
}[];
}
export function getDefaultConfig(type: ComponentType): Record<string, unknown> {
switch (type) {
case 'HEADING': return { text: 'Heading', level: 'h2' };
case 'TEXT': return { content: 'Text content here...' };
case 'IMAGE': return { src: '', alt: 'Image', linkTo: '' };
case 'BUTTON': return { text: 'Click Me', link: '#', variant: 'filled' };
case 'DIVIDER': return { style: 'solid' };
case 'GALLERY': return { images: [] };
case 'VIDEO': return { src: '', autoplay: false, controls: true };
case 'CONTACT_FORM': return { fields: ['name', 'email', 'message'], submitText: 'Send', emailTo: '' };
case 'MAP': return { address: 'New York, NY', zoom: 14 };
case 'CUSTOM_HTML': return { html: '<p>Custom HTML</p>' };
}
}
export function getDefaultStyles(type: ComponentType): Record<string, unknown> {
switch (type) {
case 'HEADING': return { color: '#1a1a1a', fontSize: '2rem', fontWeight: '700', textAlign: 'left', margin: '0 0 1rem' };
case 'TEXT': return { color: '#4a4a4a', fontSize: '1rem', lineHeight: '1.6', textAlign: 'left' };
case 'IMAGE': return { width: '100%', height: 'auto', borderRadius: '0', objectFit: 'cover' };
case 'BUTTON': return { backgroundColor: '#1976d2', color: '#ffffff', borderRadius: '4px', padding: '0.75rem 1.5rem' };
case 'DIVIDER': return { color: '#e0e0e0', thickness: '1px', margin: '1.5rem 0' };
case 'GALLERY': return { columns: '3', gap: '8px', borderRadius: '4px' };
case 'VIDEO': return { width: '100%', maxWidth: '800px', borderRadius: '4px' };
case 'CONTACT_FORM': return { backgroundColor: '#f5f5f5', padding: '2rem', borderRadius: '8px' };
case 'MAP': return { width: '100%', height: '400px', borderRadius: '4px' };
case 'CUSTOM_HTML': return { padding: '0', margin: '0' };
}
}
export function getComponentLabel(type: ComponentType): string {
const labels: Record<ComponentType, string> = {
HEADING: 'Heading',
TEXT: 'Text',
IMAGE: 'Image',
BUTTON: 'Button',
DIVIDER: 'Divider',
GALLERY: 'Gallery',
VIDEO: 'Video',
CONTACT_FORM: 'Contact Form',
MAP: 'Map',
CUSTOM_HTML: 'Custom HTML',
};
return labels[type];
}
export function getComponentIcon(type: ComponentType): string {
const icons: Record<ComponentType, string> = {
HEADING: 'title',
TEXT: 'text_fields',
IMAGE: 'image',
BUTTON: 'smart_button',
DIVIDER: 'horizontal_rule',
GALLERY: 'photo_library',
VIDEO: 'videocam',
CONTACT_FORM: 'contact_mail',
MAP: 'map',
CUSTOM_HTML: 'code',
};
return icons[type];
}

View File

@@ -58,7 +58,7 @@ import { DeleteSiteDialog } from './delete-site-dialog/delete-site-dialog';
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<mat-card *ngFor="let site of sites" class="cursor-pointer hover:shadow-lg transition-shadow">
<mat-card *ngFor="let site of sites" class="cursor-pointer hover:shadow-lg transition-shadow" (click)="openBuilder(site)">
<mat-card-header>
<mat-card-title>{{ site.name }}</mat-card-title>
<mat-card-subtitle *ngIf="site.subdomain">{{ site.subdomain }}.indie.com</mat-card-subtitle>
@@ -130,6 +130,10 @@ export class DashboardComponent implements OnInit {
});
}
openBuilder(site: SiteResponse): void {
this.router.navigate(['/builder', site.id]);
}
logout(): void {
this.authService.logout().subscribe({
next: () => this.router.navigate(['/login']),