From 77b32b4d3281598760613cb929ebe597b157a6bf Mon Sep 17 00:00:00 2001 From: Krrish Ghimire Date: Wed, 1 Jul 2026 01:14:40 +0545 Subject: [PATCH] add drag and drop builder --- .../indie/controller/BuilderController.java | 115 ++++++++ .../java/com/indie/dto/ComponentResponse.java | 34 +++ .../indie/dto/PageWithComponentsResponse.java | 40 +++ .../com/indie/dto/SaveComponentsRequest.java | 28 ++ .../com/indie/service/BuilderService.java | 55 ++++ frontend/angular.json | 1 + frontend/package-lock.json | 22 ++ frontend/package.json | 2 + frontend/src/app/app.routes.ts | 5 + .../src/app/builder/builder-api.service.ts | 25 ++ frontend/src/app/builder/builder.component.ts | 265 +++++++++++++++++ .../app/builder/canvas/canvas.component.ts | 133 +++++++++ .../component-palette.component.ts | 81 ++++++ .../create-page-dialog/create-page-dialog.ts | 74 +++++ .../app/builder/preview/preview.component.ts | 194 +++++++++++++ .../property-panel.component.ts | 270 ++++++++++++++++++ .../renderer/button-renderer.component.ts | 19 ++ .../contact-form-renderer.component.ts | 37 +++ .../custom-html-renderer.component.ts | 22 ++ .../renderer/divider-renderer.component.ts | 18 ++ .../renderer/gallery-renderer.component.ts | 33 +++ .../renderer/heading-renderer.component.ts | 23 ++ .../renderer/image-renderer.component.ts | 21 ++ .../renderer/map-renderer.component.ts | 75 +++++ .../renderer/text-renderer.component.ts | 22 ++ .../renderer/video-renderer.component.ts | 42 +++ .../app/builder/services/auto-save.service.ts | 60 ++++ .../builder/services/builder-state.service.ts | 225 +++++++++++++++ .../theme-editor/theme-editor.component.ts | 136 +++++++++ frontend/src/app/core/models/component.ts | 105 +++++++ .../src/app/dashboard/dashboard.component.ts | 6 +- 31 files changed, 2187 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/indie/controller/BuilderController.java create mode 100644 backend/src/main/java/com/indie/dto/ComponentResponse.java create mode 100644 backend/src/main/java/com/indie/dto/PageWithComponentsResponse.java create mode 100644 backend/src/main/java/com/indie/dto/SaveComponentsRequest.java create mode 100644 backend/src/main/java/com/indie/service/BuilderService.java create mode 100644 frontend/src/app/builder/builder-api.service.ts create mode 100644 frontend/src/app/builder/builder.component.ts create mode 100644 frontend/src/app/builder/canvas/canvas.component.ts create mode 100644 frontend/src/app/builder/component-palette/component-palette.component.ts create mode 100644 frontend/src/app/builder/create-page-dialog/create-page-dialog.ts create mode 100644 frontend/src/app/builder/preview/preview.component.ts create mode 100644 frontend/src/app/builder/property-panel/property-panel.component.ts create mode 100644 frontend/src/app/builder/renderer/button-renderer.component.ts create mode 100644 frontend/src/app/builder/renderer/contact-form-renderer.component.ts create mode 100644 frontend/src/app/builder/renderer/custom-html-renderer.component.ts create mode 100644 frontend/src/app/builder/renderer/divider-renderer.component.ts create mode 100644 frontend/src/app/builder/renderer/gallery-renderer.component.ts create mode 100644 frontend/src/app/builder/renderer/heading-renderer.component.ts create mode 100644 frontend/src/app/builder/renderer/image-renderer.component.ts create mode 100644 frontend/src/app/builder/renderer/map-renderer.component.ts create mode 100644 frontend/src/app/builder/renderer/text-renderer.component.ts create mode 100644 frontend/src/app/builder/renderer/video-renderer.component.ts create mode 100644 frontend/src/app/builder/services/auto-save.service.ts create mode 100644 frontend/src/app/builder/services/builder-state.service.ts create mode 100644 frontend/src/app/builder/theme-editor/theme-editor.component.ts create mode 100644 frontend/src/app/core/models/component.ts diff --git a/backend/src/main/java/com/indie/controller/BuilderController.java b/backend/src/main/java/com/indie/controller/BuilderController.java new file mode 100644 index 0000000..73a449c --- /dev/null +++ b/backend/src/main/java/com/indie/controller/BuilderController.java @@ -0,0 +1,115 @@ +package com.indie.controller; + +import com.indie.config.UserPrincipal; +import com.indie.dto.ComponentResponse; +import com.indie.dto.PageWithComponentsResponse; +import com.indie.dto.SaveComponentsRequest; +import com.indie.model.Component; +import com.indie.model.Page; +import com.indie.model.Site; +import com.indie.repository.ComponentRepository; +import com.indie.repository.PageRepository; +import com.indie.repository.SiteRepository; +import com.indie.service.BuilderService; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/builder/sites/{siteId}") +public class BuilderController { + + private final SiteRepository siteRepository; + private final PageRepository pageRepository; + private final ComponentRepository componentRepository; + private final BuilderService builderService; + + public BuilderController(SiteRepository siteRepository, PageRepository pageRepository, + ComponentRepository componentRepository, BuilderService builderService) { + this.siteRepository = siteRepository; + this.pageRepository = pageRepository; + this.componentRepository = componentRepository; + this.builderService = builderService; + } + + @GetMapping("/pages") + public ResponseEntity> listPagesWithComponents( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable UUID siteId) { + Site site = findSiteForUser(siteId, principal.id()); + List pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(site.getId()); + + List response = pages.stream() + .map(page -> { + List components = componentRepository + .findByPageIdOrderByOrderIndexAsc(page.getId()) + .stream() + .map(ComponentResponse::from) + .toList(); + return PageWithComponentsResponse.from(page, components); + }) + .toList(); + + return ResponseEntity.ok(response); + } + + @PutMapping("/pages/{pageId}/components") + public ResponseEntity> saveComponents( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable UUID siteId, + @PathVariable UUID pageId, + @Valid @RequestBody SaveComponentsRequest request) { + Site site = findSiteForUser(siteId, principal.id()); + Page page = findPageForSite(pageId, site.getId()); + + List components = builderService.saveComponents(page.getId(), request); + + List response = components.stream() + .map(ComponentResponse::from) + .toList(); + + return ResponseEntity.ok(response); + } + + @GetMapping("/theme") + public ResponseEntity> getTheme( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable UUID siteId) { + Site site = findSiteForUser(siteId, principal.id()); + return ResponseEntity.ok(site.getThemeConfig()); + } + + @PutMapping("/theme") + public ResponseEntity> updateTheme( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable UUID siteId, + @RequestBody Map themeConfig) { + Site site = findSiteForUser(siteId, principal.id()); + site.setThemeConfig(themeConfig); + siteRepository.save(site); + return ResponseEntity.ok(site.getThemeConfig()); + } + + private Site findSiteForUser(UUID siteId, UUID userId) { + Site site = siteRepository.findById(siteId) + .orElseThrow(() -> new IllegalArgumentException("Site not found")); + if (!site.getUserId().equals(userId)) { + throw new IllegalArgumentException("Site not found"); + } + return site; + } + + private Page findPageForSite(UUID pageId, UUID siteId) { + Page page = pageRepository.findById(pageId) + .orElseThrow(() -> new IllegalArgumentException("Page not found")); + if (!page.getSiteId().equals(siteId)) { + throw new IllegalArgumentException("Page not found"); + } + return page; + } +} diff --git a/backend/src/main/java/com/indie/dto/ComponentResponse.java b/backend/src/main/java/com/indie/dto/ComponentResponse.java new file mode 100644 index 0000000..b83f1ca --- /dev/null +++ b/backend/src/main/java/com/indie/dto/ComponentResponse.java @@ -0,0 +1,34 @@ +package com.indie.dto; + +import com.indie.model.Component; +import com.indie.model.ComponentType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +import java.util.Map; +import java.util.UUID; + +@Data +@AllArgsConstructor +@Builder +public class ComponentResponse { + + private UUID id; + private UUID pageId; + private ComponentType type; + private Map config; + private Map styles; + private int orderIndex; + + public static ComponentResponse from(Component component) { + return ComponentResponse.builder() + .id(component.getId()) + .pageId(component.getPageId()) + .type(component.getType()) + .config(component.getConfig()) + .styles(component.getStyles()) + .orderIndex(component.getOrderIndex()) + .build(); + } +} diff --git a/backend/src/main/java/com/indie/dto/PageWithComponentsResponse.java b/backend/src/main/java/com/indie/dto/PageWithComponentsResponse.java new file mode 100644 index 0000000..243c7a2 --- /dev/null +++ b/backend/src/main/java/com/indie/dto/PageWithComponentsResponse.java @@ -0,0 +1,40 @@ +package com.indie.dto; + +import com.indie.model.Page; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Data +@AllArgsConstructor +@Builder +public class PageWithComponentsResponse { + + private UUID id; + private UUID siteId; + private String title; + private String slug; + private String seoTitle; + private String seoDescription; + private int orderIndex; + private LocalDateTime createdAt; + private List components; + + public static PageWithComponentsResponse from(Page page, List components) { + return PageWithComponentsResponse.builder() + .id(page.getId()) + .siteId(page.getSiteId()) + .title(page.getTitle()) + .slug(page.getSlug()) + .seoTitle(page.getSeoTitle()) + .seoDescription(page.getSeoDescription()) + .orderIndex(page.getOrderIndex()) + .createdAt(page.getCreatedAt()) + .components(components) + .build(); + } +} diff --git a/backend/src/main/java/com/indie/dto/SaveComponentsRequest.java b/backend/src/main/java/com/indie/dto/SaveComponentsRequest.java new file mode 100644 index 0000000..2e6c205 --- /dev/null +++ b/backend/src/main/java/com/indie/dto/SaveComponentsRequest.java @@ -0,0 +1,28 @@ +package com.indie.dto; + +import com.indie.model.ComponentType; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Data +public class SaveComponentsRequest { + + @Valid + @NotNull + private List components; + + @Data + public static class ComponentDTO { + private UUID id; + @NotNull + private ComponentType type; + private Map config; + private Map styles; + private int orderIndex; + } +} diff --git a/backend/src/main/java/com/indie/service/BuilderService.java b/backend/src/main/java/com/indie/service/BuilderService.java new file mode 100644 index 0000000..f0c5fc3 --- /dev/null +++ b/backend/src/main/java/com/indie/service/BuilderService.java @@ -0,0 +1,55 @@ +package com.indie.service; + +import com.indie.dto.SaveComponentsRequest; +import com.indie.model.Component; +import com.indie.repository.ComponentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class BuilderService { + + private final ComponentRepository componentRepository; + + static final int MAX_COMPONENTS_PER_PAGE = 50; + + public void validateComponents(SaveComponentsRequest request) { + if (request.getComponents() == null || request.getComponents().isEmpty()) { + throw new IllegalArgumentException("Component list must not be empty"); + } + if (request.getComponents().size() > MAX_COMPONENTS_PER_PAGE) { + throw new IllegalArgumentException("Maximum " + MAX_COMPONENTS_PER_PAGE + " components allowed per page"); + } + + for (SaveComponentsRequest.ComponentDTO dto : request.getComponents()) { + if (dto.getType() == null) { + throw new IllegalArgumentException("Component type is required"); + } + } + } + + @Transactional + public List saveComponents(UUID pageId, SaveComponentsRequest request) { + validateComponents(request); + + componentRepository.deleteByPageId(pageId); + + List components = request.getComponents().stream() + .map(dto -> Component.builder() + .id(dto.getId() != null ? dto.getId() : UUID.randomUUID()) + .pageId(pageId) + .type(dto.getType()) + .config(dto.getConfig()) + .styles(dto.getStyles()) + .orderIndex(dto.getOrderIndex()) + .build()) + .toList(); + + return componentRepository.saveAll(components); + } +} diff --git a/frontend/angular.json b/frontend/angular.json index 8b66bb9..79cdd23 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -51,6 +51,7 @@ ], "styles": [ "@angular/material/prebuilt-themes/indigo-pink.css", + "node_modules/leaflet/dist/leaflet.css", "src/styles.css" ], "scripts": [] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b46b3ea..31a25e2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "@angular/platform-browser": "^17.3.0", "@angular/platform-browser-dynamic": "^17.3.0", "@angular/router": "^17.3.0", + "leaflet": "^1.9.4", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.14.3" @@ -27,6 +28,7 @@ "@angular/cli": "^17.3.17", "@angular/compiler-cli": "^17.3.0", "@types/jasmine": "~5.1.0", + "@types/leaflet": "^1.9.21", "autoprefixer": "^10.5.2", "jasmine-core": "~5.1.0", "karma": "~6.4.0", @@ -4563,6 +4565,12 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -4590,6 +4598,15 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -8628,6 +8645,11 @@ "shell-quote": "^1.8.4" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + }, "node_modules/less": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 23f9470..7d7afbd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@angular/platform-browser": "^17.3.0", "@angular/platform-browser-dynamic": "^17.3.0", "@angular/router": "^17.3.0", + "leaflet": "^1.9.4", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.14.3" @@ -29,6 +30,7 @@ "@angular/cli": "^17.3.17", "@angular/compiler-cli": "^17.3.0", "@types/jasmine": "~5.1.0", + "@types/leaflet": "^1.9.21", "autoprefixer": "^10.5.2", "jasmine-core": "~5.1.0", "karma": "~6.4.0", diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 36c8222..817a890 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -20,5 +20,10 @@ export const routes: Routes = [ loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent), canActivate: [AuthGuard], }, + { + path: 'builder/:siteId', + loadComponent: () => import('./builder/builder.component').then(m => m.BuilderComponent), + canActivate: [AuthGuard], + }, { path: '**', redirectTo: '/dashboard' }, ]; diff --git a/frontend/src/app/builder/builder-api.service.ts b/frontend/src/app/builder/builder-api.service.ts new file mode 100644 index 0000000..b3c9771 --- /dev/null +++ b/frontend/src/app/builder/builder-api.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { PageWithComponentsResponse, SaveComponentsRequest, ComponentResponse } from '../core/models/component'; + +@Injectable({ providedIn: 'root' }) +export class BuilderApiService { + constructor(private http: HttpClient) {} + + getPagesWithComponents(siteId: string): Observable { + return this.http.get(`/api/builder/sites/${siteId}/pages`); + } + + saveComponents(siteId: string, pageId: string, request: SaveComponentsRequest): Observable { + return this.http.put(`/api/builder/sites/${siteId}/pages/${pageId}/components`, request); + } + + getTheme(siteId: string): Observable> { + return this.http.get>(`/api/builder/sites/${siteId}/theme`); + } + + updateTheme(siteId: string, theme: Record): Observable> { + return this.http.put>(`/api/builder/sites/${siteId}/theme`, theme); + } +} diff --git a/frontend/src/app/builder/builder.component.ts b/frontend/src/app/builder/builder.component.ts new file mode 100644 index 0000000..e12516f --- /dev/null +++ b/frontend/src/app/builder/builder.component.ts @@ -0,0 +1,265 @@ +import { Component, OnInit, OnDestroy, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NgIf, NgFor, NgSwitch, NgSwitchCase, AsyncPipe } from '@angular/common'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { CdkDropListGroup } from '@angular/cdk/drag-drop'; +import { Subject, Subscription } from 'rxjs'; +import { BuilderApiService } from './builder-api.service'; +import { BuilderStateService } from './services/builder-state.service'; +import { AutoSaveService } from './services/auto-save.service'; +import { ComponentPalette } from './component-palette/component-palette.component'; +import { CanvasComponent } from './canvas/canvas.component'; +import { PropertyPanelComponent } from './property-panel/property-panel.component'; +import { ThemeEditorComponent } from './theme-editor/theme-editor.component'; +import { PreviewComponent } from './preview/preview.component'; +import { AuthService } from '../core/services/auth.service'; +import { SiteService } from '../core/services/site.service'; +import { PageService } from '../core/services/page.service'; +import { CreatePageDialog } from './create-page-dialog/create-page-dialog'; + +type RightPanel = 'properties' | 'theme' | 'preview'; + +@Component({ + selector: 'app-builder', + standalone: true, + imports: [ + NgIf, NgFor, NgSwitch, NgSwitchCase, AsyncPipe, + CdkDropListGroup, + MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule, MatTooltipModule, + MatTabsModule, MatProgressSpinnerModule, MatSnackBarModule, MatDialogModule, + ComponentPalette, CanvasComponent, PropertyPanelComponent, + ThemeEditorComponent, PreviewComponent, + ], + template: ` +
+ + +
+ + {{ siteName }} + {{ siteStatus }} + + + Saving... + Saved + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + Loading... +
+ + +
+ +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+ `, +}) +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(); + + 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); + } + + 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): 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(); + } +} diff --git a/frontend/src/app/builder/canvas/canvas.component.ts b/frontend/src/app/builder/canvas/canvas.component.ts new file mode 100644 index 0000000..32cbdb5 --- /dev/null +++ b/frontend/src/app/builder/canvas/canvas.component.ts @@ -0,0 +1,133 @@ +import { Component } from '@angular/core'; +import { NgFor, NgIf, NgSwitch, NgSwitchCase } from '@angular/common'; +import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; +import { MatIconModule } from '@angular/material/icon'; +import { BuilderStateService } from '../services/builder-state.service'; +import { ComponentItem, ComponentType } from '../../core/models/component'; +import { HeadingRenderer } from '../renderer/heading-renderer.component'; +import { TextRenderer } from '../renderer/text-renderer.component'; +import { ImageRenderer } from '../renderer/image-renderer.component'; +import { ButtonRenderer } from '../renderer/button-renderer.component'; +import { DividerRenderer } from '../renderer/divider-renderer.component'; +import { GalleryRenderer } from '../renderer/gallery-renderer.component'; +import { VideoRenderer } from '../renderer/video-renderer.component'; +import { ContactFormRenderer } from '../renderer/contact-form-renderer.component'; +import { MapRenderer } from '../renderer/map-renderer.component'; +import { CustomHtmlRenderer } from '../renderer/custom-html-renderer.component'; + +@Component({ + selector: 'app-canvas', + standalone: true, + imports: [ + NgFor, NgIf, NgSwitch, NgSwitchCase, MatIconModule, + CdkDropList, CdkDrag, + HeadingRenderer, TextRenderer, ImageRenderer, ButtonRenderer, DividerRenderer, + GalleryRenderer, VideoRenderer, ContactFormRenderer, MapRenderer, CustomHtmlRenderer, + ], + template: ` +
+
+

+ {{ selectedPage?.title || 'Select a page' }} +

+ {{ components.length }} components +
+ +
+
+
+ add_circle_outline +

Drag components here from the palette

+
+
+ +
+ +
+ +
+ + + + + + + + + + + + +
+ + +
+ drag_indicator +
+ + +
+ {{ comp.type }} +
+
+
+
+
+ `, + 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): 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); + } + } +} diff --git a/frontend/src/app/builder/component-palette/component-palette.component.ts b/frontend/src/app/builder/component-palette/component-palette.component.ts new file mode 100644 index 0000000..1efa3e1 --- /dev/null +++ b/frontend/src/app/builder/component-palette/component-palette.component.ts @@ -0,0 +1,81 @@ +import { Component } from '@angular/core'; +import { NgFor } from '@angular/common'; +import { CdkDrag, CdkDropList } from '@angular/cdk/drag-drop'; +import { MatIconModule } from '@angular/material/icon'; +import { MatListModule } from '@angular/material/list'; +import { ComponentType, getComponentLabel, getComponentIcon } from '../../core/models/component'; +import { BuilderStateService } from '../services/builder-state.service'; + +const CATEGORIES: { label: string; types: ComponentType[] }[] = [ + { + label: 'Content', + types: ['HEADING', 'TEXT', 'DIVIDER'], + }, + { + label: 'Media', + types: ['IMAGE', 'GALLERY', 'VIDEO'], + }, + { + label: 'Interactive', + types: ['BUTTON', 'CONTACT_FORM', 'MAP'], + }, + { + label: 'Advanced', + types: ['CUSTOM_HTML'], + }, +]; + +@Component({ + selector: 'app-component-palette', + standalone: true, + imports: [NgFor, CdkDropList, CdkDrag, MatIconModule, MatListModule], + template: ` +
+

Components

+
+
+

{{ cat.label }}

+
+
+ {{ getIcon(type) }} + {{ getLabel(type) }} +
+
+
+
+
+ `, + 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 {} +} diff --git a/frontend/src/app/builder/create-page-dialog/create-page-dialog.ts b/frontend/src/app/builder/create-page-dialog/create-page-dialog.ts new file mode 100644 index 0000000..e64a7c3 --- /dev/null +++ b/frontend/src/app/builder/create-page-dialog/create-page-dialog.ts @@ -0,0 +1,74 @@ +import { Component, Inject, inject } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { NgIf } from '@angular/common'; +import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { PageService } from '../../core/services/page.service'; + +@Component({ + selector: 'app-create-page-dialog', + standalone: true, + imports: [NgIf, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, ReactiveFormsModule], + template: ` +

Add Page

+ +
+ + Page Title + + Title is required + + + + Slug + + Slug is required + Lowercase letters, numbers, and hyphens only + +
+
+ + + + + `, +}) +export class CreatePageDialog { + private fb = inject(FormBuilder); + private dialogRef = inject(MatDialogRef); + 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; + }, + }); + } +} diff --git a/frontend/src/app/builder/preview/preview.component.ts b/frontend/src/app/builder/preview/preview.component.ts new file mode 100644 index 0000000..2962916 --- /dev/null +++ b/frontend/src/app/builder/preview/preview.component.ts @@ -0,0 +1,194 @@ +import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/core'; +import { NgFor, NgIf } from '@angular/common'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatIconModule } from '@angular/material/icon'; +import { ComponentItem, ComponentType } from '../../core/models/component'; + +type DeviceType = 'desktop' | 'tablet' | 'mobile'; + +const DEVICE_WIDTHS: Record = { + desktop: '100%', + tablet: '768px', + mobile: '375px', +}; + +@Component({ + selector: 'app-preview', + standalone: true, + imports: [NgIf, NgFor, MatButtonToggleModule, MatIconModule], + template: ` +
+
+

