From 3ccbd2ebec5c27241007c2d917d42f9e012b1931 Mon Sep 17 00:00:00 2001 From: Krrish Ghimire Date: Thu, 2 Jul 2026 01:15:16 +0545 Subject: [PATCH] generate site using llm prompt --- .gitignore | 3 + PLAN.md | 215 ++++---- backend/build.gradle | 3 + .../com/indie/config/RateLimitConfig.java | 40 ++ .../indie/controller/BuilderController.java | 115 ----- .../com/indie/controller/LLMController.java | 138 ++++++ .../com/indie/controller/PageController.java | 124 ----- .../com/indie/controller/SiteController.java | 4 - .../java/com/indie/dto/ComponentResponse.java | 34 -- .../main/java/com/indie/dto/LLMOptions.java | 20 + .../main/java/com/indie/dto/LLMResult.java | 17 + .../main/java/com/indie/dto/PageRequest.java | 22 - .../main/java/com/indie/dto/PageResponse.java | 36 -- .../indie/dto/PageWithComponentsResponse.java | 40 -- .../main/java/com/indie/dto/Reference.java | 19 + .../com/indie/dto/SaveComponentsRequest.java | 28 -- .../main/java/com/indie/dto/SiteRequest.java | 4 - .../main/java/com/indie/dto/SiteResponse.java | 6 +- .../java/com/indie/dto/SiteStructure.java | 50 ++ .../main/java/com/indie/model/Component.java | 47 -- .../java/com/indie/model/ComponentType.java | 5 - .../com/indie/model/GenerationVersion.java | 41 ++ .../src/main/java/com/indie/model/Page.java | 50 -- .../src/main/java/com/indie/model/Site.java | 6 - .../indie/repository/ComponentRepository.java | 14 - .../com/indie/repository/PageRepository.java | 17 - .../com/indie/service/BuilderService.java | 55 --- .../com/indie/service/LLMResponseParser.java | 191 ++++++++ .../java/com/indie/service/LLMService.java | 191 ++++++++ .../com/indie/service/ReferenceService.java | 62 +++ .../com/indie/service/llm/GeminiProvider.java | 133 +++++ .../com/indie/service/llm/LLMFactory.java | 55 +++ .../com/indie/service/llm/LLMProvider.java | 14 + .../com/indie/service/llm/OpenAIProvider.java | 103 ++++ backend/src/main/resources/application.yml | 19 + .../indie/controller/PageControllerTest.java | 201 -------- .../java/com/indie/dto/PageResponseTest.java | 57 --- .../java/com/indie/dto/SiteResponseTest.java | 5 - .../java/com/indie/model/ComponentTest.java | 39 -- .../test/java/com/indie/model/PageTest.java | 57 --- .../repository/ComponentRepositoryTest.java | 92 ---- .../indie/repository/PageRepositoryTest.java | 101 ---- .../support/FakeComponentRepository.java | 40 -- .../com/indie/support/FakePageRepository.java | 49 -- frontend/proxy.conf.json | 6 +- frontend/src/app/app.routes.ts | 4 +- .../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 | 89 ++-- frontend/src/app/core/models/llm.ts | 76 +++ .../src/app/core/services/llm-api.service.ts | 29 ++ .../src/app/dashboard/dashboard.component.ts | 2 +- .../delete-version-dialog.ts | 24 + .../app/llm-workspace/llm-session.service.ts | 102 ++++ .../llm-workspace/llm-workspace.component.ts | 459 ++++++++++++++++++ .../preview-dialog.component.ts | 256 ++++++++++ frontend/src/styles.css | 9 + 75 files changed, 2220 insertions(+), 3173 deletions(-) create mode 100644 backend/src/main/java/com/indie/config/RateLimitConfig.java delete mode 100644 backend/src/main/java/com/indie/controller/BuilderController.java create mode 100644 backend/src/main/java/com/indie/controller/LLMController.java delete mode 100644 backend/src/main/java/com/indie/controller/PageController.java delete mode 100644 backend/src/main/java/com/indie/dto/ComponentResponse.java create mode 100644 backend/src/main/java/com/indie/dto/LLMOptions.java create mode 100644 backend/src/main/java/com/indie/dto/LLMResult.java delete mode 100644 backend/src/main/java/com/indie/dto/PageRequest.java delete mode 100644 backend/src/main/java/com/indie/dto/PageResponse.java delete mode 100644 backend/src/main/java/com/indie/dto/PageWithComponentsResponse.java create mode 100644 backend/src/main/java/com/indie/dto/Reference.java delete mode 100644 backend/src/main/java/com/indie/dto/SaveComponentsRequest.java create mode 100644 backend/src/main/java/com/indie/dto/SiteStructure.java delete mode 100644 backend/src/main/java/com/indie/model/Component.java delete mode 100644 backend/src/main/java/com/indie/model/ComponentType.java create mode 100644 backend/src/main/java/com/indie/model/GenerationVersion.java delete mode 100644 backend/src/main/java/com/indie/model/Page.java delete mode 100644 backend/src/main/java/com/indie/repository/ComponentRepository.java delete mode 100644 backend/src/main/java/com/indie/repository/PageRepository.java delete mode 100644 backend/src/main/java/com/indie/service/BuilderService.java create mode 100644 backend/src/main/java/com/indie/service/LLMResponseParser.java create mode 100644 backend/src/main/java/com/indie/service/LLMService.java create mode 100644 backend/src/main/java/com/indie/service/ReferenceService.java create mode 100644 backend/src/main/java/com/indie/service/llm/GeminiProvider.java create mode 100644 backend/src/main/java/com/indie/service/llm/LLMFactory.java create mode 100644 backend/src/main/java/com/indie/service/llm/LLMProvider.java create mode 100644 backend/src/main/java/com/indie/service/llm/OpenAIProvider.java delete mode 100644 backend/src/test/java/com/indie/controller/PageControllerTest.java delete mode 100644 backend/src/test/java/com/indie/dto/PageResponseTest.java delete mode 100644 backend/src/test/java/com/indie/model/ComponentTest.java delete mode 100644 backend/src/test/java/com/indie/model/PageTest.java delete mode 100644 backend/src/test/java/com/indie/repository/ComponentRepositoryTest.java delete mode 100644 backend/src/test/java/com/indie/repository/PageRepositoryTest.java delete mode 100644 backend/src/test/java/com/indie/support/FakeComponentRepository.java delete mode 100644 backend/src/test/java/com/indie/support/FakePageRepository.java delete mode 100644 frontend/src/app/builder/builder-api.service.ts delete mode 100644 frontend/src/app/builder/builder.component.ts delete mode 100644 frontend/src/app/builder/canvas/canvas.component.ts delete mode 100644 frontend/src/app/builder/component-palette/component-palette.component.ts delete mode 100644 frontend/src/app/builder/create-page-dialog/create-page-dialog.ts delete mode 100644 frontend/src/app/builder/preview/preview.component.ts delete mode 100644 frontend/src/app/builder/property-panel/property-panel.component.ts delete mode 100644 frontend/src/app/builder/renderer/button-renderer.component.ts delete mode 100644 frontend/src/app/builder/renderer/contact-form-renderer.component.ts delete mode 100644 frontend/src/app/builder/renderer/custom-html-renderer.component.ts delete mode 100644 frontend/src/app/builder/renderer/divider-renderer.component.ts delete mode 100644 frontend/src/app/builder/renderer/gallery-renderer.component.ts delete mode 100644 frontend/src/app/builder/renderer/heading-renderer.component.ts delete mode 100644 frontend/src/app/builder/renderer/image-renderer.component.ts delete mode 100644 frontend/src/app/builder/renderer/map-renderer.component.ts delete mode 100644 frontend/src/app/builder/renderer/text-renderer.component.ts delete mode 100644 frontend/src/app/builder/renderer/video-renderer.component.ts delete mode 100644 frontend/src/app/builder/services/auto-save.service.ts delete mode 100644 frontend/src/app/builder/services/builder-state.service.ts delete mode 100644 frontend/src/app/builder/theme-editor/theme-editor.component.ts create mode 100644 frontend/src/app/core/models/llm.ts create mode 100644 frontend/src/app/core/services/llm-api.service.ts create mode 100644 frontend/src/app/llm-workspace/delete-version-dialog/delete-version-dialog.ts create mode 100644 frontend/src/app/llm-workspace/llm-session.service.ts create mode 100644 frontend/src/app/llm-workspace/llm-workspace.component.ts create mode 100644 frontend/src/app/llm-workspace/preview-dialog/preview-dialog.component.ts diff --git a/.gitignore b/.gitignore index b1a65c5..94c9d61 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ docker-data/ # Secrets / keys / certificates backend/config/ + +# LLM prompts (IP / business logic - deploy separately) +backend/src/main/resources/prompts/ *.key *.pem *.p8 diff --git a/PLAN.md b/PLAN.md index 53b0a38..290b144 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,6 +1,6 @@ # Indie — Independent Website Platform -A platform that lets anyone create, own, and publish their own static website without social media gatekeeping. Built with a drag-and-drop builder and LLM prompt-to-site generation. Every site is backed by a git repository. Users self-host or use free subdomain hosting. +A platform that lets anyone create, own, and publish their own static website through AI prompts. No drag-and-drop builder — just describe what you want, and the AI generates it. Every site is backed by a git repository. Users self-host or use free subdomain hosting. --- @@ -9,14 +9,51 @@ A platform that lets anyone create, own, and publish their own static website wi | Layer | Technology | |-------|-----------| | Backend | Java 17, Spring Boot 3.x, Gradle (Groovy) | -| Frontend | Angular 17, Angular Material, Angular CDK (drag-drop) | +| Frontend | Angular 17, Angular Material | | Relational DB | PostgreSQL | -| Document DB | MongoDB (LLM sessions) | +| Document DB | MongoDB (generation versions, LLM sessions) | | Object Storage | Cloudflare R2 (S3-compatible) | | LLM | Gemini (primary), OpenAI (fallback), provider-abstraction for future | | Auth | Spring Security + OAuth2 Client — email/password, Google OAuth, GitHub OAuth, OpenID Connect | | Git | JGit — local repo per site, optional GitHub/GitLab remote push | -| Infra | Docker Compose (dev), R2 + Cloudflare (hosting), Caddy/nginx (prod reverse proxy) | +| Infra | Docker Compose (dev), R2 + Cloudflare (hosting), nginx (prod reverse proxy) | + +--- + +## Core Interaction Model + +``` +Prompt + Reference URLs + │ + ▼ + ┌──────────────────┐ ┌─────────────────┐ + │ LLM generates │────▶│ Version saved │ + │ site structure │ │ to timeline │ + └──────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ + ┌──────────────────────────────────────────┐ + │ Workspace Layout: │ + │ ┌──────────┬──────────────────────────┐ │ + │ │ Version │ Live Preview │ │ + │ │ Timeline │ (sandboxed iframe) │ │ + │ │ sidebar │ │ │ + │ └──────────┴──────────────────────────┘ │ + │ ┌──────────────────────────────────────┐ │ + │ │ Content Editor (grouped by page) │ │ + │ │ ▼ Homepage │ │ + │ │ Heading: [editable text] │ │ + │ │ Body: [editable text] │ │ + │ │ ▼ About (collapsible) │ │ + │ └──────────────────────────────────────┘ │ + └──────────────────────────────────────────┘ + │ + ▼ + Refine via new prompt or inline text edits + │ + ▼ + Publish → Export → Git → Hosting +``` --- @@ -24,7 +61,7 @@ A platform that lets anyone create, own, and publish their own static website wi ### v0.1 — Auth & Foundation (Target: Weeks 1-3) -**Goal:** User can register, log in via email or OAuth, and create/manage sites and pages. +**Goal:** User can register, log in via email or OAuth, and create/manage sites. #### Backend @@ -38,15 +75,12 @@ A platform that lets anyone create, own, and publish their own static website wi | `com/indie/config/JwtConfig.java` | Add | RS256 signing, access token 15min, refresh token 7d | | `com/indie/config/GitConfig.java` | Add | Repos root path (`/var/git/sites/`), author name/email | | `com/indie/model/User.java` | Add | id(UUID), email, password(bcrypt, nullable), name, avatarUrl, provider(LOCAL/GOOGLE/GITHUB/OPENID), providerId, emailVerified, createdAt, updatedAt | -| `com/indie/model/Site.java` | Add | id, userId, name, description, subdomain(unique), themeConfig(JSONB), status(DRAFT/PUBLISHED), publishedAt, createdAt, updatedAt | -| `com/indie/model/Page.java` | Add | id, siteId(FK), title, slug, seoTitle, seoDescription, orderIndex, createdAt | -| `com/indie/model/Component.java` | Add | id, pageId(FK), type(enum), config(JSONB), styles(JSONB), orderIndex | -| `com/indie/repository/*.java` | Add | JPA repositories for User, Site, Page, Component | +| `com/indie/model/Site.java` | Add | id, userId, name, description, subdomain(unique), status(DRAFT/PUBLISHED), publishedAt, createdAt, updatedAt | +| `com/indie/repository/*.java` | Add | JPA repositories for User, Site | | `com/indie/service/AuthService.java` | Add | register, login, refreshToken, oauthLogin | | `com/indie/service/OAuth2UserService.java` | Add | Maps Google/GitHub/OpenID user info to User entity | | `com/indie/controller/AuthController.java` | Add | `POST /api/auth/register`, `/login`, `/refresh`, `/logout` | | `com/indie/controller/SiteController.java` | Add | Full CRUD: `GET/POST /api/sites`, `GET/PUT/DELETE /api/sites/{id}` | -| `com/indie/controller/PageController.java` | Add | CRUD for pages within a site | #### Frontend @@ -64,68 +98,58 @@ A platform that lets anyone create, own, and publish their own static website wi --- -### v0.2 — Drag-and-Drop Builder (Target: Weeks 4-6) +### v0.2 — LLM Prompt Workspace (Target: Weeks 4-7) -**Goal:** User can visually build a website by dragging components onto a canvas, editing their properties, styling via theme editor, and previewing live. +**Goal:** User opens a site, types a prompt describing their desired website, optionally adds reference URLs, and the AI generates it. User can refine via follow-up prompts or edit text inline. Each generation is saved as a version in a timeline. #### Backend | File | Action | Details | |------|--------|---------| -| `com/indie/controller/BuilderController.java` | Add | `GET /api/sites/{id}/pages` (with nested components), `PUT /api/sites/{id}/pages/{pageId}/components` (batch save), `GET/PUT /api/sites/{id}/theme` | -| `com/indie/service/BuilderService.java` | Add | Validate component tree (type enum, config schema, max 50/page), save in transaction | +| `com/indie/service/llm/LLMProvider.java` | Add | Interface: `generate(systemPrompt, userPrompt, references, options) → LLMResult` | +| `com/indie/service/llm/GeminiProvider.java` | Add | Primary provider. Supports multimodal (text + images via URL) | +| `com/indie/service/llm/OpenAIProvider.java` | Add | Secondary/fallback | +| `com/indie/service/llm/LLMFactory.java` | Add | Provider routing + fallback chain | +| `com/indie/dto/SiteStructure.java` | Add | Top-level DTO: `List pages`, `SiteTheme theme`. Each `PageStructure`: id, title, slug, `List`. Each `ComponentStructure`: id, type, config (Map), styles (Map) | +| `com/indie/dto/Reference.java` | Add | type(URL/IMAGE), url, altText | +| `com/indie/model/GenerationVersion.java` | Add | MongoDB: sessionId, versionNumber, prompt, references(List), full SiteStructure(JSON), createdAt. Linked to a site | +| `com/indie/service/LLMService.java` | Add | Orchestrator: validate prompt → build system prompt with references → call LLM → parse → validate schema → store version → return SiteStructure | +| `com/indie/service/LLMResponseParser.java` | Add | Parse LLM JSON output, handle markdown fences, malformed fields, partial extraction, default values | +| `com/indie/controller/LLMController.java` | Add | `POST /api/llm/generate` (prompt + references → SiteStructure), `POST /api/llm/refine` (existing structure + new prompt → updated structure), `GET /api/llm/versions/{siteId}` (timeline list), `POST /api/llm/versions/{id}/restore` | +| `com/indie/service/ReferenceService.java` | Add | For URL references: fetch page content/extract info for LLM context. Upload button present for future use; for MVP, URL-only | +| `com/indie/config/RateLimitConfig.java` | Add | Rate limiting: 10 generations/hour/user | +| `src/main/resources/prompts/site-generator.txt` | Add | System prompt: schema definition + 2-3 few-shot examples (blog, portfolio, landing page) | #### Frontend | File | Action | Details | |------|--------|---------| -| `src/app/builder/builder.component.ts` | Add | 3-panel layout (palette 250px, canvas flex, properties 320px) via CSS Grid or mat-sidenav | -| `src/app/builder/services/builder-state.service.ts` | Add | BehaviorSubject store: `components$`, `selectedComponentId$`, `themeConfig$`, `pages$`. Methods: add, remove, update, reorder. Undo/redo stack (50 snapshots) | -| `src/app/builder/services/auto-save.service.ts` | Add | 2s debounce → `PUT` batch save. "Saving..."/"Saved" indicator | -| `src/app/builder/component-palette/` | Add | PaletteComponent — Material list of 10 draggable component types, organized by category | -| `src/app/builder/canvas/` | Add | CanvasComponent — `cdkDropList` accepting drops, renders components in order, highlight on select, delete on hover | -| `src/app/builder/property-panel/` | Add | PropertyPanelComponent — dynamic form per type (config + styles sections). Material expansion panels | -| `src/app/builder/renderer/` | Add | Render components per type (heading, text, image, button, divider, gallery, video, contact_form, map, custom_html) — each takes `config` + `styles` as `@Input()` | -| `src/app/builder/theme-editor/` | Add | ThemeEditorComponent — color pickers, font selectors, spacing slider. Live preview updates | -| `src/app/builder/preview/` | Add | PreviewComponent — sandboxed ` - - - `, -}) -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 deleted file mode 100644 index a9df40d..0000000 --- a/frontend/src/app/builder/services/auto-save.service.ts +++ /dev/null @@ -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(); - 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 deleted file mode 100644 index dfee660..0000000 --- a/frontend/src/app/builder/services/builder-state.service.ts +++ /dev/null @@ -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([]); - 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 deleted file mode 100644 index b0b6515..0000000 --- a/frontend/src/app/builder/theme-editor/theme-editor.component.ts +++ /dev/null @@ -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 = { - 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 index 4523571..3e05b1e 100644 --- a/frontend/src/app/core/models/component.ts +++ b/frontend/src/app/core/models/component.ts @@ -11,37 +11,6 @@ export interface ComponentItem { 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' }; @@ -72,34 +41,36 @@ export function getDefaultStyles(type: ComponentType): Record { } } -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]; +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', +}; + +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', +}; + +export function getComponentLabel(type: string): string { + return LABELS[type] || LABELS[type.toUpperCase()] || 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]; +export function getComponentIcon(type: string): string { + return ICONS[type] || ICONS[type.toUpperCase()] || 'code'; } diff --git a/frontend/src/app/core/models/llm.ts b/frontend/src/app/core/models/llm.ts new file mode 100644 index 0000000..6db5663 --- /dev/null +++ b/frontend/src/app/core/models/llm.ts @@ -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; + fonts: Record; + spacing: Record; +} + +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; + styles: Record; +} + +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; +} diff --git a/frontend/src/app/core/services/llm-api.service.ts b/frontend/src/app/core/services/llm-api.service.ts new file mode 100644 index 0000000..bb772f1 --- /dev/null +++ b/frontend/src/app/core/services/llm-api.service.ts @@ -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 { + return this.http.post('/api/llm/generate', request); + } + + refine(request: LLMRefineRequest): Observable { + return this.http.post('/api/llm/refine', request); + } + + getVersions(siteId: string): Observable { + return this.http.get(`/api/llm/versions/${siteId}`); + } + + restoreVersion(versionId: string): Observable { + return this.http.post(`/api/llm/versions/${versionId}/restore`, {}); + } + + deleteVersion(versionId: string): Observable { + return this.http.delete(`/api/llm/versions/${versionId}`); + } +} diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index cb9fd87..b33a28f 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -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 { diff --git a/frontend/src/app/llm-workspace/delete-version-dialog/delete-version-dialog.ts b/frontend/src/app/llm-workspace/delete-version-dialog/delete-version-dialog.ts new file mode 100644 index 0000000..55f5f6a --- /dev/null +++ b/frontend/src/app/llm-workspace/delete-version-dialog/delete-version-dialog.ts @@ -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: ` +

