generate site using llm prompt

This commit is contained in:
Krrish Ghimire
2026-07-02 01:15:16 +05:45
parent 18fed113f9
commit 3ccbd2ebec
75 changed files with 2220 additions and 3173 deletions

View File

@@ -21,8 +21,8 @@ export const routes: Routes = [
canActivate: [AuthGuard],
},
{
path: 'builder/:siteId',
loadComponent: () => import('./builder/builder.component').then(m => m.BuilderComponent),
path: 'llm/:siteId',
loadComponent: () => import('./llm-workspace/llm-workspace.component').then(m => m.LlmWorkspaceComponent),
canActivate: [AuthGuard],
},
{ path: '**', redirectTo: '/dashboard' },

View File

@@ -1,25 +0,0 @@
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

@@ -1,265 +0,0 @@
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

@@ -1,133 +0,0 @@
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

@@ -1,81 +0,0 @@
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

@@ -1,74 +0,0 @@
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

@@ -1,194 +0,0 @@
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

@@ -1,270 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,37 +0,0 @@
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

@@ -1,22 +0,0 @@
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

@@ -1,18 +0,0 @@
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

@@ -1,33 +0,0 @@
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

@@ -1,23 +0,0 @@
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

@@ -1,21 +0,0 @@
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

@@ -1,75 +0,0 @@
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

@@ -1,22 +0,0 @@
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

@@ -1,42 +0,0 @@
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

@@ -1,60 +0,0 @@
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

@@ -1,225 +0,0 @@
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

@@ -1,136 +0,0 @@
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

@@ -11,37 +11,6 @@ export interface ComponentItem {
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' };
@@ -72,34 +41,36 @@ export function getDefaultStyles(type: ComponentType): Record<string, unknown> {
}
}
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];
const LABELS: Record<string, 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',
};
const ICONS: Record<string, 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',
};
export function getComponentLabel(type: string): string {
return LABELS[type] || LABELS[type.toUpperCase()] || 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];
export function getComponentIcon(type: string): string {
return ICONS[type] || ICONS[type.toUpperCase()] || 'code';
}

View File

@@ -0,0 +1,76 @@
import { ComponentType } from './component';
export interface LLMGenerateRequest {
prompt: string;
siteId: string;
references?: Reference[];
}
export interface LLMRefineRequest {
prompt: string;
siteId: string;
currentStructure: SiteStructure;
references?: Reference[];
}
export interface Reference {
type: 'URL' | 'IMAGE';
url: string;
altText: string;
}
export interface SiteStructure {
theme: ThemeConfig;
pages: PageStructure[];
}
export interface ThemeConfig {
colors: Record<string, string>;
fonts: Record<string, string>;
spacing: Record<string, string>;
}
export interface PageStructure {
id?: string;
title: string;
slug: string;
seoTitle?: string;
seoDescription?: string;
orderIndex: number;
components: ComponentStructure[];
}
export interface ComponentStructure {
id?: string;
type: ComponentType;
orderIndex: number;
config: Record<string, unknown>;
styles: Record<string, unknown>;
}
export interface TokenUsage {
prompt: number;
completion: number;
total: number;
}
export interface LLMSession {
id?: string;
siteId: string;
prompt: string;
generatedStructure: SiteStructure | null;
refinedPrompt: string | null;
acceptedAt: string | null;
createdAt: string;
}
export interface GenerationVersion {
id: string;
siteId: string;
userId: string;
versionNumber: number;
prompt: string;
references: Reference[];
fullStructure: string;
createdAt: string;
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { LLMGenerateRequest, LLMRefineRequest, SiteStructure, GenerationVersion } from '../models/llm';
@Injectable({ providedIn: 'root' })
export class LlmApiService {
constructor(private http: HttpClient) {}
generate(request: LLMGenerateRequest): Observable<SiteStructure> {
return this.http.post<SiteStructure>('/api/llm/generate', request);
}
refine(request: LLMRefineRequest): Observable<SiteStructure> {
return this.http.post<SiteStructure>('/api/llm/refine', request);
}
getVersions(siteId: string): Observable<GenerationVersion[]> {
return this.http.get<GenerationVersion[]>(`/api/llm/versions/${siteId}`);
}
restoreVersion(versionId: string): Observable<SiteStructure> {
return this.http.post<SiteStructure>(`/api/llm/versions/${versionId}/restore`, {});
}
deleteVersion(versionId: string): Observable<void> {
return this.http.delete<void>(`/api/llm/versions/${versionId}`);
}
}

View File

@@ -131,7 +131,7 @@ export class DashboardComponent implements OnInit {
}
openBuilder(site: SiteResponse): void {
this.router.navigate(['/builder', site.id]);
this.router.navigate(['/llm', site.id]);
}
logout(): void {

View File

@@ -0,0 +1,24 @@
import { Component, inject } from '@angular/core';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-delete-version-dialog',
standalone: true,
imports: [MatDialogModule, MatButtonModule],
template: `
<h2 mat-dialog-title>Delete Version</h2>
<mat-dialog-content>
<p>Are you sure you want to delete version <strong>v{{ data.versionNumber }}</strong>?</p>
<p class="text-red-600 text-sm mt-2">This action cannot be undone.</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button mat-flat-button color="warn" [mat-dialog-close]="true">Delete</button>
</mat-dialog-actions>
`,
})
export class DeleteVersionDialog {
dialogRef = inject(MatDialogRef<DeleteVersionDialog>);
data = inject<{ versionNumber: number }>(MAT_DIALOG_DATA);
}