Preview

+ + + desktop_windows + + + tablet_mac + + + phone_iphone + + +
+ +
+
+
+ No components to preview +
+
+
+
+
+ `, +}) +export class PreviewComponent implements OnChanges { + @Input() components: ComponentItem[] = []; + @Input() theme: Record = {}; + + 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 ` +
+ + ${componentHtml} +
+ `; + } + + 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, styles: string, styleObj: Record = {}): 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}`; + } + case 'TEXT': + return `
${config['content'] || ''}
`; + case 'IMAGE': { + const src = config['src'] || ''; + const alt = config['alt'] || ''; + const link = config['linkTo'] as string; + const img = `${alt}`; + return link ? `${img}` : img; + } + case 'BUTTON': { + const text = config['text'] || 'Button'; + const link = config['link'] || '#'; + return `${text}`; + } + 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 `
`; + } + 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.alt || ''}` + ).join(''); + return `
${items}
`; + } + 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 ``; + } + 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' + ? `
` + : `
` + ).join(''); + return `
${inputs}
`; + } + 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 `
+
+
📍
+

${addr}

+ View on OpenStreetMap +
+
`; + } + case 'CUSTOM_HTML': + return `
${(config['html'] as string) || ''}
`; + default: + return ''; + } + } + + private styleObjToStr(styles: Record): string { + return Object.entries(styles) + .map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`) + .join('; '); + } +} diff --git a/frontend/src/app/builder/property-panel/property-panel.component.ts b/frontend/src/app/builder/property-panel/property-panel.component.ts new file mode 100644 index 0000000..8ab9bc3 --- /dev/null +++ b/frontend/src/app/builder/property-panel/property-panel.component.ts @@ -0,0 +1,270 @@ +import { Component, OnDestroy } from '@angular/core'; +import { NgIf, NgFor, NgSwitch, NgSwitchCase, KeyValuePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { Subscription } from 'rxjs'; +import { BuilderStateService } from '../services/builder-state.service'; +import { ComponentItem, getComponentLabel } from '../../core/models/component'; + +@Component({ + selector: 'app-property-panel', + standalone: true, + imports: [ + NgIf, NgFor, NgSwitch, NgSwitchCase, KeyValuePipe, FormsModule, + MatExpansionModule, MatFormFieldModule, MatInputModule, MatSelectModule, + MatButtonModule, MatIconModule, MatSlideToggleModule, + ], + template: ` +
+

Properties

+ +
+ tune +

Select a component to edit

+
+ +
+
+

{{ componentLabel }}

+

{{ component.type }}

+
+ + + + + Content + + + + +
+ + Text + + + + Level + + H1 + H2 + H3 + H4 + H5 + H6 + + +
+ +
+ + Content + + +
+ +
+ + Image URL + + + + Alt Text + + + + Link To (optional) + + +
+ +
+ + Button Text + + + + Link + + +
+ +
+ + Style + + Solid + Dashed + Dotted + + +
+ +
+ + Video URL + + +
+ + Autoplay + + + Controls + +
+
+ +
+ + Submit Button Text + + + + Email To + + +
+ +
+ + Address + + + + Zoom Level + + {{ z }} + + +
+ +
+
+ {{ img.src }} + +
+
+ + Image URL + + + +
+

+ No images. Add image URLs above. +

+
+ +
+ + HTML + + +
+ +
+
+ + + + Styles + + +
+
+ + {{ formatKey(entry) }} + + +
+
+
+
+ +
+ +
+
+
+ `, +}) +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(); + } +} diff --git a/frontend/src/app/builder/renderer/button-renderer.component.ts b/frontend/src/app/builder/renderer/button-renderer.component.ts new file mode 100644 index 0000000..7f6dce5 --- /dev/null +++ b/frontend/src/app/builder/renderer/button-renderer.component.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-button-renderer', + standalone: true, + template: ` + + {{ config['text'] }} + + `, +}) +export class ButtonRenderer { + @Input() config: Record = {}; + @Input() styles: Record = {}; + get styleStr(): string { + return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; '); + } +} diff --git a/frontend/src/app/builder/renderer/contact-form-renderer.component.ts b/frontend/src/app/builder/renderer/contact-form-renderer.component.ts new file mode 100644 index 0000000..7579d38 --- /dev/null +++ b/frontend/src/app/builder/renderer/contact-form-renderer.component.ts @@ -0,0 +1,37 @@ +import { Component, Input } from '@angular/core'; +import { NgFor, NgIf } from '@angular/common'; + +@Component({ + selector: 'app-contact-form-renderer', + standalone: true, + imports: [NgFor, NgIf], + template: ` +
+
+ + + +
+ +
+ `, +}) +export class ContactFormRenderer { + @Input() config: Record = {}; + @Input() styles: Record = {}; + + 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;'; + } +} diff --git a/frontend/src/app/builder/renderer/custom-html-renderer.component.ts b/frontend/src/app/builder/renderer/custom-html-renderer.component.ts new file mode 100644 index 0000000..794fd4f --- /dev/null +++ b/frontend/src/app/builder/renderer/custom-html-renderer.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; + +@Component({ + selector: 'app-custom-html-renderer', + standalone: true, + template: `
`, +}) +export class CustomHtmlRenderer { + @Input() config: Record = {}; + @Input() styles: Record = {}; + + 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('; '); + } +} diff --git a/frontend/src/app/builder/renderer/divider-renderer.component.ts b/frontend/src/app/builder/renderer/divider-renderer.component.ts new file mode 100644 index 0000000..ee3e1ce --- /dev/null +++ b/frontend/src/app/builder/renderer/divider-renderer.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-divider-renderer', + standalone: true, + template: `
`, +}) +export class DividerRenderer { + @Input() config: Record = {}; + @Input() styles: Record = {}; + 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};`; + } +} diff --git a/frontend/src/app/builder/renderer/gallery-renderer.component.ts b/frontend/src/app/builder/renderer/gallery-renderer.component.ts new file mode 100644 index 0000000..8394752 --- /dev/null +++ b/frontend/src/app/builder/renderer/gallery-renderer.component.ts @@ -0,0 +1,33 @@ +import { Component, Input } from '@angular/core'; +import { NgFor, NgIf } from '@angular/common'; + +@Component({ + selector: 'app-gallery-renderer', + standalone: true, + imports: [NgFor, NgIf], + template: ` +
+ +
+ `, +}) +export class GalleryRenderer { + @Input() config: Record = {}; + @Input() styles: Record = {}; + + 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};`; + } +} diff --git a/frontend/src/app/builder/renderer/heading-renderer.component.ts b/frontend/src/app/builder/renderer/heading-renderer.component.ts new file mode 100644 index 0000000..972b255 --- /dev/null +++ b/frontend/src/app/builder/renderer/heading-renderer.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core'; +import { NgIf } from '@angular/common'; + +@Component({ + selector: 'app-heading-renderer', + standalone: true, + imports: [NgIf], + template: ` +