Delete Version

+ +

Are you sure you want to delete version v{{ data.versionNumber }}?

+

This action cannot be undone.

+
+ + + + + `, +}) +export class DeleteVersionDialog { + dialogRef = inject(MatDialogRef); + data = inject<{ versionNumber: number }>(MAT_DIALOG_DATA); +} diff --git a/frontend/src/app/llm-workspace/llm-session.service.ts b/frontend/src/app/llm-workspace/llm-session.service.ts new file mode 100644 index 0000000..1416c52 --- /dev/null +++ b/frontend/src/app/llm-workspace/llm-session.service.ts @@ -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(''); + private currentResultSubject = new BehaviorSubject(null); + private historySubject = new BehaviorSubject([]); + private generatingSubject = new BehaviorSubject(false); + private errorSubject = new BehaviorSubject(null); + private versionsSubject = new BehaviorSubject([]); + private currentVersionIdSubject = new BehaviorSubject(null); + private loadingVersionsSubject = new BehaviorSubject(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); + } +} diff --git a/frontend/src/app/llm-workspace/llm-workspace.component.ts b/frontend/src/app/llm-workspace/llm-workspace.component.ts new file mode 100644 index 0000000..9866649 --- /dev/null +++ b/frontend/src/app/llm-workspace/llm-workspace.component.ts @@ -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: ` +
+ +
+ + AI Site Generator +
+
+ +
+
+ + + + + Describe your website + + +
+ + Describe the pages, layout, theme, and content you want. + + +
+
+
+ +
+ +
+