View File

@@ -0,0 +1,102 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { SiteStructure, GenerationVersion } from '../core/models/llm';
export interface PromptEntry {
originalPrompt: string;
refinedPrompt: string | null;
result: SiteStructure | null;
timestamp: Date;
}
@Injectable({ providedIn: 'root' })
export class LlmSessionService {
private currentPromptSubject = new BehaviorSubject<string>('');
private currentResultSubject = new BehaviorSubject<SiteStructure | null>(null);
private historySubject = new BehaviorSubject<PromptEntry[]>([]);
private generatingSubject = new BehaviorSubject<boolean>(false);
private errorSubject = new BehaviorSubject<string | null>(null);
private versionsSubject = new BehaviorSubject<GenerationVersion[]>([]);
private currentVersionIdSubject = new BehaviorSubject<string | null>(null);
private loadingVersionsSubject = new BehaviorSubject<boolean>(false);
currentPrompt$ = this.currentPromptSubject.asObservable();
currentResult$ = this.currentResultSubject.asObservable();
history$ = this.historySubject.asObservable();
generating$ = this.generatingSubject.asObservable();
error$ = this.errorSubject.asObservable();
versions$ = this.versionsSubject.asObservable();
currentVersionId$ = this.currentVersionIdSubject.asObservable();
loadingVersions$ = this.loadingVersionsSubject.asObservable();
get currentPrompt(): string { return this.currentPromptSubject.value; }
get currentResult(): SiteStructure | null { return this.currentResultSubject.value; }
get generating(): boolean { return this.generatingSubject.value; }
get error(): string | null { return this.errorSubject.value; }
get versions(): GenerationVersion[] { return this.versionsSubject.value; }
get currentVersionId(): string | null { return this.currentVersionIdSubject.value; }
setPrompt(prompt: string): void {
this.currentPromptSubject.next(prompt);
this.errorSubject.next(null);
}
setResult(result: SiteStructure): void {
this.currentResultSubject.next(result);
const entry: PromptEntry = {
originalPrompt: this.currentPromptSubject.value,
refinedPrompt: null,
result,
timestamp: new Date(),
};
const history = this.historySubject.value;
this.historySubject.next([...history, entry]);
this.generatingSubject.next(false);
this.errorSubject.next(null);
}
clearResult(): void {
this.currentResultSubject.next(null);
}
setGenerating(generating: boolean): void {
this.generatingSubject.next(generating);
}
setError(error: string): void {
this.errorSubject.next(error);
this.generatingSubject.next(false);
}
clearError(): void {
this.errorSubject.next(null);
}
clearHistory(): void {
this.historySubject.next([]);
this.currentResultSubject.next(null);
this.currentPromptSubject.next('');
this.currentVersionIdSubject.next(null);
this.errorSubject.next(null);
}
setVersions(versions: GenerationVersion[]): void {
this.versionsSubject.next(versions);
this.loadingVersionsSubject.next(false);
}
setLoadingVersions(loading: boolean): void {
this.loadingVersionsSubject.next(loading);
}
setCurrentVersionId(id: string | null): void {
this.currentVersionIdSubject.next(id);
}
loadVersionData(structure: SiteStructure, versionId: string): void {
this.currentResultSubject.next(structure);
this.currentVersionIdSubject.next(versionId);
this.generatingSubject.next(false);
this.errorSubject.next(null);
}
}