{{ config['text'] }}

+

{{ config['text'] }}

+

{{ config['text'] }}

+

{{ config['text'] }}

+
{{ config['text'] }}
+
{{ config['text'] }}
+ `, +}) +export class HeadingRenderer { + @Input() config: Record = {}; + @Input() styles: Record = {}; + get styleStr(): string { + return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; '); + } +} diff --git a/frontend/src/app/builder/renderer/image-renderer.component.ts b/frontend/src/app/builder/renderer/image-renderer.component.ts new file mode 100644 index 0000000..b17d228 --- /dev/null +++ b/frontend/src/app/builder/renderer/image-renderer.component.ts @@ -0,0 +1,21 @@ +import { Component, Input } from '@angular/core'; +import { NgIf } from '@angular/common'; + +@Component({ + selector: 'app-image-renderer', + standalone: true, + imports: [NgIf], + template: ` + + + + + `, +}) +export class ImageRenderer { + @Input() config: Record = {}; + @Input() styles: Record = {}; + get styleStr(): string { + return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; '); + } +} diff --git a/frontend/src/app/builder/renderer/map-renderer.component.ts b/frontend/src/app/builder/renderer/map-renderer.component.ts new file mode 100644 index 0000000..0783468 --- /dev/null +++ b/frontend/src/app/builder/renderer/map-renderer.component.ts @@ -0,0 +1,75 @@ +import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core'; +import * as L from 'leaflet'; + +delete (L.Icon.Default.prototype as unknown as Record)['_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: ` +
+
+
+ `, +}) +export class MapRenderer implements AfterViewInit, OnDestroy { + @Input() config: Record = {}; + @Input() styles: Record = {}; + @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 { + 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: '© OpenStreetMap 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('; '); + } +} diff --git a/frontend/src/app/builder/renderer/text-renderer.component.ts b/frontend/src/app/builder/renderer/text-renderer.component.ts new file mode 100644 index 0000000..d8ab306 --- /dev/null +++ b/frontend/src/app/builder/renderer/text-renderer.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; + +@Component({ + selector: 'app-text-renderer', + standalone: true, + template: `
`, +}) +export class TextRenderer { + @Input() config: Record = {}; + @Input() styles: Record = {}; + + 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('; '); + } +} diff --git a/frontend/src/app/builder/renderer/video-renderer.component.ts b/frontend/src/app/builder/renderer/video-renderer.component.ts new file mode 100644 index 0000000..b108bb1 --- /dev/null +++ b/frontend/src/app/builder/renderer/video-renderer.component.ts @@ -0,0 +1,42 @@ +import { Component, Input } from '@angular/core'; +import { NgIf } from '@angular/common'; + +@Component({ + selector: 'app-video-renderer', + standalone: true, + imports: [NgIf], + template: ` +
+ +
+ + `, +}) +export class VideoRenderer { + @Input() config: Record = {}; + @Input() styles: Record = {}; + + 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('; '); + } +} diff --git a/frontend/src/app/builder/services/auto-save.service.ts b/frontend/src/app/builder/services/auto-save.service.ts new file mode 100644 index 0000000..a9df40d --- /dev/null +++ b/frontend/src/app/builder/services/auto-save.service.ts @@ -0,0 +1,60 @@ +import { Injectable, OnDestroy, inject } from '@angular/core'; +import { Subject, Subscription, debounceTime, tap, switchMap } from 'rxjs'; +import { BuilderApiService } from '../builder-api.service'; +import { BuilderStateService } from './builder-state.service'; + +@Injectable({ providedIn: 'root' }) +export class AutoSaveService implements OnDestroy { + private api = inject(BuilderApiService); + private state = inject(BuilderStateService); + private saveSubject = new Subject(); + 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 { + 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(); + } +} diff --git a/frontend/src/app/builder/services/builder-state.service.ts b/frontend/src/app/builder/services/builder-state.service.ts new file mode 100644 index 0000000..dfee660 --- /dev/null +++ b/frontend/src/app/builder/services/builder-state.service.ts @@ -0,0 +1,225 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { ComponentItem, ComponentType, getDefaultConfig, getDefaultStyles, PageWithComponentsResponse } from '../../core/models/component'; + +interface Snapshot { + pages: PageState[]; + selectedPageId: string | null; + selectedComponentId: string | null; +} + +interface PageState { + id: string; + title: string; + slug: string; + components: ComponentItem[]; +} + +interface HistoryEntry { + snapshot: Snapshot; + label: string; +} + +const MAX_HISTORY = 50; + +@Injectable({ providedIn: 'root' }) +export class BuilderStateService { + private pagesSubject = new BehaviorSubject([]); + private selectedPageIdSubject = new BehaviorSubject(null); + private selectedComponentIdSubject = new BehaviorSubject(null); + private themeConfigSubject = new BehaviorSubject>({}); + private loadingSubject = new BehaviorSubject(false); + private savingSubject = new BehaviorSubject(false); + private siteIdSubject = new BehaviorSubject(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 { 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): void { + this.themeConfigSubject.next(theme); + } + + updateThemeConfig(partial: Record): 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): 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; styles: Record; 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, + })), + }; + } +} diff --git a/frontend/src/app/builder/theme-editor/theme-editor.component.ts b/frontend/src/app/builder/theme-editor/theme-editor.component.ts new file mode 100644 index 0000000..b0b6515 --- /dev/null +++ b/frontend/src/app/builder/theme-editor/theme-editor.component.ts @@ -0,0 +1,136 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { NgFor, NgIf, KeyValuePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; + +const DEFAULT_THEME: Record = { + 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: ` +
+