Generating your site...

+

This usually takes 15-30 seconds

+
+
+ +
+ error_outline +
+

Generation failed

+

{{ error }}

+ +
+
+ +
+
+ + +
+

History

+ {{ versions.length }} version{{ versions.length !== 1 ? 's' : '' }} +
+
+ +
+
+
+
+ v{{ v.versionNumber }} + {{ v.createdAt | date:'short' }} + +
+

{{ truncatePrompt(v.prompt, 50) }}

+
+

+ No versions yet +

+
+
+
+
+ +
+ + +
+
+

Generated Site Structure

+

+ {{ result.pages.length }} page{{ result.pages.length !== 1 ? 's' : '' }} + · + {{ totalComponents(result) }} component{{ totalComponents(result) !== 1 ? 's' : '' }} +

+
+
+ + +
+
+
+
+ + + +

Prompt

+

{{ currentVersionPrompt }}

+
+
+ + + + + Refinement instructions + + +
+ +
+
+
+ + + +

Theme

+
+
+ Colors +
+ + {{ item.key }}: + {{ item.value }} +
+
+
+ Fonts +
+ {{ item.key }}: + {{ item.value }} +
+
+
+ Spacing +
+ {{ item.key }}: + {{ item.value }} +
+
+
+
+
+ + + + + + {{ page.title }} + + + /{{ page.slug }} + · + {{ page.components.length }} component{{ page.components.length !== 1 ? 's' : '' }} + + +
+
+ circle + {{ getLabel(comp.type) }} +
+
+
+
+
+
+ +
+ auto_awesome +