View File

@@ -0,0 +1,459 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { NgIf, NgFor, AsyncPipe, KeyValuePipe, DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { LlmApiService } from '../core/services/llm-api.service';
import { LlmSessionService } from './llm-session.service';
import { SiteStructure, GenerationVersion } from '../core/models/llm';
import { getComponentLabel } from '../core/models/component';
import { PreviewDialogComponent } from './preview-dialog/preview-dialog.component';
import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dialog';
@Component({
selector: 'app-llm-workspace',
standalone: true,
imports: [
NgIf, NgFor, AsyncPipe, KeyValuePipe, DatePipe, FormsModule, RouterModule,
MatToolbarModule, MatButtonModule, MatIconModule,
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule,
MatSnackBarModule, MatCardModule,
MatDividerModule, MatExpansionModule, MatDialogModule,
],
template: `
<div class="h-screen flex flex-col bg-gray-50">
<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)="goToDashboard()">
<mat-icon>arrow_back</mat-icon>
</button>
<span class="text-lg font-bold">AI Site Generator</span>
</div>
</mat-toolbar>
<div class="flex-1 overflow-y-auto">
<div class="px-6 py-6 space-y-6">
<mat-card class="p-4">
<mat-card-content class="!p-0">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Describe your website</mat-label>
<textarea
matInput
rows="5"
placeholder="e.g. A personal portfolio for a photographer with a gallery page, about section, and contact form. Dark theme with accent gold."
[(ngModel)]="prompt"
[disabled]="(session.generating$ | async) === true"
></textarea>
</mat-form-field>
<div class="flex items-center justify-between mt-2">
<span class="text-xs text-gray-400">
Describe the pages, layout, theme, and content you want.
</span>
<button
mat-raised-button
color="primary"
[disabled]="!prompt.trim() || (session.generating$ | async)"
(click)="generate()"
>
<mat-icon>auto_awesome</mat-icon>
Generate
</button>
</div>
</mat-card-content>
</mat-card>
<div *ngIf="session.generating$ | async" class="flex flex-col items-center justify-center py-12 space-y-4">
<mat-spinner diameter="48"></mat-spinner>
<div class="text-center">
<p class="text-lg font-medium text-gray-700">Generating your site...</p>
<p class="text-sm text-gray-400">This usually takes 15-30 seconds</p>
</div>
</div>
<div *ngIf="session.error$ | async as error" class="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<mat-icon class="text-red-500 mt-0.5">error_outline</mat-icon>
<div class="flex-1">
<p class="text-sm font-medium text-red-800">Generation failed</p>
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
<button mat-button color="warn" class="mt-2" (click)="retry()">Try Again</button>
</div>
</div>
<div *ngIf="session.currentResult$ | async as result" class="flex gap-6">
<div class="w-96 shrink-0">
<mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">History</h4>
<span class="text-xs text-gray-400">{{ versions.length }} version{{ versions.length !== 1 ? 's' : '' }}</span>
</div>
<div *ngIf="session.loadingVersions$ | async" class="flex justify-center py-4">
<mat-spinner diameter="20"></mat-spinner>
</div>
<div class="space-y-1 max-h-[calc(100vh-20rem)] overflow-y-auto overflow-x-hidden">
<div
*ngFor="let v of versions"
(click)="switchToVersion(v)"
class="w-full p-3 rounded-lg border transition-colors duration-150 flex flex-col gap-0.5 cursor-pointer group min-w-0"
[class.border-blue-500]="isCurrentVersion(v.id)"
[class.bg-blue-50]="isCurrentVersion(v.id)"
[class.border-gray-200]="!isCurrentVersion(v.id)"
[class.hover:bg-gray-50]="!isCurrentVersion(v.id)"
>
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-xs font-bold text-gray-900 shrink-0">v{{ v.versionNumber }}</span>
<span class="text-[10px] text-gray-400 shrink-0">{{ v.createdAt | date:'short' }}</span>
<button
mat-icon-button
class="!w-6 !h-6 !min-w-0 !p-0 !leading-none opacity-40 hover:opacity-100 transition-opacity shrink-0"
(click)="$event.stopPropagation(); deleteVersion(v)"
title="Delete version"
>
<mat-icon class="!text-sm !w-4 !h-4 text-red-500">delete</mat-icon>
</button>
</div>
<p class="text-xs text-gray-600 truncate">{{ truncatePrompt(v.prompt, 50) }}</p>
</div>
<p *ngIf="versions.length === 0 && !(session.loadingVersions$ | async)" class="text-xs text-gray-400 py-2 text-center">
No versions yet
</p>
</div>
</mat-card-content>
</mat-card>
</div>
<div class="flex-1 min-w-0 space-y-4">
<mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">Generated Site Structure</h3>
<p class="text-sm text-gray-500">
{{ result.pages.length }} page{{ result.pages.length !== 1 ? 's' : '' }}
&middot;
{{ totalComponents(result) }} component{{ totalComponents(result) !== 1 ? 's' : '' }}
</p>
</div>
<div class="flex gap-2">
<button mat-stroked-button color="primary" (click)="openPreview()">
<mat-icon>visibility</mat-icon>
Preview
</button>
<button mat-stroked-button color="primary" (click)="showRefine = !showRefine">
<mat-icon>refresh</mat-icon>
Refine
</button>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card *ngIf="currentVersionPrompt" class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">Prompt</h4>
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{ currentVersionPrompt }}</p>
</mat-card-content>
</mat-card>
<mat-card *ngIf="showRefine" class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Refinement instructions</mat-label>
<textarea
matInput
rows="3"
placeholder="e.g. Make it more minimal, change the primary color to teal, add a testimonials section..."
[(ngModel)]="refinePrompt"
[disabled]="(session.generating$ | async) === true"
></textarea>
</mat-form-field>
<div class="flex justify-end mt-2">
<button
mat-raised-button
color="accent"
[disabled]="!refinePrompt.trim() || (session.generating$ | async)"
(click)="refine()"
>
<mat-icon>auto_fix_high</mat-icon>
Refine
</button>
</div>
</mat-card-content>
</mat-card>
<mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Theme</h4>
<div class="flex flex-wrap gap-4">
<div *ngIf="result.theme.colors" class="space-y-1">
<span class="text-xs text-gray-500 block mb-1">Colors</span>
<div *ngFor="let item of result.theme.colors | keyvalue" class="flex items-center gap-2">
<span class="w-5 h-5 rounded-full border" [style.background]="item.value"></span>
<span class="text-xs text-gray-600">{{ item.key }}:</span>
<span class="text-xs font-mono text-gray-500">{{ item.value }}</span>
</div>
</div>
<div *ngIf="result.theme.fonts" class="space-y-1">
<span class="text-xs text-gray-500 block mb-1">Fonts</span>
<div *ngFor="let item of result.theme.fonts | keyvalue" class="flex items-center gap-2 text-xs text-gray-600">
<span>{{ item.key }}:</span>
<span class="font-mono text-gray-500">{{ item.value }}</span>
</div>
</div>
<div *ngIf="result.theme.spacing" class="space-y-1">
<span class="text-xs text-gray-500 block mb-1">Spacing</span>
<div *ngFor="let item of result.theme.spacing | keyvalue" class="flex items-center gap-2 text-xs text-gray-600">
<span>{{ item.key }}:</span>
<span class="font-mono text-gray-500">{{ item.value }}</span>
</div>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-accordion [multi]="true">
<mat-expansion-panel *ngFor="let page of result.pages" class="mb-2">
<mat-expansion-panel-header>
<mat-panel-title class="text-sm font-medium">
{{ page.title }}
</mat-panel-title>
<mat-panel-description class="text-xs text-gray-400">
/{{ page.slug }}
&middot;
{{ page.components.length }} component{{ page.components.length !== 1 ? 's' : '' }}
</mat-panel-description>
</mat-expansion-panel-header>
<div class="space-y-1">
<div *ngFor="let comp of page.components" class="flex items-center gap-2 py-1">
<mat-icon class="text-gray-400 text-lg">circle</mat-icon>
<span class="text-sm text-gray-700">{{ getLabel(comp.type) }}</span>
</div>
</div>
</mat-expansion-panel>
</mat-accordion>
</div>
</div>
<div *ngIf="!(session.generating$ | async) && !(session.currentResult$ | async) && !(session.error$ | async)" class="flex flex-col items-center justify-center py-16 text-gray-400">
<mat-icon class="text-6xl mb-4" style="width: auto; height: auto; font-size: 4rem;">auto_awesome</mat-icon>
<h3 class="text-xl font-medium text-gray-500 mb-2">Describe your dream site</h3>
<p class="text-sm text-gray-400 text-center max-w-md">
Tell the AI what kind of website you want — pages, style, content — and it will generate a complete site structure ready for you to customize.
</p>
</div>
</div>
</div>
</div>
`,
})
export class LlmWorkspaceComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private api = inject(LlmApiService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
session = inject(LlmSessionService);
prompt = '';
refinePrompt = '';
showRefine = false;
siteId: string | null = null;
versions: GenerationVersion[] = [];
private destroy$ = new Subject<void>();
ngOnInit(): void {
this.siteId = this.route.snapshot.paramMap.get('siteId');
const existingPrompt = this.session.currentPrompt;
if (existingPrompt) {
this.prompt = existingPrompt;
}
this.loadVersions();
}
private loadVersions(): void {
if (!this.siteId) return;
this.session.setLoadingVersions(true);
this.api.getVersions(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (v) => {
this.versions = v;
this.session.setVersions(v);
if (v.length > 0 && !this.session.currentResult) {
this.api.restoreVersion(v[0].id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (structure) => {
this.session.loadVersionData(structure, v[0].id);
},
error: () => this.session.setLoadingVersions(false),
});
} else if (v.length > 0 && !this.session.currentVersionId) {
this.session.setCurrentVersionId(v[0].id);
} else {
this.session.setLoadingVersions(false);
}
},
error: () => this.session.setLoadingVersions(false),
});
}
generate(): void {
if (!this.prompt.trim() || !this.siteId) return;
this.showRefine = false;
this.session.setPrompt(this.prompt);
this.session.setGenerating(true);
this.session.clearError();
this.api.generate({ prompt: this.prompt, siteId: this.siteId })
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (data) => {
this.session.setResult(data);
this.session.setCurrentVersionId(null);
this.loadVersions();
this.snackBar.open('Site generated successfully!', 'Close', { duration: 3000 });
},
error: (err) => {
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
this.session.setError(msg);
},
});
}
refine(): void {
const result = this.session.currentResult;
if (!result || !this.refinePrompt.trim() || !this.siteId) return;
this.session.setGenerating(true);
this.session.clearError();
this.api.refine({
prompt: this.refinePrompt,
siteId: this.siteId,
currentStructure: result,
})
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (data) => {
this.session.setResult(data);
this.session.setCurrentVersionId(null);
this.loadVersions();
this.refinePrompt = '';
this.showRefine = false;
this.snackBar.open('Site refined successfully!', 'Close', { duration: 3000 });
},
error: (err) => {
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
this.session.setError(msg);
},
});
}
deleteVersion(version: GenerationVersion): void {
if (this.versions.length <= 1) {
this.snackBar.open('Cannot delete the last version', 'Close', { duration: 3000 });
return;
}
const ref = this.dialog.open(DeleteVersionDialog, {
width: '400px',
data: { versionNumber: version.versionNumber },
});
ref.afterClosed().subscribe((confirmed) => {
if (!confirmed) return;
this.api.deleteVersion(version.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
if (this.session.currentVersionId === version.id) {
this.session.setCurrentVersionId(null);
}
this.loadVersions();
this.snackBar.open('Version deleted', 'Close', { duration: 2000 });
},
error: () => {
this.snackBar.open('Failed to delete version', 'Close', { duration: 3000 });
},
});
});
}
switchToVersion(version: GenerationVersion): void {
if (version.id === this.session.currentVersionId) return;
this.session.setGenerating(true);
this.api.restoreVersion(version.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (structure) => {
this.session.loadVersionData(structure, version.id);
},
error: (err) => {
const msg = err.error?.error || err.statusText || 'Failed to restore version';
this.session.setError(msg);
},
});
}
openPreview(): void {
const result = this.session.currentResult;
if (!result) return;
this.dialog.open(PreviewDialogComponent, {
data: result,
width: '100vw',
height: '100vh',
maxWidth: '100vw',
maxHeight: '100vh',
panelClass: 'fullscreen-dialog',
autoFocus: false,
});
}
retry(): void {
this.session.clearError();
this.generate();
}
goToDashboard(): void {
this.router.navigate(['/dashboard']);
}
totalComponents(structure: SiteStructure): number {
return structure.pages.reduce((sum, p) => sum + p.components.length, 0);
}
getLabel(type: string): string {
return getComponentLabel(type);
}
isCurrentVersion(versionId: string): boolean {
return this.session.currentVersionId === versionId;
}
get currentVersionPrompt(): string | null {
const version = this.versions.find(v => v.id === this.session.currentVersionId);
return version ? version.prompt : null;
}
truncatePrompt(prompt: string, maxLen: number = 60): string {
return prompt.length > maxLen ? prompt.slice(0, maxLen) + '...' : prompt;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,256 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
import { NgIf, NgFor, AsyncPipe } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { SiteStructure, PageStructure, ComponentStructure } from '../../core/models/llm';
type Device = 'desktop' | 'tablet' | 'mobile';
@Component({
selector: 'app-preview-dialog',
standalone: true,
imports: [
MatDialogModule, MatButtonModule, MatIconModule, MatToolbarModule,
NgIf, NgFor, AsyncPipe,
],
template: `
<div class="h-screen flex flex-col bg-gray-900">
<mat-toolbar class="h-12 flex items-center justify-between px-4 bg-gray-800 text-white">
<div class="flex items-center gap-2">
<button mat-icon-button class="text-white" (click)="close()">
<mat-icon>close</mat-icon>
</button>
<span class="text-sm font-medium">Preview</span>
<span class="text-xs text-gray-400 ml-1">{{ data.pages.length }} pages</span>
</div>
<div class="flex items-center gap-2">
<button mat-icon-button class="text-white" [class.text-blue-400]="device === 'desktop'" (click)="device = 'desktop'">
<mat-icon>desktop_windows</mat-icon>
</button>
<button mat-icon-button class="text-white" [class.text-blue-400]="device === 'tablet'" (click)="device = 'tablet'">
<mat-icon>tablet_mac</mat-icon>
</button>
<button mat-icon-button class="text-white" [class.text-blue-400]="device === 'mobile'" (click)="device = 'mobile'">
<mat-icon>phone_android</mat-icon>
</button>
<span class="w-px h-5 bg-gray-600 mx-1"></span>
<button *ngFor="let page of data.pages; let i = index" mat-stroked-button class="!text-white !border-gray-500 !text-xs"
[class.!bg-blue-600]="selectedPage === i"
(click)="selectPage(i)">
{{ page.title }}
</button>
</div>
</mat-toolbar>
<div class="flex-1 flex items-start justify-center p-4 overflow-auto bg-gray-900">
<div class="bg-white transition-all duration-300"
[style.width]="device === 'desktop' ? '100%' : device === 'tablet' ? '768px' : '375px'"
[style.maxWidth]="device === 'desktop' ? '1280px' : ''"
[style.minHeight]="'calc(100vh - 6rem)'">
<iframe [srcdoc]="sanitizedHtml" class="w-full h-full border-0" style="min-height: calc(100vh - 6rem);"></iframe>
</div>
</div>
</div>
`,
})
export class PreviewDialogComponent {
device: Device = 'desktop';
selectedPage = 0;
constructor(
@Inject(MAT_DIALOG_DATA) public data: SiteStructure,
private dialogRef: MatDialogRef<PreviewDialogComponent>,
private sanitizer: DomSanitizer,
) {}
get sanitizedHtml(): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(this.buildHtml());
}
selectPage(index: number): void {
this.selectedPage = index;
}
close(): void {
this.dialogRef.close();
}
private buildHtml(): string {
const page = this.data.pages[this.selectedPage];
const theme = this.data.theme;
const colors = theme.colors || {};
const fonts = theme.fonts || {};
const spacing = theme.spacing || {};
const cssVars = Object.entries(colors).map(([k, v]) => ` --color-${k}: ${v};`).join('\n')
+ Object.entries(fonts).map(([k, v]) => ` --font-${k}: ${v};`).join('\n')
+ Object.entries(spacing).map(([k, v]) => ` --spacing-${k}: ${v};`).join('\n');
const sections: { bg: string; indices: number[] }[] = [];
for (let i = 0; i < page.components.length; i++) {
const c = page.components[i];
const bg = String((c.styles && c.styles['backgroundColor']) || '');
if (bg || i === 0) {
sections.push({ bg, indices: [i] });
} else {
sections[sections.length - 1].indices.push(i);
}
}
const lastIndices = new Set(sections.map(s => s.indices[s.indices.length - 1]));
const sectionsHtml = sections.map(s => {
const bgStyle = s.bg ? ` style="background:${s.bg}"` : '';
const inner = s.indices.map(idx => this.renderComponent(page.components[idx], idx)).join('\n');
return `<section${bgStyle}>\n <div class="container">\n${inner}\n </div>\n</section>`;
}).join('\n');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${page.title}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@400;500;600;700&family=Merriweather:wght@300;400;700&family=Lato:wght@300;400;700&family=Open+Sans:wght@300;400;600;700&family=DM+Sans:wght@300;400;500;600;700&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
${cssVars}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-body, 'Inter', sans-serif);
color: var(--color-text, #1a1a1a);
background: var(--color-background, #ffffff);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.container { max-width: var(--spacing-containerWidth, 1100px); margin: 0 auto; padding: 0 1.5rem; }
section { padding: var(--spacing-sectionPadding, 3rem) 0; }
${this.buildPageStyles(page.components, lastIndices)}
</style>
</head>
<body>
${sectionsHtml}
</body>
</html>`;
}
private buildPageStyles(components: ComponentStructure[], lastIndices: Set<number>): string {
return components.map((c, i) => {
const s = c.styles || {};
let entries = Object.entries(s).filter(([k]) => k !== 'backgroundColor');
if (lastIndices.has(i)) {
entries = entries.filter(([k]) => k !== 'marginBottom');
}
if (entries.length === 0) return '';
return `.comp-${i} {\n${entries.map(([k, v]) => ` ${this.cssProperty(k)}: ${v};`).join('\n')}\n}`;
}).filter(Boolean).join('\n');
}
private renderComponent(c: ComponentStructure, i?: number): string {
const idx = i ?? 0;
const config = c.config || {};
const type = (c.type || '').toUpperCase();
const cls = `comp-${idx}`;
switch (type) {
case 'HEADING': {
const level = (config['level'] as string) || 'h2';
const text = (config['text'] as string) || 'Heading';
const tag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(level) ? level : 'h2';
return `<${tag} class="${cls}">${this.esc(text)}</${tag}>`;
}
case 'TEXT': {
const content = (config['content'] as string) || '';
return `<div class="${cls}">${content}</div>`;
}
case 'IMAGE': {
const src = (config['src'] as string) || '';
const alt = (config['alt'] as string) || '';
const caption = (config['caption'] as string) || '';
const linkTo = (config['linkTo'] as string) || '';
const img = `<img src="${this.esc(src)}" alt="${this.esc(alt)}" class="${cls}" style="max-width:100%;height:auto">`;
const imgHtml = linkTo ? `<a href="${this.esc(linkTo)}">${img}</a>` : img;
return caption ? `<figure style="margin:0">${imgHtml}<figcaption style="text-align:center;font-size:0.875rem;color:#6b7280;margin-top:0.5rem">${this.esc(caption)}</figcaption></figure>` : imgHtml;
}
case 'BUTTON': {
const text = (config['text'] as string) || 'Button';
const link = (config['link'] as string) || '#';
const variant = (config['variant'] as string) || 'primary';
let btnStyle = 'display:inline-block;text-decoration:none;font-weight:500;font-size:1rem;padding:0.75rem 2rem;border-radius:8px;transition:all 0.2s;cursor:pointer;border:2px solid transparent';
if (variant === 'outline') {
btnStyle += `;background:transparent;color:var(--color-primary,#4f46e5);border-color:var(--color-primary,#4f46e5)`;
} else if (variant === 'secondary') {
btnStyle += `;background:var(--color-secondary,#6366f1);color:#ffffff`;
} else {
btnStyle += `;background:var(--color-primary,#4f46e5);color:#ffffff`;
}
return `<a href="${this.esc(link)}" class="${cls}" style="${btnStyle}">${this.esc(text)}</a>`;
}
case 'DIVIDER': {
const style = (config['style'] as string) || 'solid';
const color = (config['color'] as string) || 'var(--color-text, #d1d5db)';
return `<hr class="${cls}" style="border:none;border-top:1px ${style} ${color}">`;
}
case 'GALLERY': {
const images = (config['images'] as string[]) || [];
const columns = Math.min(Math.max((config['columns'] as number) || 3, 1), 6);
if (images.length === 0) return `<div class="${cls}">Gallery (no images)</div>`;
return `<div class="${cls}" style="display:grid;grid-template-columns:repeat(${columns},1fr);gap:1rem">
${images.map(src => `<img src="${this.esc(src)}" style="width:100%;height:250px;object-fit:cover;border-radius:8px">`).join('\n')}
</div>`;
}
case 'VIDEO': {
const src = (config['src'] as string) || '';
const controls = config['controls'] !== false;
const autoplay = config['autoplay'] === true;
return `<video src="${this.esc(src)}" class="${cls}" ${controls ? 'controls' : ''} ${autoplay ? 'autoplay' : ''} muted style="max-width:100%"></video>`;
}
case 'CONTACT_FORM': {
const fields = (config['fields'] as string[]) || ['name', 'email', 'message'];
const submitText = (config['submitText'] as string) || 'Send';
return `<form class="${cls}">
${fields.map(f => `
<div style="margin-bottom:1rem">
<label style="display:block;font-size:0.875rem;font-weight:600;margin-bottom:0.375rem;color:var(--color-text,#374151)">${this.esc(this.capitalize(f))}</label>
${f === 'message' ? `<textarea rows="4" style="width:100%;padding:0.75rem;border:1px solid #d1d5db;border-radius:8px;font-family:inherit;font-size:0.95rem;transition:border-color 0.2s;outline:none" onfocus="this.style.borderColor='var(--color-primary,#4f46e5)'" onblur="this.style.borderColor='#d1d5db'"></textarea>`
: `<input type="${f === 'email' ? 'email' : 'text'}" style="width:100%;padding:0.75rem;border:1px solid #d1d5db;border-radius:8px;font-family:inherit;font-size:0.95rem;transition:border-color 0.2s;outline:none" onfocus="this.style.borderColor='var(--color-primary,#4f46e5)'" onblur="this.style.borderColor='#d1d5db'">`}
</div>
`).join('')}
<button type="submit" style="padding:0.75rem 2rem;background:var(--color-primary,#4f46e5);color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:1rem;font-weight:500;transition:opacity 0.2s" onmouseover="this.style.opacity='0.9'" onmouseout="this.style.opacity='1'">${this.esc(submitText)}</button>
</form>`;
}
case 'MAP': {
const address = (config['address'] as string) || 'New York, NY';
return `<div class="${cls}" style="display:flex;align-items:center;justify-content:center;background:#f3f4f6">
<span style="color:#9ca3af">Map: ${this.esc(address)}</span>
</div>`;
}
case 'CUSTOM_HTML': {
const html = (config['html'] as string) || '';
return `<div class="${cls}">${html}</div>`;
}
default:
return `<div class="${cls}">[${this.esc(type)}]</div>`;
}
}
private cssProperty(key: string): string {
return key.replace(/([A-Z])/g, '-$1').toLowerCase();
}
private esc(s: string): string {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
private capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
}

View File

@@ -8,3 +8,12 @@ body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
mat-form-field { width: 100%; }
input.mat-input-element { margin-top: 0 !important; }
.fullscreen-dialog .mat-mdc-dialog-container {
padding: 0;
border-radius: 0;
}
.fullscreen-dialog .mat-mdc-dialog-surface {
border-radius: 0;
}