Theme

+ + + + + Colors + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ + + + Typography + +
+ + Heading Font + + {{ font }} + + + + Body Font + + {{ font }} + + +
+
+ + + + Layout + +
+ + Border Radius + + + + Spacing + + +
+
+
+
+ `, +}) +export class ThemeEditorComponent { + @Input() theme: Record = { ...DEFAULT_THEME }; + @Output() themeChange = new EventEmitter>(); + fonts = FONTS; + + updateKey(key: string, value: string): void { + this.theme = { ...this.theme, [key]: value }; + this.themeChange.emit(this.theme); + } +} diff --git a/frontend/src/app/core/models/component.ts b/frontend/src/app/core/models/component.ts new file mode 100644 index 0000000..4523571 --- /dev/null +++ b/frontend/src/app/core/models/component.ts @@ -0,0 +1,105 @@ +export type ComponentType = + | 'HEADING' | 'TEXT' | 'IMAGE' | 'BUTTON' | 'DIVIDER' + | 'GALLERY' | 'VIDEO' | 'CONTACT_FORM' | 'MAP' | 'CUSTOM_HTML'; + +export interface ComponentItem { + id: string; + pageId?: string; + type: ComponentType; + config: Record; + styles: Record; + orderIndex: number; +} + +export interface ComponentResponse { + id: string; + pageId: string; + type: ComponentType; + config: Record; + styles: Record; + 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; + styles: Record; + orderIndex: number; + }[]; +} + +export function getDefaultConfig(type: ComponentType): Record { + switch (type) { + case 'HEADING': return { text: 'Heading', level: 'h2' }; + case 'TEXT': return { content: 'Text content here...' }; + case 'IMAGE': return { src: '', alt: 'Image', linkTo: '' }; + case 'BUTTON': return { text: 'Click Me', link: '#', variant: 'filled' }; + case 'DIVIDER': return { style: 'solid' }; + case 'GALLERY': return { images: [] }; + case 'VIDEO': return { src: '', autoplay: false, controls: true }; + case 'CONTACT_FORM': return { fields: ['name', 'email', 'message'], submitText: 'Send', emailTo: '' }; + case 'MAP': return { address: 'New York, NY', zoom: 14 }; + case 'CUSTOM_HTML': return { html: '

Custom HTML

' }; + } +} + +export function getDefaultStyles(type: ComponentType): Record { + switch (type) { + case 'HEADING': return { color: '#1a1a1a', fontSize: '2rem', fontWeight: '700', textAlign: 'left', margin: '0 0 1rem' }; + case 'TEXT': return { color: '#4a4a4a', fontSize: '1rem', lineHeight: '1.6', textAlign: 'left' }; + case 'IMAGE': return { width: '100%', height: 'auto', borderRadius: '0', objectFit: 'cover' }; + case 'BUTTON': return { backgroundColor: '#1976d2', color: '#ffffff', borderRadius: '4px', padding: '0.75rem 1.5rem' }; + case 'DIVIDER': return { color: '#e0e0e0', thickness: '1px', margin: '1.5rem 0' }; + case 'GALLERY': return { columns: '3', gap: '8px', borderRadius: '4px' }; + case 'VIDEO': return { width: '100%', maxWidth: '800px', borderRadius: '4px' }; + case 'CONTACT_FORM': return { backgroundColor: '#f5f5f5', padding: '2rem', borderRadius: '8px' }; + case 'MAP': return { width: '100%', height: '400px', borderRadius: '4px' }; + case 'CUSTOM_HTML': return { padding: '0', margin: '0' }; + } +} + +export function getComponentLabel(type: ComponentType): string { + const labels: Record = { + HEADING: 'Heading', + TEXT: 'Text', + IMAGE: 'Image', + BUTTON: 'Button', + DIVIDER: 'Divider', + GALLERY: 'Gallery', + VIDEO: 'Video', + CONTACT_FORM: 'Contact Form', + MAP: 'Map', + CUSTOM_HTML: 'Custom HTML', + }; + return labels[type]; +} + +export function getComponentIcon(type: ComponentType): string { + const icons: Record = { + 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]; +} diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index 1561917..cb9fd87 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -58,7 +58,7 @@ import { DeleteSiteDialog } from './delete-site-dialog/delete-site-dialog';
- + {{ site.name }} {{ site.subdomain }}.indie.com @@ -130,6 +130,10 @@ export class DashboardComponent implements OnInit { }); } + openBuilder(site: SiteResponse): void { + this.router.navigate(['/builder', site.id]); + } + logout(): void { this.authService.logout().subscribe({ next: () => this.router.navigate(['/login']),