Describe your dream site

+

+ 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. +

+
+
+
+
+ `, +}) +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(); + + 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(); + } +} diff --git a/frontend/src/app/llm-workspace/preview-dialog/preview-dialog.component.ts b/frontend/src/app/llm-workspace/preview-dialog/preview-dialog.component.ts new file mode 100644 index 0000000..82d590c --- /dev/null +++ b/frontend/src/app/llm-workspace/preview-dialog/preview-dialog.component.ts @@ -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: ` +
+ +
+ + Preview + {{ data.pages.length }} pages +
+ +
+ + + + + + + +
+
+ +
+
+ +
+
+
+ `, +}) +export class PreviewDialogComponent { + device: Device = 'desktop'; + selectedPage = 0; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: SiteStructure, + private dialogRef: MatDialogRef, + 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 `\n
\n${inner}\n
\n`; + }).join('\n'); + + return ` + + + + + ${page.title} + + + + + ${sectionsHtml} + +`; + } + + private buildPageStyles(components: ComponentStructure[], lastIndices: Set): 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)}`; + } + case 'TEXT': { + const content = (config['content'] as string) || ''; + return `
${content}
`; + } + 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 = `${this.esc(alt)}`; + const imgHtml = linkTo ? `${img}` : img; + return caption ? `
${imgHtml}
${this.esc(caption)}
` : 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 `${this.esc(text)}`; + } + case 'DIVIDER': { + const style = (config['style'] as string) || 'solid'; + const color = (config['color'] as string) || 'var(--color-text, #d1d5db)'; + return `
`; + } + 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 `
Gallery (no images)
`; + return `
+ ${images.map(src => ``).join('\n')} +
`; + } + case 'VIDEO': { + const src = (config['src'] as string) || ''; + const controls = config['controls'] !== false; + const autoplay = config['autoplay'] === true; + return ``; + } + case 'CONTACT_FORM': { + const fields = (config['fields'] as string[]) || ['name', 'email', 'message']; + const submitText = (config['submitText'] as string) || 'Send'; + return `
+ ${fields.map(f => ` +
+ + ${f === 'message' ? `` + : ``} +
+ `).join('')} + +
`; + } + case 'MAP': { + const address = (config['address'] as string) || 'New York, NY'; + return `
+ Map: ${this.esc(address)} +
`; + } + case 'CUSTOM_HTML': { + const html = (config['html'] as string) || ''; + return `
${html}
`; + } + default: + return `
[${this.esc(type)}]
`; + } + } + + 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); + } +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f91ee0b..3f60933 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; +}