generate site using llm prompt
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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('; ');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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('; ');
|
||||
}
|
||||
}
|
||||
@@ -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;';
|
||||
}
|
||||
}
|
||||
@@ -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('; ');
|
||||
}
|
||||
}
|
||||
@@ -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};`;
|
||||
}
|
||||
}
|
||||
@@ -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};`;
|
||||
}
|
||||
}
|
||||
@@ -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('; ');
|
||||
}
|
||||
}
|
||||
@@ -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('; ');
|
||||
}
|
||||
}
|
||||
@@ -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: '© <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('; ');
|
||||
}
|
||||
}
|
||||
@@ -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('; ');
|
||||
}
|
||||
}
|
||||
@@ -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('; ');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
76
frontend/src/app/core/models/llm.ts
Normal file
76
frontend/src/app/core/models/llm.ts
Normal 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;
|
||||
}
|
||||
29
frontend/src/app/core/services/llm-api.service.ts
Normal file
29
frontend/src/app/core/services/llm-api.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
102
frontend/src/app/llm-workspace/llm-session.service.ts
Normal file
102
frontend/src/app/llm-workspace/llm-session.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
459
frontend/src/app/llm-workspace/llm-workspace.component.ts
Normal file
459
frontend/src/app/llm-workspace/llm-workspace.component.ts
Normal 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' : '' }}
|
||||
·
|
||||
{{ 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 }}
|
||||
·
|
||||
{{ 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user