generate site using llm prompt
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,6 +44,9 @@ docker-data/
|
|||||||
|
|
||||||
# Secrets / keys / certificates
|
# Secrets / keys / certificates
|
||||||
backend/config/
|
backend/config/
|
||||||
|
|
||||||
|
# LLM prompts (IP / business logic - deploy separately)
|
||||||
|
backend/src/main/resources/prompts/
|
||||||
*.key
|
*.key
|
||||||
*.pem
|
*.pem
|
||||||
*.p8
|
*.p8
|
||||||
|
|||||||
215
PLAN.md
215
PLAN.md
@@ -1,6 +1,6 @@
|
|||||||
# Indie — Independent Website Platform
|
# 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 |
|
| Layer | Technology |
|
||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
| Backend | Java 17, Spring Boot 3.x, Gradle (Groovy) |
|
| 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 |
|
| Relational DB | PostgreSQL |
|
||||||
| Document DB | MongoDB (LLM sessions) |
|
| Document DB | MongoDB (generation versions, LLM sessions) |
|
||||||
| Object Storage | Cloudflare R2 (S3-compatible) |
|
| Object Storage | Cloudflare R2 (S3-compatible) |
|
||||||
| LLM | Gemini (primary), OpenAI (fallback), provider-abstraction for future |
|
| LLM | Gemini (primary), OpenAI (fallback), provider-abstraction for future |
|
||||||
| Auth | Spring Security + OAuth2 Client — email/password, Google OAuth, GitHub OAuth, OpenID Connect |
|
| Auth | Spring Security + OAuth2 Client — email/password, Google OAuth, GitHub OAuth, OpenID Connect |
|
||||||
| Git | JGit — local repo per site, optional GitHub/GitLab remote push |
|
| 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)
|
### 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
|
#### 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/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/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/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/Site.java` | Add | id, userId, name, description, subdomain(unique), status(DRAFT/PUBLISHED), publishedAt, createdAt, updatedAt |
|
||||||
| `com/indie/model/Page.java` | Add | id, siteId(FK), title, slug, seoTitle, seoDescription, orderIndex, createdAt |
|
| `com/indie/repository/*.java` | Add | JPA repositories for User, Site |
|
||||||
| `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/service/AuthService.java` | Add | register, login, refreshToken, oauthLogin |
|
| `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/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/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/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
|
#### 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
|
#### Backend
|
||||||
|
|
||||||
| File | Action | Details |
|
| 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/llm/LLMProvider.java` | Add | Interface: `generate(systemPrompt, userPrompt, references, options) → LLMResult` |
|
||||||
| `com/indie/service/BuilderService.java` | Add | Validate component tree (type enum, config schema, max 50/page), save in transaction |
|
| `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<PageStructure> pages`, `SiteTheme theme`. Each `PageStructure`: id, title, slug, `List<ComponentStructure>`. 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
|
#### Frontend
|
||||||
|
|
||||||
| File | Action | Details |
|
| 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/workspace/workspace.component.ts` | Add | Main workspace layout: prompt area (top), preview (center-right), version timeline (right sidebar), content editor (bottom panel) |
|
||||||
| `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/workspace/prompt-input/` | Add | PromptInputComponent — expanding textarea, reference URL chip input, Upload button (file picker, MVP: URL only). Generate + Refine buttons |
|
||||||
| `src/app/builder/services/auto-save.service.ts` | Add | 2s debounce → `PUT` batch save. "Saving..."/"Saved" indicator |
|
| `src/app/workspace/preview/` | Add | PreviewComponent — sandboxed iframe rendering generated HTML (srcdoc). Device toggle (desktop 1200px, tablet 768px, mobile 375px). Styled to feel like a browser window |
|
||||||
| `src/app/builder/component-palette/` | Add | PaletteComponent — Material list of 10 draggable component types, organized by category |
|
| `src/app/workspace/version-timeline/` | Add | VersionTimelineComponent — Material vertical stepper list. Each entry: version number, short prompt summary (truncated), timestamp, thumbnail. Current version highlighted. Click to preview that version. Restore button with confirmation dialog |
|
||||||
| `src/app/builder/canvas/` | Add | CanvasComponent — `cdkDropList` accepting drops, renders components in order, highlight on select, delete on hover |
|
| `src/app/workspace/content-editor/` | Add | ContentEditorComponent — expandable Material accordion grouped by page. Each page section contains editable fields for each text component (heading text, body text, button labels, image alt texts, etc.). Style edits (colors, fonts) go through prompts. Changes update preview in real time |
|
||||||
| `src/app/builder/property-panel/` | Add | PropertyPanelComponent — dynamic form per type (config + styles sections). Material expansion panels |
|
| `src/app/workspace/services/llm-session.service.ts` | Add | Manages: activeVersion, version list, current prompt, references |
|
||||||
| `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/workspace/services/workspace-state.service.ts` | Add | BehaviorSubject store: currentStructure$, versions$, selectedVersion$, draftEdits$, previewHtml$ |
|
||||||
| `src/app/builder/theme-editor/` | Add | ThemeEditorComponent — color pickers, font selectors, spacing slider. Live preview updates |
|
| `src/app/workspace/services/preview-renderer.service.ts` | Add | Takes SiteStructure + any draft edits → generates HTML string for iframe srcdoc |
|
||||||
| `src/app/builder/preview/` | Add | PreviewComponent — sandboxed `<iframe>` with `srcdoc`, responsive device toggles (desktop/tablet/mobile) |
|
|
||||||
| `src/app/builder/builder-api.service.ts` | Add | HTTP calls for save/load components, theme, pages |
|
|
||||||
|
|
||||||
**Component types:** heading, text, image, button, divider, gallery, video, contact_form, map, custom_html
|
**Component types for generated sites:**
|
||||||
|
- heading — text, level (h1-h6)
|
||||||
|
- text — rich text (HTML, sanitized on output)
|
||||||
|
- image — src, alt, caption
|
||||||
|
- button — label, url, style
|
||||||
|
- divider — style, color
|
||||||
|
- gallery — image list, layout
|
||||||
|
- video — embed URL
|
||||||
|
- contact_form — fields config
|
||||||
|
- map — address, zoom
|
||||||
|
- custom_html — raw HTML
|
||||||
|
|
||||||
|
**Version storage:** Each generation attempt creates a MongoDB document (`GenerationVersion`) with the full site structure. Timeline fetched sorted by versionNumber desc. Max 100 versions per site.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### v0.3 — LLM Prompt-to-Site (Target: Weeks 7-8)
|
### v0.3 — Export, Git & Hosting (Target: Weeks 8-11)
|
||||||
|
|
||||||
**Goal:** User types a description of their desired website → AI generates the full site structure (pages + components + theme) → user tweaks in builder or refines via another prompt.
|
|
||||||
|
|
||||||
#### Backend
|
|
||||||
|
|
||||||
| File | Action | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| `com/indie/service/llm/LLMProvider.java` | Add | Interface: `generate(systemPrompt, userPrompt, options) → LLMResult`. `LLMResult`: content, tokenUsage, model, latency |
|
|
||||||
| `com/indie/service/llm/GeminiProvider.java` | Add | Implements LLMProvider via Gemini API. Config: `indie.llm.providers.gemini.api-key`, model |
|
|
||||||
| `com/indie/service/llm/OpenAIProvider.java` | Add | Implements LLMProvider via OpenAI API. Config: `indie.llm.providers.openai.api-key`, model |
|
|
||||||
| `com/indie/service/llm/LLMFactory.java` | Add | Returns configured provider from `indie.llm.active-provider`, fallback chain |
|
|
||||||
| `com/indie/dto/LLMOptions.java` | Add | temperature, maxTokens, timeout, responseFormat |
|
|
||||||
| `com/indie/dto/SiteStructure.java` | Add | Top-level DTO: `List<PageStructure> pages`, `ThemeConfig theme`. Nested types for page and component |
|
|
||||||
| `com/indie/dto/LLMResult.java` | Add | content(String), tokenUsage, model, latencyMs |
|
|
||||||
| `com/indie/model/LLMSession.java` | Add | MongoDB document: userId, prompt, generatedStructure, acceptedAt, refinedPrompt, createdAt |
|
|
||||||
| `com/indie/service/LLMService.java` | Add | Orchestrator: prompt → LLM → parse → validate → return SiteStructure |
|
|
||||||
| `com/indie/service/LLMResponseParser.java` | Add | Parse LLM JSON output, handle markdown fences, malformed fields, partial extraction |
|
|
||||||
| `com/indie/controller/LLMController.java` | Add | `POST /api/llm/generate` (prompt→structure), `POST /api/llm/refine` (structure+prompt→refined). Rate limit: 10/hour/user |
|
|
||||||
| `com/indie/config/RateLimitConfig.java` | Add | Rate limiting via bucket4j |
|
|
||||||
| `src/main/resources/prompts/site-generator.txt` | Add | System prompt: schema definition + 2-3 few-shot examples |
|
|
||||||
|
|
||||||
#### Frontend
|
|
||||||
|
|
||||||
| File | Action | Details |
|
|
||||||
|------|--------|---------|
|
|
||||||
| `src/app/llm-workspace/` | Add | LLMWorkspaceComponent — textarea, "Generate" button, loading spinner with ETA, result preview summary, "Apply to Builder" button |
|
|
||||||
| `src/app/llm-workspace/llm-session.service.ts` | Add | Prompt history, refinement flow (original prompt + user edits + refinement → new generation) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### v0.4 — Export, Git & Hosting (Target: Weeks 9-11)
|
|
||||||
|
|
||||||
**Goal:** User can publish their site as pure static files. Every publish creates a git commit. User can connect GitHub/GitLab for automatic pushes. Sites served at `*.indie.com` for free.
|
**Goal:** User can publish their site as pure static files. Every publish creates a git commit. User can connect GitHub/GitLab for automatic pushes. Sites served at `*.indie.com` for free.
|
||||||
|
|
||||||
@@ -133,8 +157,8 @@ A platform that lets anyone create, own, and publish their own static website wi
|
|||||||
|
|
||||||
| File | Action | Details |
|
| File | Action | Details |
|
||||||
|------|--------|---------|
|
|------|--------|---------|
|
||||||
| `com/indie/service/RenderService.java` | Add | Core render engine: `renderSite(site)`, `renderPage(site, page, components) → String`, `renderCss(theme, components) → String`, `renderJs() → String` |
|
| `com/indie/service/RenderService.java` | Add | Core render engine: `renderSite(structure) → SiteOutput`. Takes SiteStructure → generates HTML, CSS, JS strings. Uses Thymeleaf templates per component type |
|
||||||
| `com/indie/engine/templates/*.html` | Add | Thymeleaf templates per component type + `layout.html` + `head.html` |
|
| `com/indie/engine/templates/*.html` | Add | Thymeleaf templates per component + `layout.html` (page shell) + `head.html` (SEO meta) |
|
||||||
| `com/indie/engine/StyleCompiler.java` | Add | Theme → CSS custom properties at `:root`, component styles → CSS classes, responsive breakpoints |
|
| `com/indie/engine/StyleCompiler.java` | Add | Theme → CSS custom properties at `:root`, component styles → CSS classes, responsive breakpoints |
|
||||||
| `com/indie/engine/JsBundle.java` | Add | Generates `script.js`: nav toggle, smooth scroll, lightbox, contact form handler, comment form handler. Minified |
|
| `com/indie/engine/JsBundle.java` | Add | Generates `script.js`: nav toggle, smooth scroll, lightbox, contact form handler, comment form handler. Minified |
|
||||||
| `com/indie/service/ExportService.java` | Add | `export(siteId) → ZipOutputStream` — full site directory + `.git/` |
|
| `com/indie/service/ExportService.java` | Add | `export(siteId) → ZipOutputStream` — full site directory + `.git/` |
|
||||||
@@ -146,15 +170,15 @@ A platform that lets anyone create, own, and publish their own static website wi
|
|||||||
| `com/indie/service/DeploymentService.java` | Add | On publish: render → write to git working dir → `GitService.commit` → upload to R2 → return public URL |
|
| `com/indie/service/DeploymentService.java` | Add | On publish: render → write to git working dir → `GitService.commit` → upload to R2 → return public URL |
|
||||||
| `com/indie/controller/DeploymentController.java` | Add | `POST /api/sites/{id}/publish`, `/unpublish` |
|
| `com/indie/controller/DeploymentController.java` | Add | `POST /api/sites/{id}/publish`, `/unpublish` |
|
||||||
| `com/indie/service/DomainService.java` | Add | Skeleton: store custom domain mapping in Site entity, prepared for future TLS |
|
| `com/indie/service/DomainService.java` | Add | Skeleton: store custom domain mapping in Site entity, prepared for future TLS |
|
||||||
| `com/indie/service/MediaService.java` | Add | Upload to R2, validate type/size, resize |
|
| `com/indie/service/MediaService.java` | Add | Upload to R2, validate type/size, resize. Upload button present but MVP is URL-only |
|
||||||
| `com/indie/controller/MediaController.java` | Add | `POST /api/media/upload`, `GET /api/media/{id}` |
|
| `com/indie/controller/MediaController.java` | Add | `POST /api/media/upload`, `GET /api/media/{id}` |
|
||||||
|
|
||||||
#### Frontend
|
#### Frontend
|
||||||
|
|
||||||
| File | Action | Details |
|
| File | Action | Details |
|
||||||
|------|--------|---------|
|
|------|--------|---------|
|
||||||
| `src/app/builder/git-settings/` | Add | GitSettingsComponent — connect/disconnect GitHub/GitLab OAuth, select remote repo, connection status |
|
| `src/app/workspace/git-settings/` | Add | GitSettingsComponent — connect/disconnect GitHub/GitLab OAuth, select remote repo, connection status indicator |
|
||||||
| `src/app/builder/settings/` | Add | SettingsComponent — site name, subdomain, export zip button, publish/unpublish buttons |
|
| `src/app/workspace/settings/` | Add | SettingsComponent — site name, subdomain, export zip button, publish/unpublish buttons |
|
||||||
|
|
||||||
#### Infrastructure
|
#### Infrastructure
|
||||||
|
|
||||||
@@ -175,9 +199,24 @@ A platform that lets anyone create, own, and publish their own static website wi
|
|||||||
└── assets/
|
└── assets/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Output file structure (zip export):**
|
||||||
|
```
|
||||||
|
site-export/
|
||||||
|
├── index.html
|
||||||
|
├── about/index.html
|
||||||
|
├── contact/index.html
|
||||||
|
├── style.css
|
||||||
|
├── script.js
|
||||||
|
├── assets/images/
|
||||||
|
│ ├── hero.jpg
|
||||||
|
│ └── gallery-1.jpg
|
||||||
|
├── 404.html
|
||||||
|
└── .git/
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### v0.5 — Social Features & Polish (Target: Weeks 12-13)
|
### v0.4 — Social Features & Polish (Target: Weeks 12-13)
|
||||||
|
|
||||||
**Goal:** Minimal social features (comments, RSS) and final polish before public release.
|
**Goal:** Minimal social features (comments, RSS) and final polish before public release.
|
||||||
|
|
||||||
@@ -194,8 +233,8 @@ A platform that lets anyone create, own, and publish their own static website wi
|
|||||||
|
|
||||||
| File | Action | Details |
|
| File | Action | Details |
|
||||||
|------|--------|---------|
|
|------|--------|---------|
|
||||||
| `src/app/builder/settings/` | Modify | Add git history panel (commit list, rollback button, download repo) |
|
| `src/app/workspace/settings/` | Modify | Add git history panel (commit list, rollback button, download repo button) |
|
||||||
| `src/app/builder/components/comments-section/` | Add | In-builder config (enable/disable, moderate) + renderer for generated sites |
|
| `src/app/workspace/components/comments-section/` | Add | In-workspace config (enable/disable, moderate) + renderer for generated sites |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -222,11 +261,9 @@ A platform that lets anyone create, own, and publish their own static website wi
|
|||||||
│ │ │
|
│ │ │
|
||||||
┌────────▼───┐ ┌──────▼──────┐ ┌───▼────────┐
|
┌────────▼───┐ ┌──────▼──────┐ ┌───▼────────┐
|
||||||
│ PostgreSQL │ │ MongoDB │ │ Git Repos │
|
│ PostgreSQL │ │ MongoDB │ │ Git Repos │
|
||||||
│ (users, │ │ (LLM sess.) │ │ /var/git/ │
|
│ (users, │ │ (versions, │ │ /var/git/ │
|
||||||
│ sites, │ └─────────────┘ └────────────┘
|
│ sites) │ │ sessions) │ └────────────┘
|
||||||
│ pages, │
|
└─────────────┘ └─────────────┘
|
||||||
│ comps) │
|
|
||||||
└─────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -234,13 +271,15 @@ A platform that lets anyone create, own, and publish their own static website wi
|
|||||||
## LLM Provider Abstraction
|
## LLM Provider Abstraction
|
||||||
|
|
||||||
```java
|
```java
|
||||||
// Adding a new provider = implement this interface + add config
|
|
||||||
public interface LLMProvider {
|
public interface LLMProvider {
|
||||||
LLMResult generate(String systemPrompt, String userPrompt, LLMOptions options);
|
LLMResult generate(String systemPrompt, String userPrompt,
|
||||||
|
List<Reference> references, LLMOptions options);
|
||||||
String getName();
|
String getName();
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
// application.yml
|
```yaml
|
||||||
|
# application.yml
|
||||||
indie:
|
indie:
|
||||||
llm:
|
llm:
|
||||||
active-provider: gemini
|
active-provider: gemini
|
||||||
@@ -254,19 +293,23 @@ indie:
|
|||||||
model: gpt-4o-mini
|
model: gpt-4o-mini
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Adding a new provider = implement `LLMProvider` interface + add config block.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key Design Decisions
|
## Key Design Decisions
|
||||||
|
|
||||||
1. **Static output only** — Generated sites are pure HTML/CSS/JS. No Angular/React dependency for end users. They own the output forever, no lock-in.
|
1. **Pure prompt-based** — No drag-and-drop builder. Every site starts from a prompt. Iteration happens through follow-up prompts and inline text edits.
|
||||||
|
|
||||||
2. **Git as source of truth** — Every site is a git repository. Publish = commit. Rollback is a `git checkout`. Export includes full `.git/` history.
|
2. **Static output only** — Generated sites are pure HTML/CSS/JS. No runtime framework dependency. Users own the output forever.
|
||||||
|
|
||||||
3. **Shared rendering** — The same component renderers drive the builder preview AND the static export. What you see is what you get.
|
3. **Git as source of truth** — Every site is a git repository. Publish = commit. Rollback = checkout. Export includes full `.git/`.
|
||||||
|
|
||||||
4. **No billing in MVP** — Subdomain hosting is free. No Stripe/PayPal code. Monetization (paid hosting, custom domains) is a post-MVP concern.
|
4. **Version timeline** — Every generation creates a version in MongoDB. Users can browse, preview, and restore any previous version.
|
||||||
|
|
||||||
5. **Incremental releases** — Each v0.x is independently shippable. If timeline slips, ship what's done.
|
5. **Content editor** — Text edits happen in a structured form grouped by page, not inside the iframe. Style/structure changes go through prompts.
|
||||||
|
|
||||||
|
6. **No billing in MVP** — Subdomain hosting is free. Monetization is post-MVP.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -274,11 +317,10 @@ indie:
|
|||||||
|
|
||||||
| Release | What to Test |
|
| Release | What to Test |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| v0.1 | Register, login (email + OAuth), refresh token, site CRUD, page CRUD, 401 on unauthenticated |
|
| v0.1 | Register, login (email + OAuth), refresh token, site CRUD, 401 on unauthenticated |
|
||||||
| v0.2 | Drag → reorder → save → reload matches, theme change reflects in preview, undo/redo, all 10 component types render |
|
| v0.2 | Prompt → valid SiteStructure generated, preview renders correctly, version timeline shows history, restore works, content editor updates preview, refinement prompt modifies existing structure, reference URLs included in generation |
|
||||||
| v0.3 | LLM generates valid SiteStructure JSON, generation loads into builder, refinement updates correctly, error handling (timeout, bad JSON) |
|
| v0.3 | Exported zip opens in browser identically to preview, git init + commit works, git push to remote works, R2 upload succeeds, subdomain serves content |
|
||||||
| v0.4 | Exported zip opens in browser identically to preview, git init + commit works, git push to remote works, R2 upload succeeds, subdomain serves content |
|
| v0.4 | Comment submit → approve → display, RSS validates, rollback restores previous version |
|
||||||
| v0.5 | Comment submit → approve → display, RSS validates, image upload + resize, rollback restores previous version |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -286,8 +328,7 @@ indie:
|
|||||||
|
|
||||||
| Risk | Likelihood | Mitigation |
|
| Risk | Likelihood | Mitigation |
|
||||||
|------|-----------|------------|
|
|------|-----------|------------|
|
||||||
| Scope creep (adding features mid-phase) | High | Strict feature freeze per v0.x; ship as-is |
|
| LLM output quality inconsistent (most critical risk) | High | Invest heavily in system prompt + few-shot examples; solid response parser with fallbacks; allow manual text edits as safety net |
|
||||||
| Drag-drop performance with many components | Medium | Cap at 50 components/page; virtual scroll if needed |
|
| Scope creep | High | Strict feature freeze per v0.x; ship as-is |
|
||||||
| LLM output quality inconsistent | Medium | Iterate on system prompt; template gallery fallback |
|
|
||||||
| Solo developer burnout | Medium | Realistic timeline; celebrate each release |
|
|
||||||
| LLM API costs | Low | Gemini-2.0-flash is free-tier friendly; rate limit 10/hour/user |
|
| LLM API costs | Low | Gemini-2.0-flash is free-tier friendly; rate limit 10/hour/user |
|
||||||
|
| Solo developer burnout | Medium | Realistic timeline; 4 releases instead of 5 saves ~2 weeks |
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
|
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
|
||||||
|
|
||||||
|
implementation "com.bucket4j:bucket4j-core:8.10.1"
|
||||||
|
|
||||||
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
|
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
|
||||||
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
|
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
|
||||||
|
|||||||
40
backend/src/main/java/com/indie/config/RateLimitConfig.java
Normal file
40
backend/src/main/java/com/indie/config/RateLimitConfig.java
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package com.indie.config;
|
||||||
|
|
||||||
|
import io.github.bucket4j.Bandwidth;
|
||||||
|
import io.github.bucket4j.Bucket;
|
||||||
|
import io.github.bucket4j.Refill;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class RateLimitConfig {
|
||||||
|
|
||||||
|
private final Map<UUID, Bucket> cache = new ConcurrentHashMap<>();
|
||||||
|
private final int maxRequestsPerHour;
|
||||||
|
|
||||||
|
public RateLimitConfig(
|
||||||
|
@Value("${indie.llm.rate-limit.max-requests-per-hour:10}") int maxRequestsPerHour) {
|
||||||
|
this.maxRequestsPerHour = maxRequestsPerHour;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean tryConsume(UUID userId) {
|
||||||
|
Bucket bucket = cache.computeIfAbsent(userId, this::createBucket);
|
||||||
|
return bucket.tryConsume(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getAvailableTokens(UUID userId) {
|
||||||
|
Bucket bucket = cache.computeIfAbsent(userId, this::createBucket);
|
||||||
|
return (int) bucket.getAvailableTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Bucket createBucket(UUID userId) {
|
||||||
|
Bandwidth limit = Bandwidth.classic(maxRequestsPerHour,
|
||||||
|
Refill.greedy(maxRequestsPerHour, Duration.ofHours(1)));
|
||||||
|
return Bucket.builder().addLimit(limit).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
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<List<PageWithComponentsResponse>> listPagesWithComponents(
|
|
||||||
@AuthenticationPrincipal UserPrincipal principal,
|
|
||||||
@PathVariable UUID siteId) {
|
|
||||||
Site site = findSiteForUser(siteId, principal.id());
|
|
||||||
List<Page> pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(site.getId());
|
|
||||||
|
|
||||||
List<PageWithComponentsResponse> response = pages.stream()
|
|
||||||
.map(page -> {
|
|
||||||
List<ComponentResponse> 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<List<ComponentResponse>> 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<Component> components = builderService.saveComponents(page.getId(), request);
|
|
||||||
|
|
||||||
List<ComponentResponse> response = components.stream()
|
|
||||||
.map(ComponentResponse::from)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/theme")
|
|
||||||
public ResponseEntity<Map<String, Object>> getTheme(
|
|
||||||
@AuthenticationPrincipal UserPrincipal principal,
|
|
||||||
@PathVariable UUID siteId) {
|
|
||||||
Site site = findSiteForUser(siteId, principal.id());
|
|
||||||
return ResponseEntity.ok(site.getThemeConfig());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/theme")
|
|
||||||
public ResponseEntity<Map<String, Object>> updateTheme(
|
|
||||||
@AuthenticationPrincipal UserPrincipal principal,
|
|
||||||
@PathVariable UUID siteId,
|
|
||||||
@RequestBody Map<String, Object> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
138
backend/src/main/java/com/indie/controller/LLMController.java
Normal file
138
backend/src/main/java/com/indie/controller/LLMController.java
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package com.indie.controller;
|
||||||
|
|
||||||
|
import com.indie.config.RateLimitConfig;
|
||||||
|
import com.indie.config.UserPrincipal;
|
||||||
|
import com.indie.dto.Reference;
|
||||||
|
import com.indie.dto.SiteStructure;
|
||||||
|
import com.indie.model.GenerationVersion;
|
||||||
|
import com.indie.service.LLMService;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
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/llm")
|
||||||
|
public class LLMController {
|
||||||
|
|
||||||
|
private final LLMService llmService;
|
||||||
|
private final RateLimitConfig rateLimitConfig;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public LLMController(LLMService llmService, RateLimitConfig rateLimitConfig, ObjectMapper objectMapper) {
|
||||||
|
this.llmService = llmService;
|
||||||
|
this.rateLimitConfig = rateLimitConfig;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/generate")
|
||||||
|
public ResponseEntity<?> generate(
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@RequestBody Map<String, Object> request) {
|
||||||
|
if (!rateLimitConfig.tryConsume(principal.id())) {
|
||||||
|
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
.body(Map.of("error", "Rate limit exceeded. Max " +
|
||||||
|
rateLimitConfig.getAvailableTokens(principal.id()) +
|
||||||
|
" requests per hour."));
|
||||||
|
}
|
||||||
|
|
||||||
|
String prompt = (String) request.get("prompt");
|
||||||
|
if (prompt == null || prompt.isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Prompt is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String siteIdStr = (String) request.get("siteId");
|
||||||
|
if (siteIdStr == null || siteIdStr.isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "siteId is required"));
|
||||||
|
}
|
||||||
|
UUID siteId = UUID.fromString(siteIdStr);
|
||||||
|
|
||||||
|
List<Reference> references = null;
|
||||||
|
if (request.containsKey("references")) {
|
||||||
|
references = objectMapper.convertValue(
|
||||||
|
request.get("references"), new TypeReference<List<Reference>>() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
SiteStructure structure = llmService.generateSite(principal.id(), siteId, prompt, references);
|
||||||
|
return ResponseEntity.ok(structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/refine")
|
||||||
|
public ResponseEntity<?> refine(
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@RequestBody Map<String, Object> request) {
|
||||||
|
if (!rateLimitConfig.tryConsume(principal.id())) {
|
||||||
|
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
.body(Map.of("error", "Rate limit exceeded. Max " +
|
||||||
|
rateLimitConfig.getAvailableTokens(principal.id()) +
|
||||||
|
" requests per hour."));
|
||||||
|
}
|
||||||
|
|
||||||
|
String prompt = (String) request.get("prompt");
|
||||||
|
if (prompt == null || prompt.isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Prompt is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String siteIdStr = (String) request.get("siteId");
|
||||||
|
if (siteIdStr == null || siteIdStr.isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "siteId is required"));
|
||||||
|
}
|
||||||
|
UUID siteId = UUID.fromString(siteIdStr);
|
||||||
|
|
||||||
|
SiteStructure currentStructure;
|
||||||
|
try {
|
||||||
|
currentStructure = objectMapper.convertValue(
|
||||||
|
request.get("currentStructure"), SiteStructure.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid currentStructure"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStructure == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "currentStructure is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Reference> references = null;
|
||||||
|
if (request.containsKey("references")) {
|
||||||
|
references = objectMapper.convertValue(
|
||||||
|
request.get("references"), new TypeReference<List<Reference>>() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
SiteStructure structure = llmService.refineSite(principal.id(), siteId,
|
||||||
|
currentStructure, prompt, references);
|
||||||
|
return ResponseEntity.ok(structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/versions/{siteId}")
|
||||||
|
public ResponseEntity<List<GenerationVersion>> getVersions(
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable UUID siteId) {
|
||||||
|
List<GenerationVersion> versions = llmService.getVersions(siteId);
|
||||||
|
return ResponseEntity.ok(versions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/versions/{id}/restore")
|
||||||
|
public ResponseEntity<?> restoreVersion(
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable String id) {
|
||||||
|
SiteStructure structure = llmService.restoreVersion(id);
|
||||||
|
return ResponseEntity.ok(structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/versions/{id}")
|
||||||
|
public ResponseEntity<?> deleteVersion(
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable String id) {
|
||||||
|
try {
|
||||||
|
llmService.deleteVersion(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
package com.indie.controller;
|
|
||||||
|
|
||||||
import com.indie.config.UserPrincipal;
|
|
||||||
import com.indie.dto.PageRequest;
|
|
||||||
import com.indie.dto.PageResponse;
|
|
||||||
import com.indie.model.Page;
|
|
||||||
import com.indie.model.Site;
|
|
||||||
import com.indie.repository.PageRepository;
|
|
||||||
import com.indie.repository.SiteRepository;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
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.UUID;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/sites/{siteId}/pages")
|
|
||||||
public class PageController {
|
|
||||||
|
|
||||||
private final PageRepository pageRepository;
|
|
||||||
private final SiteRepository siteRepository;
|
|
||||||
|
|
||||||
public PageController(PageRepository pageRepository, SiteRepository siteRepository) {
|
|
||||||
this.pageRepository = pageRepository;
|
|
||||||
this.siteRepository = siteRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
public ResponseEntity<List<PageResponse>> listPages(@AuthenticationPrincipal UserPrincipal principal,
|
|
||||||
@PathVariable UUID siteId) {
|
|
||||||
Site site = findSiteForUser(siteId, principal.id());
|
|
||||||
List<Page> pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(site.getId());
|
|
||||||
List<PageResponse> response = pages.stream()
|
|
||||||
.map(PageResponse::from)
|
|
||||||
.toList();
|
|
||||||
return ResponseEntity.ok(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
public ResponseEntity<PageResponse> createPage(@AuthenticationPrincipal UserPrincipal principal,
|
|
||||||
@PathVariable UUID siteId,
|
|
||||||
@Valid @RequestBody PageRequest request) {
|
|
||||||
Site site = findSiteForUser(siteId, principal.id());
|
|
||||||
|
|
||||||
if (pageRepository.existsBySiteIdAndSlug(site.getId(), request.getSlug())) {
|
|
||||||
throw new IllegalArgumentException("A page with this slug already exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
Page page = Page.builder()
|
|
||||||
.siteId(site.getId())
|
|
||||||
.title(request.getTitle())
|
|
||||||
.slug(request.getSlug())
|
|
||||||
.seoTitle(request.getSeoTitle())
|
|
||||||
.seoDescription(request.getSeoDescription())
|
|
||||||
.orderIndex(request.getOrderIndex())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
page = pageRepository.save(page);
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(PageResponse.from(page));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/{pageId}")
|
|
||||||
public ResponseEntity<PageResponse> getPage(@AuthenticationPrincipal UserPrincipal principal,
|
|
||||||
@PathVariable UUID siteId,
|
|
||||||
@PathVariable UUID pageId) {
|
|
||||||
Site site = findSiteForUser(siteId, principal.id());
|
|
||||||
Page page = findPageForSite(pageId, site.getId());
|
|
||||||
return ResponseEntity.ok(PageResponse.from(page));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/{pageId}")
|
|
||||||
public ResponseEntity<PageResponse> updatePage(@AuthenticationPrincipal UserPrincipal principal,
|
|
||||||
@PathVariable UUID siteId,
|
|
||||||
@PathVariable UUID pageId,
|
|
||||||
@Valid @RequestBody PageRequest request) {
|
|
||||||
Site site = findSiteForUser(siteId, principal.id());
|
|
||||||
Page page = findPageForSite(pageId, site.getId());
|
|
||||||
|
|
||||||
if (!page.getSlug().equals(request.getSlug())
|
|
||||||
&& pageRepository.existsBySiteIdAndSlug(site.getId(), request.getSlug())) {
|
|
||||||
throw new IllegalArgumentException("A page with this slug already exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
page.setTitle(request.getTitle());
|
|
||||||
page.setSlug(request.getSlug());
|
|
||||||
page.setSeoTitle(request.getSeoTitle());
|
|
||||||
page.setSeoDescription(request.getSeoDescription());
|
|
||||||
page.setOrderIndex(request.getOrderIndex());
|
|
||||||
|
|
||||||
page = pageRepository.save(page);
|
|
||||||
return ResponseEntity.ok(PageResponse.from(page));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{pageId}")
|
|
||||||
public ResponseEntity<Void> deletePage(@AuthenticationPrincipal UserPrincipal principal,
|
|
||||||
@PathVariable UUID siteId,
|
|
||||||
@PathVariable UUID pageId) {
|
|
||||||
Site site = findSiteForUser(siteId, principal.id());
|
|
||||||
Page page = findPageForSite(pageId, site.getId());
|
|
||||||
pageRepository.delete(page);
|
|
||||||
return ResponseEntity.noContent().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,7 +46,6 @@ public class SiteController {
|
|||||||
.name(request.getName())
|
.name(request.getName())
|
||||||
.description(request.getDescription())
|
.description(request.getDescription())
|
||||||
.subdomain(subdomain)
|
.subdomain(subdomain)
|
||||||
.themeConfig(request.getThemeConfig())
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
site = siteRepository.save(site);
|
site = siteRepository.save(site);
|
||||||
@@ -76,9 +75,6 @@ public class SiteController {
|
|||||||
site.setName(request.getName());
|
site.setName(request.getName());
|
||||||
site.setDescription(request.getDescription());
|
site.setDescription(request.getDescription());
|
||||||
site.setSubdomain(newSubdomain);
|
site.setSubdomain(newSubdomain);
|
||||||
if (request.getThemeConfig() != null) {
|
|
||||||
site.setThemeConfig(request.getThemeConfig());
|
|
||||||
}
|
|
||||||
|
|
||||||
site = siteRepository.save(site);
|
site = siteRepository.save(site);
|
||||||
return ResponseEntity.ok(SiteResponse.from(site));
|
return ResponseEntity.ok(SiteResponse.from(site));
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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<String, Object> config;
|
|
||||||
private Map<String, Object> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
backend/src/main/java/com/indie/dto/LLMOptions.java
Normal file
20
backend/src/main/java/com/indie/dto/LLMOptions.java
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package com.indie.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class LLMOptions {
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private double temperature = 0.7;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private int maxTokens = 4096;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private int timeoutSeconds = 60;
|
||||||
|
}
|
||||||
17
backend/src/main/java/com/indie/dto/LLMResult.java
Normal file
17
backend/src/main/java/com/indie/dto/LLMResult.java
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package com.indie.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class LLMResult {
|
||||||
|
|
||||||
|
private String content;
|
||||||
|
private int promptTokens;
|
||||||
|
private int completionTokens;
|
||||||
|
private String model;
|
||||||
|
private long latencyMs;
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package com.indie.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.Pattern;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class PageRequest {
|
|
||||||
|
|
||||||
@NotBlank(message = "Page title is required")
|
|
||||||
private String title;
|
|
||||||
|
|
||||||
@NotBlank(message = "Page slug is required")
|
|
||||||
@Pattern(regexp = "^[a-z0-9-]+$", message = "Slug must be lowercase alphanumeric with hyphens")
|
|
||||||
private String slug;
|
|
||||||
|
|
||||||
private String seoTitle;
|
|
||||||
|
|
||||||
private String seoDescription;
|
|
||||||
|
|
||||||
private int orderIndex;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package com.indie.dto;
|
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Builder
|
|
||||||
public class PageResponse {
|
|
||||||
|
|
||||||
private UUID id;
|
|
||||||
private UUID siteId;
|
|
||||||
private String title;
|
|
||||||
private String slug;
|
|
||||||
private String seoTitle;
|
|
||||||
private String seoDescription;
|
|
||||||
private int orderIndex;
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
public static PageResponse from(com.indie.model.Page page) {
|
|
||||||
return PageResponse.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())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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<ComponentResponse> components;
|
|
||||||
|
|
||||||
public static PageWithComponentsResponse from(Page page, List<ComponentResponse> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
backend/src/main/java/com/indie/dto/Reference.java
Normal file
19
backend/src/main/java/com/indie/dto/Reference.java
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package com.indie.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class Reference {
|
||||||
|
|
||||||
|
private ReferenceType type;
|
||||||
|
private String url;
|
||||||
|
private String altText;
|
||||||
|
|
||||||
|
public enum ReferenceType {
|
||||||
|
URL, IMAGE
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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<ComponentDTO> components;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public static class ComponentDTO {
|
|
||||||
private UUID id;
|
|
||||||
@NotNull
|
|
||||||
private ComponentType type;
|
|
||||||
private Map<String, Object> config;
|
|
||||||
private Map<String, Object> styles;
|
|
||||||
private int orderIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,6 @@ package com.indie.dto;
|
|||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class SiteRequest {
|
public class SiteRequest {
|
||||||
|
|
||||||
@@ -14,6 +12,4 @@ public class SiteRequest {
|
|||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
private String subdomain;
|
private String subdomain;
|
||||||
|
|
||||||
private Map<String, Object> themeConfig;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package com.indie.dto;
|
package com.indie.dto;
|
||||||
|
|
||||||
|
import com.indie.model.Site;
|
||||||
import com.indie.model.SiteStatus;
|
import com.indie.model.SiteStatus;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@@ -19,20 +19,18 @@ public class SiteResponse {
|
|||||||
private String name;
|
private String name;
|
||||||
private String description;
|
private String description;
|
||||||
private String subdomain;
|
private String subdomain;
|
||||||
private Map<String, Object> themeConfig;
|
|
||||||
private SiteStatus status;
|
private SiteStatus status;
|
||||||
private LocalDateTime publishedAt;
|
private LocalDateTime publishedAt;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
public static SiteResponse from(com.indie.model.Site site) {
|
public static SiteResponse from(Site site) {
|
||||||
return SiteResponse.builder()
|
return SiteResponse.builder()
|
||||||
.id(site.getId())
|
.id(site.getId())
|
||||||
.userId(site.getUserId())
|
.userId(site.getUserId())
|
||||||
.name(site.getName())
|
.name(site.getName())
|
||||||
.description(site.getDescription())
|
.description(site.getDescription())
|
||||||
.subdomain(site.getSubdomain())
|
.subdomain(site.getSubdomain())
|
||||||
.themeConfig(site.getThemeConfig())
|
|
||||||
.status(site.getStatus())
|
.status(site.getStatus())
|
||||||
.publishedAt(site.getPublishedAt())
|
.publishedAt(site.getPublishedAt())
|
||||||
.createdAt(site.getCreatedAt())
|
.createdAt(site.getCreatedAt())
|
||||||
|
|||||||
50
backend/src/main/java/com/indie/dto/SiteStructure.java
Normal file
50
backend/src/main/java/com/indie/dto/SiteStructure.java
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package com.indie.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class SiteStructure {
|
||||||
|
|
||||||
|
private ThemeConfig theme;
|
||||||
|
private List<PageStructure> pages;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public static class ThemeConfig {
|
||||||
|
private Map<String, String> colors;
|
||||||
|
private Map<String, String> fonts;
|
||||||
|
private Map<String, String> spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public static class PageStructure {
|
||||||
|
private String id;
|
||||||
|
private String title;
|
||||||
|
private String slug;
|
||||||
|
private String seoTitle;
|
||||||
|
private String seoDescription;
|
||||||
|
private int orderIndex;
|
||||||
|
private List<ComponentStructure> components;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public static class ComponentStructure {
|
||||||
|
private String id;
|
||||||
|
private String type;
|
||||||
|
private int orderIndex;
|
||||||
|
private Map<String, Object> config;
|
||||||
|
private Map<String, Object> styles;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package com.indie.model;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.hibernate.annotations.JdbcTypeCode;
|
|
||||||
import org.hibernate.type.SqlTypes;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "components")
|
|
||||||
@Data
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Builder
|
|
||||||
public class Component {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
private UUID id;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private UUID pageId;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(nullable = false)
|
|
||||||
private ComponentType type;
|
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
|
||||||
private Map<String, Object> config;
|
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
|
||||||
private Map<String, Object> styles;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private int orderIndex;
|
|
||||||
|
|
||||||
@PrePersist
|
|
||||||
void onCreate() {
|
|
||||||
if (id == null) {
|
|
||||||
id = UUID.randomUUID();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package com.indie.model;
|
|
||||||
|
|
||||||
public enum ComponentType {
|
|
||||||
HEADING, TEXT, IMAGE, BUTTON, DIVIDER, GALLERY, VIDEO, CONTACT_FORM, MAP, CUSTOM_HTML
|
|
||||||
}
|
|
||||||
41
backend/src/main/java/com/indie/model/GenerationVersion.java
Normal file
41
backend/src/main/java/com/indie/model/GenerationVersion.java
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package com.indie.model;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Document(collection = "generation_versions")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class GenerationVersion {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
private UUID siteId;
|
||||||
|
private UUID userId;
|
||||||
|
private int versionNumber;
|
||||||
|
private String prompt;
|
||||||
|
private List<ReferenceEntry> references;
|
||||||
|
private String fullStructure;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public static class ReferenceEntry {
|
||||||
|
private String type;
|
||||||
|
private String url;
|
||||||
|
private String altText;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package com.indie.model;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "pages")
|
|
||||||
@Data
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Builder
|
|
||||||
public class Page {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
private UUID id;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private UUID siteId;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private String title;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private String slug;
|
|
||||||
|
|
||||||
private String seoTitle;
|
|
||||||
|
|
||||||
@Column(length = 500)
|
|
||||||
private String seoDescription;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private int orderIndex;
|
|
||||||
|
|
||||||
@Column(nullable = false, updatable = false)
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@PrePersist
|
|
||||||
void onCreate() {
|
|
||||||
if (id == null) {
|
|
||||||
id = UUID.randomUUID();
|
|
||||||
}
|
|
||||||
createdAt = LocalDateTime.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,11 +5,8 @@ import lombok.AllArgsConstructor;
|
|||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import org.hibernate.annotations.JdbcTypeCode;
|
|
||||||
import org.hibernate.type.SqlTypes;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -35,9 +32,6 @@ public class Site {
|
|||||||
@Column(unique = true)
|
@Column(unique = true)
|
||||||
private String subdomain;
|
private String subdomain;
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
|
||||||
private Map<String, Object> themeConfig;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package com.indie.repository;
|
|
||||||
|
|
||||||
import com.indie.model.Component;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface ComponentRepository extends JpaRepository<Component, UUID> {
|
|
||||||
|
|
||||||
List<Component> findByPageIdOrderByOrderIndexAsc(UUID pageId);
|
|
||||||
|
|
||||||
void deleteByPageId(UUID pageId);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package com.indie.repository;
|
|
||||||
|
|
||||||
import com.indie.model.Page;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface PageRepository extends JpaRepository<Page, UUID> {
|
|
||||||
|
|
||||||
List<Page> findBySiteIdOrderByOrderIndexAsc(UUID siteId);
|
|
||||||
|
|
||||||
Optional<Page> findBySiteIdAndSlug(UUID siteId, String slug);
|
|
||||||
|
|
||||||
boolean existsBySiteIdAndSlug(UUID siteId, String slug);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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<Component> saveComponents(UUID pageId, SaveComponentsRequest request) {
|
|
||||||
validateComponents(request);
|
|
||||||
|
|
||||||
componentRepository.deleteByPageId(pageId);
|
|
||||||
|
|
||||||
List<Component> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
191
backend/src/main/java/com/indie/service/LLMResponseParser.java
Normal file
191
backend/src/main/java/com/indie/service/LLMResponseParser.java
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package com.indie.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.indie.dto.SiteStructure;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class LLMResponseParser {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LLMResponseParser.class);
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public LLMResponseParser(ObjectMapper objectMapper) {
|
||||||
|
this.objectMapper = objectMapper.copy()
|
||||||
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SiteStructure parse(String llmOutput) {
|
||||||
|
String json = extractJson(llmOutput);
|
||||||
|
try {
|
||||||
|
JsonNode root = objectMapper.readTree(json);
|
||||||
|
return parseSiteStructure(root);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalArgumentException("Failed to parse LLM output as JSON: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String extractJson(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("LLM output is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
String trimmed = raw.trim();
|
||||||
|
|
||||||
|
if (trimmed.startsWith("```")) {
|
||||||
|
int start = trimmed.indexOf('\n');
|
||||||
|
int end = trimmed.lastIndexOf("```");
|
||||||
|
if (start > 0 && end > start) {
|
||||||
|
trimmed = trimmed.substring(start, end).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed.startsWith("{")) {
|
||||||
|
int brace = trimmed.indexOf('{');
|
||||||
|
if (brace >= 0) {
|
||||||
|
trimmed = trimmed.substring(brace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed.endsWith("}")) {
|
||||||
|
int brace = trimmed.lastIndexOf('}');
|
||||||
|
if (brace >= 0) {
|
||||||
|
trimmed = trimmed.substring(0, brace + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SiteStructure parseSiteStructure(JsonNode root) {
|
||||||
|
SiteStructure.SiteStructureBuilder builder = SiteStructure.builder();
|
||||||
|
|
||||||
|
if (root.has("theme")) {
|
||||||
|
builder.theme(parseTheme(root.get("theme")));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SiteStructure.PageStructure> pages = new ArrayList<>();
|
||||||
|
if (root.has("pages") && root.get("pages").isArray()) {
|
||||||
|
for (JsonNode pageNode : root.get("pages")) {
|
||||||
|
pages.add(parsePage(pageNode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.pages(pages);
|
||||||
|
|
||||||
|
SiteStructure structure = builder.build();
|
||||||
|
if (structure.getPages() == null || structure.getPages().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("LLM output must contain at least one page");
|
||||||
|
}
|
||||||
|
|
||||||
|
return structure;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SiteStructure.ThemeConfig parseTheme(JsonNode themeNode) {
|
||||||
|
SiteStructure.ThemeConfig.ThemeConfigBuilder builder = SiteStructure.ThemeConfig.builder();
|
||||||
|
|
||||||
|
if (themeNode.has("colors")) {
|
||||||
|
builder.colors(parseStringMap(themeNode.get("colors")));
|
||||||
|
}
|
||||||
|
if (themeNode.has("fonts")) {
|
||||||
|
builder.fonts(parseStringMap(themeNode.get("fonts")));
|
||||||
|
}
|
||||||
|
if (themeNode.has("spacing")) {
|
||||||
|
builder.spacing(parseStringMap(themeNode.get("spacing")));
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> parseStringMap(JsonNode node) {
|
||||||
|
Map<String, String> map = new java.util.LinkedHashMap<>();
|
||||||
|
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
|
||||||
|
while (fields.hasNext()) {
|
||||||
|
Map.Entry<String, JsonNode> field = fields.next();
|
||||||
|
map.put(field.getKey(), field.getValue().asText());
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SiteStructure.PageStructure parsePage(JsonNode pageNode) {
|
||||||
|
SiteStructure.PageStructure.PageStructureBuilder builder = SiteStructure.PageStructure.builder();
|
||||||
|
|
||||||
|
if (pageNode.has("id")) {
|
||||||
|
builder.id(pageNode.get("id").asText());
|
||||||
|
} else {
|
||||||
|
builder.id(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageNode.has("title")) {
|
||||||
|
builder.title(pageNode.get("title").asText());
|
||||||
|
}
|
||||||
|
if (pageNode.has("slug")) {
|
||||||
|
builder.slug(pageNode.get("slug").asText());
|
||||||
|
}
|
||||||
|
if (pageNode.has("seoTitle")) {
|
||||||
|
builder.seoTitle(pageNode.get("seoTitle").asText());
|
||||||
|
}
|
||||||
|
if (pageNode.has("seoDescription")) {
|
||||||
|
builder.seoDescription(pageNode.get("seoDescription").asText());
|
||||||
|
}
|
||||||
|
if (pageNode.has("orderIndex")) {
|
||||||
|
builder.orderIndex(pageNode.get("orderIndex").asInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SiteStructure.ComponentStructure> components = new ArrayList<>();
|
||||||
|
if (pageNode.has("components") && pageNode.get("components").isArray()) {
|
||||||
|
for (JsonNode compNode : pageNode.get("components")) {
|
||||||
|
components.add(parseComponent(compNode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.components(components);
|
||||||
|
|
||||||
|
SiteStructure.PageStructure page = builder.build();
|
||||||
|
if (page.getTitle() == null || page.getSlug() == null) {
|
||||||
|
throw new IllegalArgumentException("Page must have title and slug");
|
||||||
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SiteStructure.ComponentStructure parseComponent(JsonNode compNode) {
|
||||||
|
SiteStructure.ComponentStructure.ComponentStructureBuilder builder = SiteStructure.ComponentStructure.builder();
|
||||||
|
|
||||||
|
if (compNode.has("id")) {
|
||||||
|
builder.id(compNode.get("id").asText());
|
||||||
|
} else {
|
||||||
|
builder.id(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compNode.has("type")) {
|
||||||
|
builder.type(compNode.get("type").asText());
|
||||||
|
}
|
||||||
|
if (compNode.has("orderIndex")) {
|
||||||
|
builder.orderIndex(compNode.get("orderIndex").asInt());
|
||||||
|
}
|
||||||
|
if (compNode.has("config")) {
|
||||||
|
builder.config(objectMapper.convertValue(compNode.get("config"), Map.class));
|
||||||
|
}
|
||||||
|
if (compNode.has("styles")) {
|
||||||
|
builder.styles(objectMapper.convertValue(compNode.get("styles"), Map.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
SiteStructure.ComponentStructure component = builder.build();
|
||||||
|
if (component.getType() == null) {
|
||||||
|
throw new IllegalArgumentException("Component must have a type");
|
||||||
|
}
|
||||||
|
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
}
|
||||||
191
backend/src/main/java/com/indie/service/LLMService.java
Normal file
191
backend/src/main/java/com/indie/service/LLMService.java
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package com.indie.service;
|
||||||
|
|
||||||
|
import com.indie.dto.LLMOptions;
|
||||||
|
import com.indie.dto.LLMResult;
|
||||||
|
import com.indie.dto.Reference;
|
||||||
|
import com.indie.dto.SiteStructure;
|
||||||
|
import com.indie.model.GenerationVersion;
|
||||||
|
import com.indie.service.llm.LLMFactory;
|
||||||
|
import com.indie.service.llm.LLMProvider;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.ResourceLoader;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||||
|
import org.springframework.data.mongodb.core.query.Criteria;
|
||||||
|
import org.springframework.data.mongodb.core.query.Query;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class LLMService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LLMService.class);
|
||||||
|
private static final int MAX_VERSIONS_PER_SITE = 100;
|
||||||
|
|
||||||
|
private final LLMFactory llmFactory;
|
||||||
|
private final LLMResponseParser responseParser;
|
||||||
|
private final ReferenceService referenceService;
|
||||||
|
private final MongoTemplate mongoTemplate;
|
||||||
|
private final String systemPrompt;
|
||||||
|
|
||||||
|
public LLMService(LLMFactory llmFactory,
|
||||||
|
LLMResponseParser responseParser,
|
||||||
|
ReferenceService referenceService,
|
||||||
|
MongoTemplate mongoTemplate,
|
||||||
|
ResourceLoader resourceLoader,
|
||||||
|
@Value("${indie.llm.system-prompt-path:classpath:prompts/site-generator.txt}") String promptPath) {
|
||||||
|
this.llmFactory = llmFactory;
|
||||||
|
this.responseParser = responseParser;
|
||||||
|
this.referenceService = referenceService;
|
||||||
|
this.mongoTemplate = mongoTemplate;
|
||||||
|
this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt, List<Reference> references) {
|
||||||
|
String referenceContext = referenceService.buildReferenceContext(references);
|
||||||
|
String fullPrompt = prompt + referenceContext;
|
||||||
|
|
||||||
|
LLMOptions options = LLMOptions.builder().build();
|
||||||
|
LLMResult result = callLLM(systemPrompt, fullPrompt, references, options);
|
||||||
|
log.info("LLM generation completed: model={}, latency={}ms, tokens={}+{}",
|
||||||
|
result.getModel(), result.getLatencyMs(),
|
||||||
|
result.getPromptTokens(), result.getCompletionTokens());
|
||||||
|
|
||||||
|
SiteStructure structure = responseParser.parse(result.getContent());
|
||||||
|
saveVersion(userId, siteId, prompt, references, result.getContent());
|
||||||
|
|
||||||
|
return structure;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||||
|
String refinementPrompt, List<Reference> references) {
|
||||||
|
String currentJson = serializeStructure(currentStructure);
|
||||||
|
String referenceContext = referenceService.buildReferenceContext(references);
|
||||||
|
String combinedPrompt = "Current site structure:\n" + currentJson
|
||||||
|
+ "\n\nRefinement request: " + refinementPrompt
|
||||||
|
+ referenceContext
|
||||||
|
+ "\n\nOutput the complete updated JSON structure with the requested changes applied.";
|
||||||
|
|
||||||
|
LLMOptions options = LLMOptions.builder()
|
||||||
|
.temperature(0.5)
|
||||||
|
.build();
|
||||||
|
LLMResult result = callLLM(systemPrompt, combinedPrompt, references, options);
|
||||||
|
log.info("LLM refinement completed: model={}, latency={}ms, tokens={}+{}",
|
||||||
|
result.getModel(), result.getLatencyMs(),
|
||||||
|
result.getPromptTokens(), result.getCompletionTokens());
|
||||||
|
|
||||||
|
SiteStructure structure = responseParser.parse(result.getContent());
|
||||||
|
saveVersion(userId, siteId, refinementPrompt, references, result.getContent());
|
||||||
|
|
||||||
|
return structure;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<GenerationVersion> getVersions(UUID siteId) {
|
||||||
|
Query query = new Query(Criteria.where("siteId").is(siteId))
|
||||||
|
.with(Sort.by(Sort.Direction.DESC, "versionNumber"))
|
||||||
|
.limit(MAX_VERSIONS_PER_SITE);
|
||||||
|
return mongoTemplate.find(query, GenerationVersion.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SiteStructure restoreVersion(String versionId) {
|
||||||
|
GenerationVersion version = mongoTemplate.findById(versionId, GenerationVersion.class);
|
||||||
|
if (version == null) {
|
||||||
|
throw new IllegalArgumentException("Version not found: " + versionId);
|
||||||
|
}
|
||||||
|
return responseParser.parse(version.getFullStructure());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteVersion(String versionId) {
|
||||||
|
GenerationVersion version = mongoTemplate.findById(versionId, GenerationVersion.class);
|
||||||
|
if (version == null) {
|
||||||
|
throw new IllegalArgumentException("Version not found: " + versionId);
|
||||||
|
}
|
||||||
|
mongoTemplate.remove(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LLMResult callLLM(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
|
||||||
|
LLMProvider provider = llmFactory.getActiveProvider();
|
||||||
|
try {
|
||||||
|
return provider.generate(systemPrompt, userPrompt, references, options);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Active provider {} failed: {}. Trying fallback...",
|
||||||
|
provider.getName(), e.getMessage());
|
||||||
|
return llmFactory.getFallbackProvider()
|
||||||
|
.orElseThrow(() -> new RuntimeException("All LLM providers failed", e))
|
||||||
|
.generate(systemPrompt, userPrompt, references, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveVersion(UUID userId, UUID siteId, String prompt,
|
||||||
|
List<Reference> references, String generatedJson) {
|
||||||
|
int nextVersion = getNextVersionNumber(siteId);
|
||||||
|
|
||||||
|
List<GenerationVersion.ReferenceEntry> refEntries = null;
|
||||||
|
if (references != null) {
|
||||||
|
refEntries = references.stream()
|
||||||
|
.map(r -> GenerationVersion.ReferenceEntry.builder()
|
||||||
|
.type(r.getType().name())
|
||||||
|
.url(r.getUrl())
|
||||||
|
.altText(r.getAltText())
|
||||||
|
.build())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
GenerationVersion version = GenerationVersion.builder()
|
||||||
|
.siteId(siteId)
|
||||||
|
.userId(userId)
|
||||||
|
.versionNumber(nextVersion)
|
||||||
|
.prompt(prompt)
|
||||||
|
.references(refEntries)
|
||||||
|
.fullStructure(generatedJson)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
mongoTemplate.save(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int getNextVersionNumber(UUID siteId) {
|
||||||
|
Query query = new Query(Criteria.where("siteId").is(siteId))
|
||||||
|
.with(Sort.by(Sort.Direction.DESC, "versionNumber"))
|
||||||
|
.limit(1);
|
||||||
|
GenerationVersion latest = mongoTemplate.findOne(query, GenerationVersion.class);
|
||||||
|
return latest != null ? latest.getVersionNumber() + 1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String loadSystemPrompt(ResourceLoader resourceLoader, String path) {
|
||||||
|
try {
|
||||||
|
Resource resource = resourceLoader.getResource(path);
|
||||||
|
if (!resource.exists()) {
|
||||||
|
log.warn("System prompt file not found at {}, using default", path);
|
||||||
|
return "You are a website generator. Given a user's description, output a valid JSON site structure.";
|
||||||
|
}
|
||||||
|
try (BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
|
||||||
|
return reader.lines().collect(Collectors.joining("\n"));
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Failed to load system prompt from {}: {}", path, e.getMessage());
|
||||||
|
return "You are a website generator. Given a user's description, output a valid JSON site structure.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String serializeStructure(SiteStructure structure) {
|
||||||
|
try {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(structure);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to serialize site structure", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.indie.service;
|
||||||
|
|
||||||
|
import com.indie.dto.Reference;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ReferenceService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ReferenceService.class);
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
|
||||||
|
public ReferenceService() {
|
||||||
|
this.restTemplate = new RestTemplate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String buildReferenceContext(List<Reference> references) {
|
||||||
|
if (references == null || references.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder("\n\nReference materials provided by the user:\n");
|
||||||
|
for (int i = 0; i < references.size(); i++) {
|
||||||
|
Reference ref = references.get(i);
|
||||||
|
sb.append("[").append(i + 1).append("] ");
|
||||||
|
sb.append(ref.getType()).append(": ").append(ref.getUrl());
|
||||||
|
if (ref.getAltText() != null && !ref.getAltText().isBlank()) {
|
||||||
|
sb.append(" (").append(ref.getAltText()).append(")");
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
|
||||||
|
if (ref.getType() == Reference.ReferenceType.URL) {
|
||||||
|
try {
|
||||||
|
String content = fetchUrlContent(ref.getUrl());
|
||||||
|
sb.append(" Content: ").append(truncate(content, 2000)).append("\n");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to fetch reference URL {}: {}", ref.getUrl(), e.getMessage());
|
||||||
|
sb.append(" Content: [unable to fetch]\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String fetchUrlContent(String url) {
|
||||||
|
String html = restTemplate.getForObject(url, String.class);
|
||||||
|
if (html == null) return "";
|
||||||
|
return html.replaceAll("<[^>]+>", " ")
|
||||||
|
.replaceAll("\\s+", " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncate(String text, int maxLen) {
|
||||||
|
if (text.length() <= maxLen) return text;
|
||||||
|
return text.substring(0, maxLen) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
133
backend/src/main/java/com/indie/service/llm/GeminiProvider.java
Normal file
133
backend/src/main/java/com/indie/service/llm/GeminiProvider.java
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package com.indie.service.llm;
|
||||||
|
|
||||||
|
import com.indie.dto.LLMResult;
|
||||||
|
import com.indie.dto.LLMOptions;
|
||||||
|
import com.indie.dto.Reference;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class GeminiProvider implements LLMProvider {
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private final String apiKey;
|
||||||
|
private final String model;
|
||||||
|
private final String baseUrl;
|
||||||
|
|
||||||
|
public GeminiProvider(
|
||||||
|
@Value("${indie.llm.providers.gemini.api-key:}") String apiKey,
|
||||||
|
@Value("${indie.llm.providers.gemini.model:gemini-2.0-flash}") String model,
|
||||||
|
@Value("${indie.llm.providers.gemini.url:https://generativelanguage.googleapis.com/v1beta/models}") String baseUrl,
|
||||||
|
RestTemplateBuilder builder) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.model = model;
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
this.restTemplate = builder
|
||||||
|
.setConnectTimeout(Duration.ofSeconds(30))
|
||||||
|
.setReadTimeout(Duration.ofSeconds(60))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LLMResult generate(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
|
||||||
|
List<Map<String, Object>> parts = new ArrayList<>();
|
||||||
|
parts.add(Map.of("text", userPrompt));
|
||||||
|
|
||||||
|
if (references != null) {
|
||||||
|
for (Reference ref : references) {
|
||||||
|
if (ref.getType() == Reference.ReferenceType.IMAGE && ref.getUrl() != null) {
|
||||||
|
parts.add(Map.of(
|
||||||
|
"inlineData", Map.of(
|
||||||
|
"mimeType", "image/jpeg",
|
||||||
|
"data", ref.getUrl()
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> systemInstruction = Map.of(
|
||||||
|
"parts", List.of(Map.of("text", systemPrompt))
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> contents = Map.of(
|
||||||
|
"parts", parts
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> generationConfig = Map.of(
|
||||||
|
"temperature", options.getTemperature(),
|
||||||
|
"maxOutputTokens", options.getMaxTokens()
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> requestBody = Map.of(
|
||||||
|
"system_instruction", systemInstruction,
|
||||||
|
"contents", List.of(contents),
|
||||||
|
"generationConfig", generationConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
|
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
|
||||||
|
|
||||||
|
String url = baseUrl + "/" + model + ":generateContent?key=" + apiKey;
|
||||||
|
|
||||||
|
ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.POST, request, Map.class);
|
||||||
|
|
||||||
|
long latencyMs = System.currentTimeMillis() - start;
|
||||||
|
|
||||||
|
Map<String, Object> responseBody = response.getBody();
|
||||||
|
String content = extractContent(responseBody);
|
||||||
|
|
||||||
|
int promptTokens = 0;
|
||||||
|
int completionTokens = 0;
|
||||||
|
if (responseBody != null && responseBody.containsKey("usageMetadata")) {
|
||||||
|
Map<String, Object> usage = (Map<String, Object>) responseBody.get("usageMetadata");
|
||||||
|
promptTokens = ((Number) usage.getOrDefault("promptTokenCount", 0)).intValue();
|
||||||
|
completionTokens = ((Number) usage.getOrDefault("candidatesTokenCount", 0)).intValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
return LLMResult.builder()
|
||||||
|
.content(content)
|
||||||
|
.promptTokens(promptTokens)
|
||||||
|
.completionTokens(completionTokens)
|
||||||
|
.model(model)
|
||||||
|
.latencyMs(latencyMs)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private String extractContent(Map<String, Object> responseBody) {
|
||||||
|
try {
|
||||||
|
List<Map<String, Object>> candidates = (List<Map<String, Object>>) responseBody.get("candidates");
|
||||||
|
if (candidates != null && !candidates.isEmpty()) {
|
||||||
|
Map<String, Object> first = candidates.get(0);
|
||||||
|
Map<String, Object> content = (Map<String, Object>) first.get("content");
|
||||||
|
if (content != null) {
|
||||||
|
List<Map<String, Object>> parts = (List<Map<String, Object>>) content.get("parts");
|
||||||
|
if (parts != null && !parts.isEmpty()) {
|
||||||
|
return (String) parts.get(0).get("text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to parse Gemini response: " + e.getMessage());
|
||||||
|
}
|
||||||
|
throw new RuntimeException("Empty response from Gemini API");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "gemini";
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/src/main/java/com/indie/service/llm/LLMFactory.java
Normal file
55
backend/src/main/java/com/indie/service/llm/LLMFactory.java
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package com.indie.service.llm;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class LLMFactory {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LLMFactory.class);
|
||||||
|
|
||||||
|
private final List<LLMProvider> providers;
|
||||||
|
private final String activeProvider;
|
||||||
|
private final String fallbackProvider;
|
||||||
|
|
||||||
|
private Map<String, LLMProvider> providerMap;
|
||||||
|
|
||||||
|
public LLMFactory(List<LLMProvider> providers,
|
||||||
|
@Value("${indie.llm.active-provider:gemini}") String activeProvider,
|
||||||
|
@Value("${indie.llm.fallback-provider:openai}") String fallbackProvider) {
|
||||||
|
this.providers = providers;
|
||||||
|
this.activeProvider = activeProvider;
|
||||||
|
this.fallbackProvider = fallbackProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void init() {
|
||||||
|
providerMap = providers.stream()
|
||||||
|
.collect(Collectors.toMap(LLMProvider::getName, Function.identity()));
|
||||||
|
log.info("Available LLM providers: {}", providerMap.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public LLMProvider getActiveProvider() {
|
||||||
|
return getProvider(activeProvider)
|
||||||
|
.orElseThrow(() -> new IllegalStateException(
|
||||||
|
"Active LLM provider '" + activeProvider + "' not found. Available: " + providerMap.keySet()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<LLMProvider> getFallbackProvider() {
|
||||||
|
return getProvider(fallbackProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<LLMProvider> getProvider(String name) {
|
||||||
|
return Optional.ofNullable(providerMap.get(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/main/java/com/indie/service/llm/LLMProvider.java
Normal file
14
backend/src/main/java/com/indie/service/llm/LLMProvider.java
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package com.indie.service.llm;
|
||||||
|
|
||||||
|
import com.indie.dto.LLMResult;
|
||||||
|
import com.indie.dto.LLMOptions;
|
||||||
|
import com.indie.dto.Reference;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface LLMProvider {
|
||||||
|
|
||||||
|
LLMResult generate(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options);
|
||||||
|
|
||||||
|
String getName();
|
||||||
|
}
|
||||||
103
backend/src/main/java/com/indie/service/llm/OpenAIProvider.java
Normal file
103
backend/src/main/java/com/indie/service/llm/OpenAIProvider.java
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package com.indie.service.llm;
|
||||||
|
|
||||||
|
import com.indie.dto.LLMResult;
|
||||||
|
import com.indie.dto.LLMOptions;
|
||||||
|
import com.indie.dto.Reference;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class OpenAIProvider implements LLMProvider {
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private final String apiKey;
|
||||||
|
private final String model;
|
||||||
|
private final String url;
|
||||||
|
|
||||||
|
public OpenAIProvider(
|
||||||
|
@Value("${indie.llm.providers.openai.api-key:}") String apiKey,
|
||||||
|
@Value("${indie.llm.providers.openai.model:gpt-4o-mini}") String model,
|
||||||
|
@Value("${indie.llm.providers.openai.url:https://api.openai.com/v1/chat/completions}") String url,
|
||||||
|
RestTemplateBuilder builder) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.model = model;
|
||||||
|
this.url = url;
|
||||||
|
this.restTemplate = builder
|
||||||
|
.setConnectTimeout(Duration.ofSeconds(30))
|
||||||
|
.setReadTimeout(Duration.ofSeconds(60))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LLMResult generate(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
|
||||||
|
Map<String, Object> requestBody = Map.of(
|
||||||
|
"model", model,
|
||||||
|
"messages", List.of(
|
||||||
|
Map.of("role", "system", "content", systemPrompt),
|
||||||
|
Map.of("role", "user", "content", userPrompt)
|
||||||
|
),
|
||||||
|
"temperature", options.getTemperature(),
|
||||||
|
"max_tokens", options.getMaxTokens()
|
||||||
|
);
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
headers.setBearerAuth(apiKey);
|
||||||
|
|
||||||
|
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
|
||||||
|
|
||||||
|
ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.POST, request, Map.class);
|
||||||
|
|
||||||
|
long latencyMs = System.currentTimeMillis() - start;
|
||||||
|
|
||||||
|
Map<String, Object> responseBody = response.getBody();
|
||||||
|
String content = extractContent(responseBody);
|
||||||
|
|
||||||
|
int promptTokens = 0;
|
||||||
|
int completionTokens = 0;
|
||||||
|
if (responseBody != null && responseBody.containsKey("usage")) {
|
||||||
|
Map<String, Object> usage = (Map<String, Object>) responseBody.get("usage");
|
||||||
|
promptTokens = ((Number) usage.getOrDefault("prompt_tokens", 0)).intValue();
|
||||||
|
completionTokens = ((Number) usage.getOrDefault("completion_tokens", 0)).intValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
return LLMResult.builder()
|
||||||
|
.content(content)
|
||||||
|
.promptTokens(promptTokens)
|
||||||
|
.completionTokens(completionTokens)
|
||||||
|
.model(model)
|
||||||
|
.latencyMs(latencyMs)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private String extractContent(Map<String, Object> responseBody) {
|
||||||
|
try {
|
||||||
|
List<Map<String, Object>> choices = (List<Map<String, Object>>) responseBody.get("choices");
|
||||||
|
if (choices != null && !choices.isEmpty()) {
|
||||||
|
Map<String, Object> first = choices.get(0);
|
||||||
|
Map<String, Object> message = (Map<String, Object>) first.get("message");
|
||||||
|
if (message != null) {
|
||||||
|
return (String) message.get("content");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to parse OpenAI response: " + e.getMessage());
|
||||||
|
}
|
||||||
|
throw new RuntimeException("Empty response from OpenAI API");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "openai";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@ server:
|
|||||||
port: 8080
|
port: 8080
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
|
data:
|
||||||
|
mongodb:
|
||||||
|
uri: ${MONGODB_URI:mongodb://localhost:27017/indie}
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/indie}
|
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/indie}
|
||||||
username: ${SPRING_DATASOURCE_USERNAME:indie}
|
username: ${SPRING_DATASOURCE_USERNAME:indie}
|
||||||
@@ -41,3 +45,18 @@ indie:
|
|||||||
author-email: ${INDIE_GIT_AUTHOR_EMAIL:git@indie.local}
|
author-email: ${INDIE_GIT_AUTHOR_EMAIL:git@indie.local}
|
||||||
cors:
|
cors:
|
||||||
allowed-origins: ${INDIE_CORS_ORIGINS:http://localhost:4200,http://localhost:3000}
|
allowed-origins: ${INDIE_CORS_ORIGINS:http://localhost:4200,http://localhost:3000}
|
||||||
|
|
||||||
|
llm:
|
||||||
|
active-provider: ${INDIE_LLM_ACTIVE_PROVIDER:gemini}
|
||||||
|
fallback-provider: ${INDIE_LLM_FALLBACK_PROVIDER:openai}
|
||||||
|
rate-limit:
|
||||||
|
max-requests-per-hour: ${INDIE_LLM_RATE_LIMIT:10}
|
||||||
|
providers:
|
||||||
|
gemini:
|
||||||
|
api-key: ${GEMINI_API_KEY:}
|
||||||
|
model: ${GEMINI_MODEL:gemini-3.1-flash-lite}
|
||||||
|
url: https://generativelanguage.googleapis.com/v1beta/models
|
||||||
|
openai:
|
||||||
|
api-key: ${OPENAI_API_KEY:}
|
||||||
|
model: ${OPENAI_MODEL:gpt-4o-mini}
|
||||||
|
url: https://api.openai.com/v1/chat/completions
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
package com.indie.controller;
|
|
||||||
|
|
||||||
import com.indie.config.GlobalExceptionHandler;
|
|
||||||
import com.indie.config.JwtAuthenticationToken;
|
|
||||||
import com.indie.config.UserPrincipal;
|
|
||||||
import com.indie.dto.PageRequest;
|
|
||||||
import com.indie.model.Page;
|
|
||||||
import com.indie.model.Site;
|
|
||||||
import com.indie.model.SiteStatus;
|
|
||||||
import com.indie.repository.PageRepository;
|
|
||||||
import com.indie.repository.SiteRepository;
|
|
||||||
import com.indie.support.TestSecurityConfig;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
|
||||||
|
|
||||||
@WebMvcTest(PageController.class)
|
|
||||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
|
||||||
class PageControllerTest {
|
|
||||||
|
|
||||||
private final MockMvc mockMvc;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@MockBean
|
|
||||||
private PageRepository pageRepository;
|
|
||||||
|
|
||||||
@MockBean
|
|
||||||
private SiteRepository siteRepository;
|
|
||||||
|
|
||||||
private UUID userId;
|
|
||||||
|
|
||||||
PageControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) {
|
|
||||||
this.mockMvc = mockMvc;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
}
|
|
||||||
private UUID siteId;
|
|
||||||
private UserPrincipal principal;
|
|
||||||
private JwtAuthenticationToken auth;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
userId = UUID.randomUUID();
|
|
||||||
siteId = UUID.randomUUID();
|
|
||||||
principal = new UserPrincipal(userId, "user@test.com", "Test User");
|
|
||||||
auth = new JwtAuthenticationToken(principal, "test-token");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listPagesReturns200() throws Exception {
|
|
||||||
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
|
|
||||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
|
||||||
|
|
||||||
Page page = Page.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.siteId(siteId)
|
|
||||||
.title("Home")
|
|
||||||
.slug("home")
|
|
||||||
.orderIndex(0)
|
|
||||||
.build();
|
|
||||||
when(pageRepository.findBySiteIdOrderByOrderIndexAsc(siteId))
|
|
||||||
.thenReturn(List.of(page));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/sites/{siteId}/pages", siteId)
|
|
||||||
.with(authentication(auth)))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$[0].title").value("Home"))
|
|
||||||
.andExpect(jsonPath("$[0].slug").value("home"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createPageReturns201() throws Exception {
|
|
||||||
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
|
|
||||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
|
||||||
when(pageRepository.existsBySiteIdAndSlug(siteId, "about")).thenReturn(false);
|
|
||||||
|
|
||||||
Page saved = Page.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.siteId(siteId)
|
|
||||||
.title("About")
|
|
||||||
.slug("about")
|
|
||||||
.orderIndex(1)
|
|
||||||
.createdAt(LocalDateTime.now())
|
|
||||||
.build();
|
|
||||||
when(pageRepository.save(any(Page.class))).thenReturn(saved);
|
|
||||||
|
|
||||||
PageRequest request = new PageRequest();
|
|
||||||
request.setTitle("About");
|
|
||||||
request.setSlug("about");
|
|
||||||
request.setOrderIndex(1);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/sites/{siteId}/pages", siteId)
|
|
||||||
.with(authentication(auth))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isCreated())
|
|
||||||
.andExpect(jsonPath("$.title").value("About"))
|
|
||||||
.andExpect(jsonPath("$.slug").value("about"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createPageReturns400OnDuplicateSlug() throws Exception {
|
|
||||||
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
|
|
||||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
|
||||||
when(pageRepository.existsBySiteIdAndSlug(siteId, "home")).thenReturn(true);
|
|
||||||
|
|
||||||
PageRequest request = new PageRequest();
|
|
||||||
request.setTitle("Home");
|
|
||||||
request.setSlug("home");
|
|
||||||
request.setOrderIndex(0);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/sites/{siteId}/pages", siteId)
|
|
||||||
.with(authentication(auth))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.detail").value("A page with this slug already exists"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void deletePageReturns204() throws Exception {
|
|
||||||
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
|
|
||||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
|
||||||
|
|
||||||
UUID pageId = UUID.randomUUID();
|
|
||||||
Page page = Page.builder()
|
|
||||||
.id(pageId)
|
|
||||||
.siteId(siteId)
|
|
||||||
.title("To Delete")
|
|
||||||
.slug("to-delete")
|
|
||||||
.orderIndex(0)
|
|
||||||
.build();
|
|
||||||
when(pageRepository.findById(pageId)).thenReturn(Optional.of(page));
|
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/sites/{siteId}/pages/{pageId}", siteId, pageId)
|
|
||||||
.with(authentication(auth)))
|
|
||||||
.andExpect(status().isNoContent());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updatePageReturns200() throws Exception {
|
|
||||||
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
|
|
||||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
|
||||||
|
|
||||||
UUID pageId = UUID.randomUUID();
|
|
||||||
Page existing = Page.builder()
|
|
||||||
.id(pageId)
|
|
||||||
.siteId(siteId)
|
|
||||||
.title("Old Title")
|
|
||||||
.slug("old-slug")
|
|
||||||
.orderIndex(0)
|
|
||||||
.build();
|
|
||||||
when(pageRepository.findById(pageId)).thenReturn(Optional.of(existing));
|
|
||||||
when(pageRepository.existsBySiteIdAndSlug(siteId, "new-slug")).thenReturn(false);
|
|
||||||
when(pageRepository.save(any(Page.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
|
||||||
|
|
||||||
PageRequest request = new PageRequest();
|
|
||||||
request.setTitle("New Title");
|
|
||||||
request.setSlug("new-slug");
|
|
||||||
request.setOrderIndex(1);
|
|
||||||
|
|
||||||
mockMvc.perform(put("/api/sites/{siteId}/pages/{pageId}", siteId, pageId)
|
|
||||||
.with(authentication(auth))
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.title").value("New Title"))
|
|
||||||
.andExpect(jsonPath("$.slug").value("new-slug"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listPagesReturns400OnWrongSiteOwner() throws Exception {
|
|
||||||
Site site = Site.builder()
|
|
||||||
.id(siteId)
|
|
||||||
.userId(UUID.randomUUID())
|
|
||||||
.name("Not Mine")
|
|
||||||
.build();
|
|
||||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/sites/{siteId}/pages", siteId)
|
|
||||||
.with(authentication(auth)))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.detail").value("Site not found"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package com.indie.dto;
|
|
||||||
|
|
||||||
import com.indie.model.Page;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class PageResponseTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void fromMapsAllFields() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
UUID siteId = UUID.randomUUID();
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
|
|
||||||
Page page = Page.builder()
|
|
||||||
.id(id)
|
|
||||||
.siteId(siteId)
|
|
||||||
.title("About")
|
|
||||||
.slug("about")
|
|
||||||
.seoTitle("About Us")
|
|
||||||
.seoDescription("Learn about us")
|
|
||||||
.orderIndex(1)
|
|
||||||
.createdAt(now)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
PageResponse response = PageResponse.from(page);
|
|
||||||
|
|
||||||
assertEquals(id, response.getId());
|
|
||||||
assertEquals(siteId, response.getSiteId());
|
|
||||||
assertEquals("About", response.getTitle());
|
|
||||||
assertEquals("about", response.getSlug());
|
|
||||||
assertEquals("About Us", response.getSeoTitle());
|
|
||||||
assertEquals("Learn about us", response.getSeoDescription());
|
|
||||||
assertEquals(1, response.getOrderIndex());
|
|
||||||
assertEquals(now, response.getCreatedAt());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void fromHandlesNullOptionalFields() {
|
|
||||||
Page page = Page.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.siteId(UUID.randomUUID())
|
|
||||||
.title("Home")
|
|
||||||
.slug("home")
|
|
||||||
.orderIndex(0)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
PageResponse response = PageResponse.from(page);
|
|
||||||
|
|
||||||
assertNull(response.getSeoTitle());
|
|
||||||
assertNull(response.getSeoDescription());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import com.indie.model.SiteStatus;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
@@ -17,7 +16,6 @@ class SiteResponseTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
UUID userId = UUID.randomUUID();
|
UUID userId = UUID.randomUUID();
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
Map<String, Object> theme = Map.of("primaryColor", "#000");
|
|
||||||
|
|
||||||
Site site = Site.builder()
|
Site site = Site.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
@@ -25,7 +23,6 @@ class SiteResponseTest {
|
|||||||
.name("My Site")
|
.name("My Site")
|
||||||
.description("A test site")
|
.description("A test site")
|
||||||
.subdomain("mysite")
|
.subdomain("mysite")
|
||||||
.themeConfig(theme)
|
|
||||||
.status(SiteStatus.PUBLISHED)
|
.status(SiteStatus.PUBLISHED)
|
||||||
.publishedAt(now)
|
.publishedAt(now)
|
||||||
.createdAt(now)
|
.createdAt(now)
|
||||||
@@ -39,7 +36,6 @@ class SiteResponseTest {
|
|||||||
assertEquals("My Site", response.getName());
|
assertEquals("My Site", response.getName());
|
||||||
assertEquals("A test site", response.getDescription());
|
assertEquals("A test site", response.getDescription());
|
||||||
assertEquals("mysite", response.getSubdomain());
|
assertEquals("mysite", response.getSubdomain());
|
||||||
assertEquals(theme, response.getThemeConfig());
|
|
||||||
assertEquals(SiteStatus.PUBLISHED, response.getStatus());
|
assertEquals(SiteStatus.PUBLISHED, response.getStatus());
|
||||||
assertEquals(now, response.getPublishedAt());
|
assertEquals(now, response.getPublishedAt());
|
||||||
assertEquals(now, response.getCreatedAt());
|
assertEquals(now, response.getCreatedAt());
|
||||||
@@ -59,7 +55,6 @@ class SiteResponseTest {
|
|||||||
|
|
||||||
assertNull(response.getDescription());
|
assertNull(response.getDescription());
|
||||||
assertNull(response.getSubdomain());
|
assertNull(response.getSubdomain());
|
||||||
assertNull(response.getThemeConfig());
|
|
||||||
assertNull(response.getPublishedAt());
|
assertNull(response.getPublishedAt());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
package com.indie.model;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class ComponentTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void prePersistSetsId() {
|
|
||||||
Component component = new Component();
|
|
||||||
component.setPageId(UUID.randomUUID());
|
|
||||||
component.setType(ComponentType.HEADING);
|
|
||||||
component.setOrderIndex(0);
|
|
||||||
|
|
||||||
assertNull(component.getId());
|
|
||||||
|
|
||||||
component.onCreate();
|
|
||||||
|
|
||||||
assertNotNull(component.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void prePersistDoesNotOverrideExistingId() {
|
|
||||||
UUID existingId = UUID.randomUUID();
|
|
||||||
Component component = Component.builder()
|
|
||||||
.id(existingId)
|
|
||||||
.pageId(UUID.randomUUID())
|
|
||||||
.type(ComponentType.TEXT)
|
|
||||||
.orderIndex(0)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
component.onCreate();
|
|
||||||
|
|
||||||
assertEquals(existingId, component.getId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package com.indie.model;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class PageTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void prePersistSetsIdAndCreatedAt() {
|
|
||||||
Page page = new Page();
|
|
||||||
page.setSiteId(UUID.randomUUID());
|
|
||||||
page.setTitle("Home");
|
|
||||||
page.setSlug("home");
|
|
||||||
page.setOrderIndex(0);
|
|
||||||
|
|
||||||
assertNull(page.getId());
|
|
||||||
assertNull(page.getCreatedAt());
|
|
||||||
|
|
||||||
page.onCreate();
|
|
||||||
|
|
||||||
assertNotNull(page.getId());
|
|
||||||
assertNotNull(page.getCreatedAt());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void prePersistDoesNotOverrideExistingId() {
|
|
||||||
UUID existingId = UUID.randomUUID();
|
|
||||||
Page page = Page.builder()
|
|
||||||
.id(existingId)
|
|
||||||
.siteId(UUID.randomUUID())
|
|
||||||
.title("Home")
|
|
||||||
.slug("home")
|
|
||||||
.orderIndex(0)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
page.onCreate();
|
|
||||||
|
|
||||||
assertEquals(existingId, page.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void noUpdatedAtField() {
|
|
||||||
Page page = Page.builder()
|
|
||||||
.siteId(UUID.randomUUID())
|
|
||||||
.title("Home")
|
|
||||||
.slug("home")
|
|
||||||
.orderIndex(0)
|
|
||||||
.build();
|
|
||||||
page.onCreate();
|
|
||||||
|
|
||||||
assertNotNull(page.getCreatedAt());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package com.indie.repository;
|
|
||||||
|
|
||||||
import com.indie.model.Component;
|
|
||||||
import com.indie.model.ComponentType;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
@DataJpaTest
|
|
||||||
class ComponentRepositoryTest {
|
|
||||||
|
|
||||||
private final ComponentRepository componentRepository;
|
|
||||||
|
|
||||||
ComponentRepositoryTest(@Autowired ComponentRepository componentRepository) {
|
|
||||||
this.componentRepository = componentRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void saveAndFindById() {
|
|
||||||
Component component = Component.builder()
|
|
||||||
.pageId(UUID.randomUUID())
|
|
||||||
.type(ComponentType.HEADING)
|
|
||||||
.orderIndex(0)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
Component saved = componentRepository.save(component);
|
|
||||||
|
|
||||||
assertNotNull(saved.getId());
|
|
||||||
|
|
||||||
var found = componentRepository.findById(saved.getId());
|
|
||||||
assertTrue(found.isPresent());
|
|
||||||
assertEquals(ComponentType.HEADING, found.get().getType());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findByPageIdOrderByOrderIndexAsc() {
|
|
||||||
UUID pageId = UUID.randomUUID();
|
|
||||||
|
|
||||||
Component c1 = Component.builder().pageId(pageId).type(ComponentType.TEXT).orderIndex(1).build();
|
|
||||||
Component c2 = Component.builder().pageId(pageId).type(ComponentType.HEADING).orderIndex(0).build();
|
|
||||||
componentRepository.save(c1);
|
|
||||||
componentRepository.save(c2);
|
|
||||||
|
|
||||||
List<Component> components = componentRepository.findByPageIdOrderByOrderIndexAsc(pageId);
|
|
||||||
|
|
||||||
assertEquals(2, components.size());
|
|
||||||
assertEquals(ComponentType.HEADING, components.get(0).getType());
|
|
||||||
assertEquals(ComponentType.TEXT, components.get(1).getType());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findByPageIdReturnsEmptyForUnknownPage() {
|
|
||||||
List<Component> components = componentRepository.findByPageIdOrderByOrderIndexAsc(UUID.randomUUID());
|
|
||||||
assertTrue(components.isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void deleteByPageId() {
|
|
||||||
UUID pageId = UUID.randomUUID();
|
|
||||||
|
|
||||||
Component c1 = Component.builder().pageId(pageId).type(ComponentType.BUTTON).orderIndex(0).build();
|
|
||||||
Component c2 = Component.builder().pageId(pageId).type(ComponentType.TEXT).orderIndex(1).build();
|
|
||||||
componentRepository.save(c1);
|
|
||||||
componentRepository.save(c2);
|
|
||||||
|
|
||||||
componentRepository.deleteByPageId(pageId);
|
|
||||||
|
|
||||||
List<Component> remaining = componentRepository.findByPageIdOrderByOrderIndexAsc(pageId);
|
|
||||||
assertTrue(remaining.isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void deleteByPageIdOnlyDeletesForGivenPage() {
|
|
||||||
UUID pageId1 = UUID.randomUUID();
|
|
||||||
UUID pageId2 = UUID.randomUUID();
|
|
||||||
|
|
||||||
Component c1 = Component.builder().pageId(pageId1).type(ComponentType.GALLERY).orderIndex(0).build();
|
|
||||||
Component c2 = Component.builder().pageId(pageId2).type(ComponentType.IMAGE).orderIndex(0).build();
|
|
||||||
componentRepository.save(c1);
|
|
||||||
componentRepository.save(c2);
|
|
||||||
|
|
||||||
componentRepository.deleteByPageId(pageId1);
|
|
||||||
|
|
||||||
assertEquals(1, componentRepository.findByPageIdOrderByOrderIndexAsc(pageId2).size());
|
|
||||||
assertEquals(0, componentRepository.findByPageIdOrderByOrderIndexAsc(pageId1).size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package com.indie.repository;
|
|
||||||
|
|
||||||
import com.indie.model.Page;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
@DataJpaTest
|
|
||||||
class PageRepositoryTest {
|
|
||||||
|
|
||||||
private final PageRepository pageRepository;
|
|
||||||
|
|
||||||
PageRepositoryTest(@Autowired PageRepository pageRepository) {
|
|
||||||
this.pageRepository = pageRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void saveAndFindById() {
|
|
||||||
Page page = Page.builder()
|
|
||||||
.siteId(UUID.randomUUID())
|
|
||||||
.title("Home")
|
|
||||||
.slug("home")
|
|
||||||
.orderIndex(0)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
Page saved = pageRepository.save(page);
|
|
||||||
|
|
||||||
assertNotNull(saved.getId());
|
|
||||||
|
|
||||||
Optional<Page> found = pageRepository.findById(saved.getId());
|
|
||||||
assertTrue(found.isPresent());
|
|
||||||
assertEquals("Home", found.get().getTitle());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findBySiteIdOrderByOrderIndexAsc() {
|
|
||||||
UUID siteId = UUID.randomUUID();
|
|
||||||
|
|
||||||
Page page1 = Page.builder().siteId(siteId).title("About").slug("about").orderIndex(1).build();
|
|
||||||
Page page2 = Page.builder().siteId(siteId).title("Home").slug("home").orderIndex(0).build();
|
|
||||||
pageRepository.save(page1);
|
|
||||||
pageRepository.save(page2);
|
|
||||||
|
|
||||||
List<Page> pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(siteId);
|
|
||||||
|
|
||||||
assertEquals(2, pages.size());
|
|
||||||
assertEquals("home", pages.get(0).getSlug());
|
|
||||||
assertEquals("about", pages.get(1).getSlug());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findBySiteIdReturnsEmptyForUnknownSite() {
|
|
||||||
List<Page> pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(UUID.randomUUID());
|
|
||||||
assertTrue(pages.isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findBySiteIdAndSlug() {
|
|
||||||
UUID siteId = UUID.randomUUID();
|
|
||||||
Page page = Page.builder()
|
|
||||||
.siteId(siteId)
|
|
||||||
.title("Contact")
|
|
||||||
.slug("contact")
|
|
||||||
.orderIndex(2)
|
|
||||||
.build();
|
|
||||||
pageRepository.save(page);
|
|
||||||
|
|
||||||
Optional<Page> found = pageRepository.findBySiteIdAndSlug(siteId, "contact");
|
|
||||||
assertTrue(found.isPresent());
|
|
||||||
assertEquals("Contact", found.get().getTitle());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findBySiteIdAndSlugReturnsEmptyWhenNotMatching() {
|
|
||||||
Optional<Page> found = pageRepository.findBySiteIdAndSlug(UUID.randomUUID(), "nonexistent");
|
|
||||||
assertTrue(found.isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void existsBySiteIdAndSlug() {
|
|
||||||
UUID siteId = UUID.randomUUID();
|
|
||||||
Page page = Page.builder()
|
|
||||||
.siteId(siteId)
|
|
||||||
.title("Blog")
|
|
||||||
.slug("blog")
|
|
||||||
.orderIndex(0)
|
|
||||||
.build();
|
|
||||||
pageRepository.save(page);
|
|
||||||
|
|
||||||
assertTrue(pageRepository.existsBySiteIdAndSlug(siteId, "blog"));
|
|
||||||
assertFalse(pageRepository.existsBySiteIdAndSlug(siteId, "nonexistent"));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package com.indie.support;
|
|
||||||
|
|
||||||
import com.indie.model.Component;
|
|
||||||
import com.indie.repository.ComponentRepository;
|
|
||||||
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public class FakeComponentRepository extends InMemoryRepository<Component, UUID> implements ComponentRepository {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected UUID getId(Component entity) {
|
|
||||||
return entity.getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Component setId(Component entity, UUID id) {
|
|
||||||
entity.setId(id);
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected UUID generateId() {
|
|
||||||
return UUID.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<Component> findByPageIdOrderByOrderIndexAsc(UUID pageId) {
|
|
||||||
return store.values().stream()
|
|
||||||
.filter(c -> c.getPageId().equals(pageId))
|
|
||||||
.sorted(Comparator.comparingInt(Component::getOrderIndex))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void deleteByPageId(UUID pageId) {
|
|
||||||
store.values().removeIf(c -> c.getPageId().equals(pageId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package com.indie.support;
|
|
||||||
|
|
||||||
import com.indie.model.Page;
|
|
||||||
import com.indie.repository.PageRepository;
|
|
||||||
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public class FakePageRepository extends InMemoryRepository<Page, UUID> implements PageRepository {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected UUID getId(Page entity) {
|
|
||||||
return entity.getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Page setId(Page entity, UUID id) {
|
|
||||||
entity.setId(id);
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected UUID generateId() {
|
|
||||||
return UUID.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<Page> findBySiteIdOrderByOrderIndexAsc(UUID siteId) {
|
|
||||||
return store.values().stream()
|
|
||||||
.filter(p -> p.getSiteId().equals(siteId))
|
|
||||||
.sorted(Comparator.comparingInt(Page::getOrderIndex))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Optional<Page> findBySiteIdAndSlug(UUID siteId, String slug) {
|
|
||||||
return store.values().stream()
|
|
||||||
.filter(p -> p.getSiteId().equals(siteId) && p.getSlug().equals(slug))
|
|
||||||
.findFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean existsBySiteIdAndSlug(UUID siteId, String slug) {
|
|
||||||
return store.values().stream()
|
|
||||||
.anyMatch(p -> p.getSiteId().equals(siteId) && p.getSlug().equals(slug));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"/api": {
|
"/api": {
|
||||||
"target": "http://localhost:8080",
|
"target": "http://127.0.0.1:8080",
|
||||||
"secure": false
|
"secure": false
|
||||||
},
|
},
|
||||||
"/oauth2": {
|
"/oauth2": {
|
||||||
"target": "http://localhost:8080",
|
"target": "http://127.0.0.1:8080",
|
||||||
"secure": false
|
"secure": false
|
||||||
},
|
},
|
||||||
"/login": {
|
"/login": {
|
||||||
"target": "http://localhost:8080",
|
"target": "http://127.0.0.1:8080",
|
||||||
"secure": false
|
"secure": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export const routes: Routes = [
|
|||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'builder/:siteId',
|
path: 'llm/:siteId',
|
||||||
loadComponent: () => import('./builder/builder.component').then(m => m.BuilderComponent),
|
loadComponent: () => import('./llm-workspace/llm-workspace.component').then(m => m.LlmWorkspaceComponent),
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
{ path: '**', redirectTo: '/dashboard' },
|
{ path: '**', redirectTo: '/dashboard' },
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { PageWithComponentsResponse, SaveComponentsRequest, ComponentResponse } from '../core/models/component';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class BuilderApiService {
|
|
||||||
constructor(private http: HttpClient) {}
|
|
||||||
|
|
||||||
getPagesWithComponents(siteId: string): Observable<PageWithComponentsResponse[]> {
|
|
||||||
return this.http.get<PageWithComponentsResponse[]>(`/api/builder/sites/${siteId}/pages`);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveComponents(siteId: string, pageId: string, request: SaveComponentsRequest): Observable<ComponentResponse[]> {
|
|
||||||
return this.http.put<ComponentResponse[]>(`/api/builder/sites/${siteId}/pages/${pageId}/components`, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
getTheme(siteId: string): Observable<Record<string, unknown>> {
|
|
||||||
return this.http.get<Record<string, unknown>>(`/api/builder/sites/${siteId}/theme`);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTheme(siteId: string, theme: Record<string, unknown>): Observable<Record<string, unknown>> {
|
|
||||||
return this.http.put<Record<string, unknown>>(`/api/builder/sites/${siteId}/theme`, theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
|
||||||
import { NgIf, NgFor, NgSwitch, NgSwitchCase, AsyncPipe } from '@angular/common';
|
|
||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
||||||
import { CdkDropListGroup } from '@angular/cdk/drag-drop';
|
|
||||||
import { Subject, Subscription } from 'rxjs';
|
|
||||||
import { BuilderApiService } from './builder-api.service';
|
|
||||||
import { BuilderStateService } from './services/builder-state.service';
|
|
||||||
import { AutoSaveService } from './services/auto-save.service';
|
|
||||||
import { ComponentPalette } from './component-palette/component-palette.component';
|
|
||||||
import { CanvasComponent } from './canvas/canvas.component';
|
|
||||||
import { PropertyPanelComponent } from './property-panel/property-panel.component';
|
|
||||||
import { ThemeEditorComponent } from './theme-editor/theme-editor.component';
|
|
||||||
import { PreviewComponent } from './preview/preview.component';
|
|
||||||
import { AuthService } from '../core/services/auth.service';
|
|
||||||
import { SiteService } from '../core/services/site.service';
|
|
||||||
import { PageService } from '../core/services/page.service';
|
|
||||||
import { CreatePageDialog } from './create-page-dialog/create-page-dialog';
|
|
||||||
|
|
||||||
type RightPanel = 'properties' | 'theme' | 'preview';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-builder',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
NgIf, NgFor, NgSwitch, NgSwitchCase, AsyncPipe,
|
|
||||||
CdkDropListGroup,
|
|
||||||
MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule, MatTooltipModule,
|
|
||||||
MatTabsModule, MatProgressSpinnerModule, MatSnackBarModule, MatDialogModule,
|
|
||||||
ComponentPalette, CanvasComponent, PropertyPanelComponent,
|
|
||||||
ThemeEditorComponent, PreviewComponent,
|
|
||||||
],
|
|
||||||
template: `
|
|
||||||
<div class="h-screen flex flex-col bg-gray-50">
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<mat-toolbar color="primary" class="h-14 flex items-center justify-between px-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button mat-icon-button (click)="backToDashboard()">
|
|
||||||
<mat-icon>arrow_back</mat-icon>
|
|
||||||
</button>
|
|
||||||
<span class="text-lg font-bold">{{ siteName }}</span>
|
|
||||||
<span class="text-xs bg-white/20 px-2 py-0.5 rounded">{{ siteStatus }}</span>
|
|
||||||
<span class="text-xs ml-2" [class.text-green-300]="saveStatus === 'saved'"
|
|
||||||
[class.text-yellow-300]="saveStatus === 'saving'"
|
|
||||||
[class.text-gray-400]="saveStatus === 'idle'">
|
|
||||||
<ng-container [ngSwitch]="saveStatus">
|
|
||||||
<span *ngSwitchCase="'saving'">Saving...</span>
|
|
||||||
<span *ngSwitchCase="'saved'">Saved</span>
|
|
||||||
<span *ngSwitchCase="'idle'"></span>
|
|
||||||
</ng-container>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button mat-icon-button [disabled]="!canUndo" (click)="undo()" matTooltip="Undo (Ctrl+Z)">
|
|
||||||
<mat-icon>undo</mat-icon>
|
|
||||||
</button>
|
|
||||||
<button mat-icon-button [disabled]="!canRedo" (click)="redo()" matTooltip="Redo (Ctrl+Shift+Z)">
|
|
||||||
<mat-icon>redo</mat-icon>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span class="w-px h-6 bg-white/30 mx-1"></span>
|
|
||||||
|
|
||||||
<button mat-icon-button (click)="addPage()" matTooltip="Add Page">
|
|
||||||
<mat-icon>add_circle_outline</mat-icon>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button mat-icon-button [matMenuTriggerFor]="pageMenu" matTooltip="Switch Page">
|
|
||||||
<mat-icon>description</mat-icon>
|
|
||||||
</button>
|
|
||||||
<mat-menu #pageMenu="matMenu">
|
|
||||||
<button mat-menu-item *ngFor="let page of pages" (click)="selectPage(page.id)">
|
|
||||||
<mat-icon>article</mat-icon>
|
|
||||||
<span>{{ page.title }}</span>
|
|
||||||
<span class="text-xs text-gray-400 ml-2">{{ page.slug }}</span>
|
|
||||||
</button>
|
|
||||||
</mat-menu>
|
|
||||||
|
|
||||||
<span class="w-px h-6 bg-white/30 mx-1"></span>
|
|
||||||
|
|
||||||
<button mat-icon-button [matMenuTriggerFor]="viewMenu" matTooltip="View">
|
|
||||||
<mat-icon>visibility</mat-icon>
|
|
||||||
</button>
|
|
||||||
<mat-menu #viewMenu="matMenu">
|
|
||||||
<button mat-menu-item (click)="setRightPanel('properties')">
|
|
||||||
<mat-icon>tune</mat-icon>
|
|
||||||
<span>Properties</span>
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item (click)="setRightPanel('theme')">
|
|
||||||
<mat-icon>palette</mat-icon>
|
|
||||||
<span>Theme</span>
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item (click)="setRightPanel('preview')">
|
|
||||||
<mat-icon>visibility</mat-icon>
|
|
||||||
<span>Preview</span>
|
|
||||||
</button>
|
|
||||||
</mat-menu>
|
|
||||||
</div>
|
|
||||||
</mat-toolbar>
|
|
||||||
|
|
||||||
<!-- Loading overlay -->
|
|
||||||
<div *ngIf="loading$ | async" class="flex items-center justify-center py-4 bg-blue-50 text-blue-600 text-sm">
|
|
||||||
<mat-spinner diameter="20" class="mr-2"></mat-spinner>
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 3-panel layout -->
|
|
||||||
<div class="flex-1 flex overflow-hidden" cdkDropListGroup>
|
|
||||||
<!-- Left: Palette -->
|
|
||||||
<div class="w-64 border-r bg-white overflow-y-auto flex-shrink-0">
|
|
||||||
<app-component-palette></app-component-palette>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Center: Canvas -->
|
|
||||||
<div class="flex-1 overflow-hidden">
|
|
||||||
<app-canvas></app-canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Properties / Theme / Preview -->
|
|
||||||
<div class="w-96 border-l bg-white overflow-y-auto flex-shrink-0">
|
|
||||||
<div *ngIf="rightPanel === 'properties'">
|
|
||||||
<app-property-panel></app-property-panel>
|
|
||||||
</div>
|
|
||||||
<div *ngIf="rightPanel === 'theme'">
|
|
||||||
<app-theme-editor [theme]="themeConfig" (themeChange)="onThemeChange($event)"></app-theme-editor>
|
|
||||||
</div>
|
|
||||||
<div *ngIf="rightPanel === 'preview'">
|
|
||||||
<app-preview [components]="selectedPageComponents" [theme]="themeConfig"></app-preview>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class BuilderComponent implements OnInit, OnDestroy {
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
private router = inject(Router);
|
|
||||||
private api = inject(BuilderApiService);
|
|
||||||
private state = inject(BuilderStateService);
|
|
||||||
private autoSave = inject(AutoSaveService);
|
|
||||||
private authService = inject(AuthService);
|
|
||||||
private siteService = inject(SiteService);
|
|
||||||
private pageService = inject(PageService);
|
|
||||||
private snackBar = inject(MatSnackBar);
|
|
||||||
private dialog = inject(MatDialog);
|
|
||||||
|
|
||||||
rightPanel: RightPanel = 'properties';
|
|
||||||
siteName = '';
|
|
||||||
siteStatus = '';
|
|
||||||
saveStatus: 'saving' | 'saved' | 'idle' = 'idle';
|
|
||||||
loading$ = this.state.loading$;
|
|
||||||
private saveSub: Subscription;
|
|
||||||
private destroy$ = new Subject<void>();
|
|
||||||
|
|
||||||
get pages() { return this.state.pages; }
|
|
||||||
get themeConfig() { return this.state.themeConfig; }
|
|
||||||
get selectedPageComponents() { return this.state.selectedPage?.components ?? []; }
|
|
||||||
get canUndo() { return this.state.canUndo(); }
|
|
||||||
get canRedo() { return this.state.canRedo(); }
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.saveSub = this.autoSave.saveStatus.subscribe(status => {
|
|
||||||
this.saveStatus = status;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.state.setLoading(true);
|
|
||||||
const siteId = this.route.snapshot.paramMap.get('siteId');
|
|
||||||
if (!siteId) {
|
|
||||||
this.router.navigate(['/dashboard']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.loadSite(siteId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadSite(siteId: string): void {
|
|
||||||
this.siteService.get(siteId).subscribe({
|
|
||||||
next: (site) => {
|
|
||||||
this.siteName = site.name;
|
|
||||||
this.siteStatus = site.status;
|
|
||||||
this.state.init(siteId, []);
|
|
||||||
|
|
||||||
if (site.themeConfig) {
|
|
||||||
this.state.setThemeConfig(site.themeConfig as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loadPages(siteId);
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.snackBar.open('Failed to load site', 'Close', { duration: 3000 });
|
|
||||||
this.router.navigate(['/dashboard']);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadPages(siteId: string): void {
|
|
||||||
this.api.getPagesWithComponents(siteId).subscribe({
|
|
||||||
next: (pages) => {
|
|
||||||
this.state.init(siteId, pages);
|
|
||||||
this.state.setLoading(false);
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.snackBar.open('Failed to load pages', 'Close', { duration: 3000 });
|
|
||||||
this.state.setLoading(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
selectPage(pageId: string): void {
|
|
||||||
this.state.selectPage(pageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
setRightPanel(panel: RightPanel): void {
|
|
||||||
this.rightPanel = panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
onThemeChange(theme: Record<string, unknown>): void {
|
|
||||||
this.state.updateThemeConfig(theme);
|
|
||||||
const siteId = this.state.siteId;
|
|
||||||
if (siteId) {
|
|
||||||
this.api.updateTheme(siteId, theme).subscribe();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addPage(): void {
|
|
||||||
const siteId = this.state.siteId;
|
|
||||||
if (!siteId) return;
|
|
||||||
const dialogRef = this.dialog.open(CreatePageDialog, {
|
|
||||||
width: '400px',
|
|
||||||
data: { siteId },
|
|
||||||
});
|
|
||||||
dialogRef.afterClosed().subscribe((result) => {
|
|
||||||
if (result) {
|
|
||||||
this.loadPages(siteId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
undo(): void {
|
|
||||||
this.state.undo();
|
|
||||||
}
|
|
||||||
|
|
||||||
redo(): void {
|
|
||||||
this.state.redo();
|
|
||||||
}
|
|
||||||
|
|
||||||
backToDashboard(): void {
|
|
||||||
this.router.navigate(['/dashboard']);
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.saveSub?.unsubscribe();
|
|
||||||
this.destroy$.next();
|
|
||||||
this.destroy$.complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
import { NgFor, NgIf, NgSwitch, NgSwitchCase } from '@angular/common';
|
|
||||||
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { BuilderStateService } from '../services/builder-state.service';
|
|
||||||
import { ComponentItem, ComponentType } from '../../core/models/component';
|
|
||||||
import { HeadingRenderer } from '../renderer/heading-renderer.component';
|
|
||||||
import { TextRenderer } from '../renderer/text-renderer.component';
|
|
||||||
import { ImageRenderer } from '../renderer/image-renderer.component';
|
|
||||||
import { ButtonRenderer } from '../renderer/button-renderer.component';
|
|
||||||
import { DividerRenderer } from '../renderer/divider-renderer.component';
|
|
||||||
import { GalleryRenderer } from '../renderer/gallery-renderer.component';
|
|
||||||
import { VideoRenderer } from '../renderer/video-renderer.component';
|
|
||||||
import { ContactFormRenderer } from '../renderer/contact-form-renderer.component';
|
|
||||||
import { MapRenderer } from '../renderer/map-renderer.component';
|
|
||||||
import { CustomHtmlRenderer } from '../renderer/custom-html-renderer.component';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-canvas',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
NgFor, NgIf, NgSwitch, NgSwitchCase, MatIconModule,
|
|
||||||
CdkDropList, CdkDrag,
|
|
||||||
HeadingRenderer, TextRenderer, ImageRenderer, ButtonRenderer, DividerRenderer,
|
|
||||||
GalleryRenderer, VideoRenderer, ContactFormRenderer, MapRenderer, CustomHtmlRenderer,
|
|
||||||
],
|
|
||||||
template: `
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
<div class="flex items-center justify-between px-4 py-2 border-b bg-white">
|
|
||||||
<h3 class="text-sm font-medium text-gray-700">
|
|
||||||
{{ selectedPage?.title || 'Select a page' }}
|
|
||||||
</h3>
|
|
||||||
<span class="text-xs text-gray-400">{{ components.length }} components</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 bg-gray-50">
|
|
||||||
<div cdkDropList
|
|
||||||
class="space-y-2 min-h-full"
|
|
||||||
[cdkDropListData]="components"
|
|
||||||
(cdkDropListDropped)="onDrop($event)"
|
|
||||||
[cdkDropListDisabled]="!selectedPage">
|
|
||||||
<div *ngIf="components.length === 0"
|
|
||||||
class="flex flex-col items-center justify-center h-64 text-gray-400">
|
|
||||||
<mat-icon style="font-size: 48px; width: 48px; height: 48px;" class="mb-3">add_circle_outline</mat-icon>
|
|
||||||
<p class="text-sm">Drag components here from the palette</p>
|
|
||||||
</div>
|
|
||||||
<div *ngFor="let comp of components; let i = index"
|
|
||||||
cdkDrag
|
|
||||||
[cdkDragData]="comp"
|
|
||||||
(click)="selectComponent(comp.id)"
|
|
||||||
class="relative rounded-lg border-2 transition-all cursor-pointer"
|
|
||||||
[class.border-blue-500]="selectedComponentId === comp.id"
|
|
||||||
[class.border-transparent]="selectedComponentId !== comp.id"
|
|
||||||
[class.hover:border-blue-300]="selectedComponentId !== comp.id"
|
|
||||||
[class.bg-white]="true"
|
|
||||||
[class.shadow-sm]="true">
|
|
||||||
|
|
||||||
<div class="absolute top-1 right-1 z-10 flex gap-1 opacity-0 hover:opacity-100 transition-opacity">
|
|
||||||
<button mat-icon-button (click)="$event.stopPropagation(); removeComponent(comp.id)"
|
|
||||||
class="w-6 h-6 flex items-center justify-center bg-red-500 text-white rounded-full text-xs">
|
|
||||||
<mat-icon style="font-size: 14px; width: 14px; height: 14px;">close</mat-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-3" [class.pointer-events-none]="true">
|
|
||||||
<ng-container [ngSwitch]="comp.type">
|
|
||||||
<app-heading-renderer *ngSwitchCase="'HEADING'" [config]="comp.config" [styles]="comp.styles"></app-heading-renderer>
|
|
||||||
<app-text-renderer *ngSwitchCase="'TEXT'" [config]="comp.config" [styles]="comp.styles"></app-text-renderer>
|
|
||||||
<app-image-renderer *ngSwitchCase="'IMAGE'" [config]="comp.config" [styles]="comp.styles"></app-image-renderer>
|
|
||||||
<app-button-renderer *ngSwitchCase="'BUTTON'" [config]="comp.config" [styles]="comp.styles"></app-button-renderer>
|
|
||||||
<app-divider-renderer *ngSwitchCase="'DIVIDER'" [config]="comp.config" [styles]="comp.styles"></app-divider-renderer>
|
|
||||||
<app-gallery-renderer *ngSwitchCase="'GALLERY'" [config]="comp.config" [styles]="comp.styles"></app-gallery-renderer>
|
|
||||||
<app-video-renderer *ngSwitchCase="'VIDEO'" [config]="comp.config" [styles]="comp.styles"></app-video-renderer>
|
|
||||||
<app-contact-form-renderer *ngSwitchCase="'CONTACT_FORM'" [config]="comp.config" [styles]="comp.styles"></app-contact-form-renderer>
|
|
||||||
<app-map-renderer *ngSwitchCase="'MAP'" [config]="comp.config" [styles]="comp.styles"></app-map-renderer>
|
|
||||||
<app-custom-html-renderer *ngSwitchCase="'CUSTOM_HTML'" [config]="comp.config" [styles]="comp.styles"></app-custom-html-renderer>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- drag handle -->
|
|
||||||
<div class="absolute top-1 left-1 text-gray-300 cursor-grab opacity-0 hover:opacity-100 transition-opacity"
|
|
||||||
*cdkDragHandle>
|
|
||||||
<mat-icon style="font-size: 16px; width: 16px; height: 16px;">drag_indicator</mat-icon>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- type badge -->
|
|
||||||
<div class="absolute bottom-1 left-1 text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">
|
|
||||||
{{ comp.type }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
:host { display: flex; flex-direction: column; height: 100%; }
|
|
||||||
.cdk-drag-preview {
|
|
||||||
@apply bg-white shadow-lg rounded-lg border border-blue-200 p-3;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.cdk-drag-placeholder { opacity: 0; }
|
|
||||||
.cdk-drag-animating { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }
|
|
||||||
.cdk-drop-list-dragging .cdk-drag { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }
|
|
||||||
`],
|
|
||||||
})
|
|
||||||
export class CanvasComponent {
|
|
||||||
constructor(private state: BuilderStateService) {}
|
|
||||||
|
|
||||||
get selectedPage() { return this.state.selectedPage; }
|
|
||||||
get components(): ComponentItem[] { return this.state.selectedPage?.components ?? []; }
|
|
||||||
get selectedComponentId(): string | null { return this.state.selectedComponentId; }
|
|
||||||
|
|
||||||
selectComponent(id: string): void {
|
|
||||||
this.state.selectComponent(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeComponent(id: string): void {
|
|
||||||
this.state.removeComponent(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDrop(event: CdkDragDrop<ComponentItem[], any>): void {
|
|
||||||
if (event.previousContainer === event.container) {
|
|
||||||
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
|
|
||||||
const pageId = this.selectedPage?.id;
|
|
||||||
if (pageId) {
|
|
||||||
this.state.reorderComponents(pageId, event.container.data);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const data = event.previousContainer.data[event.previousIndex];
|
|
||||||
this.state.addComponent(data as any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
import { NgFor } from '@angular/common';
|
|
||||||
import { CdkDrag, CdkDropList } from '@angular/cdk/drag-drop';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatListModule } from '@angular/material/list';
|
|
||||||
import { ComponentType, getComponentLabel, getComponentIcon } from '../../core/models/component';
|
|
||||||
import { BuilderStateService } from '../services/builder-state.service';
|
|
||||||
|
|
||||||
const CATEGORIES: { label: string; types: ComponentType[] }[] = [
|
|
||||||
{
|
|
||||||
label: 'Content',
|
|
||||||
types: ['HEADING', 'TEXT', 'DIVIDER'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Media',
|
|
||||||
types: ['IMAGE', 'GALLERY', 'VIDEO'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Interactive',
|
|
||||||
types: ['BUTTON', 'CONTACT_FORM', 'MAP'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Advanced',
|
|
||||||
types: ['CUSTOM_HTML'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-component-palette',
|
|
||||||
standalone: true,
|
|
||||||
imports: [NgFor, CdkDropList, CdkDrag, MatIconModule, MatListModule],
|
|
||||||
template: `
|
|
||||||
<div class="p-3">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">Components</h3>
|
|
||||||
<div cdkDropList [cdkDropListData]="allTypes" (cdkDropListDropped)="noop()" class="space-y-4">
|
|
||||||
<div *ngFor="let cat of categories" class="mb-4">
|
|
||||||
<p class="text-xs text-gray-400 uppercase px-2 mb-2">{{ cat.label }}</p>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div *ngFor="let type of cat.types"
|
|
||||||
cdkDrag
|
|
||||||
[cdkDragData]="type"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 rounded-md cursor-grab
|
|
||||||
text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600
|
|
||||||
transition-colors border border-transparent hover:border-blue-200"
|
|
||||||
(click)="addComponent(type)">
|
|
||||||
<mat-icon class="text-lg" style="width: 20px; height: 20px; font-size: 18px;">{{ getIcon(type) }}</mat-icon>
|
|
||||||
<span>{{ getLabel(type) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
:host { display: block; }
|
|
||||||
.cdk-drag-preview {
|
|
||||||
@apply bg-white shadow-lg rounded-md border border-blue-200 px-3 py-2 text-sm;
|
|
||||||
}
|
|
||||||
.cdk-drag-placeholder { opacity: 0; }
|
|
||||||
`],
|
|
||||||
})
|
|
||||||
export class ComponentPalette {
|
|
||||||
categories = CATEGORIES;
|
|
||||||
allTypes: ComponentType[] = CATEGORIES.flatMap(c => c.types);
|
|
||||||
|
|
||||||
constructor(private state: BuilderStateService) {}
|
|
||||||
|
|
||||||
getLabel(type: ComponentType): string {
|
|
||||||
return getComponentLabel(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
getIcon(type: ComponentType): string {
|
|
||||||
return getComponentIcon(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
addComponent(type: ComponentType): void {
|
|
||||||
this.state.addComponent(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
noop(): void {}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { Component, Inject, inject } from '@angular/core';
|
|
||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
||||||
import { NgIf } from '@angular/common';
|
|
||||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { PageService } from '../../core/services/page.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-create-page-dialog',
|
|
||||||
standalone: true,
|
|
||||||
imports: [NgIf, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, ReactiveFormsModule],
|
|
||||||
template: `
|
|
||||||
<h2 mat-dialog-title>Add Page</h2>
|
|
||||||
<mat-dialog-content>
|
|
||||||
<form [formGroup]="form" class="flex flex-col gap-4 pt-2">
|
|
||||||
<mat-form-field appearance="outline" class="w-full">
|
|
||||||
<mat-label>Page Title</mat-label>
|
|
||||||
<input matInput formControlName="title" placeholder="About" />
|
|
||||||
<mat-error>Title is required</mat-error>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="w-full">
|
|
||||||
<mat-label>Slug</mat-label>
|
|
||||||
<input matInput formControlName="slug" placeholder="about" />
|
|
||||||
<mat-error *ngIf="form.get('slug')?.hasError('required')">Slug is required</mat-error>
|
|
||||||
<mat-error *ngIf="form.get('slug')?.hasError('pattern')">Lowercase letters, numbers, and hyphens only</mat-error>
|
|
||||||
</mat-form-field>
|
|
||||||
</form>
|
|
||||||
</mat-dialog-content>
|
|
||||||
<mat-dialog-actions align="end">
|
|
||||||
<button mat-button mat-dialog-close>Cancel</button>
|
|
||||||
<button mat-flat-button color="primary" [disabled]="form.invalid || loading" (click)="onCreate()">
|
|
||||||
{{ loading ? 'Creating...' : 'Create' }}
|
|
||||||
</button>
|
|
||||||
</mat-dialog-actions>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class CreatePageDialog {
|
|
||||||
private fb = inject(FormBuilder);
|
|
||||||
private dialogRef = inject(MatDialogRef<CreatePageDialog>);
|
|
||||||
private pageService = inject(PageService);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const data: { siteId: string } = inject(MAT_DIALOG_DATA);
|
|
||||||
this.siteId = data.siteId;
|
|
||||||
}
|
|
||||||
|
|
||||||
siteId: string;
|
|
||||||
|
|
||||||
form = this.fb.group({
|
|
||||||
title: ['', Validators.required],
|
|
||||||
slug: ['', [Validators.pattern(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/)]],
|
|
||||||
});
|
|
||||||
loading = false;
|
|
||||||
|
|
||||||
onCreate(): void {
|
|
||||||
if (this.form.invalid) return;
|
|
||||||
this.loading = true;
|
|
||||||
this.pageService.create(this.siteId, {
|
|
||||||
title: this.form.value.title || '',
|
|
||||||
slug: this.form.value.slug || '',
|
|
||||||
orderIndex: 0,
|
|
||||||
}).subscribe({
|
|
||||||
next: (page) => {
|
|
||||||
this.dialogRef.close(page);
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.loading = false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/core';
|
|
||||||
import { NgFor, NgIf } from '@angular/common';
|
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
|
||||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { ComponentItem, ComponentType } from '../../core/models/component';
|
|
||||||
|
|
||||||
type DeviceType = 'desktop' | 'tablet' | 'mobile';
|
|
||||||
|
|
||||||
const DEVICE_WIDTHS: Record<DeviceType, string> = {
|
|
||||||
desktop: '100%',
|
|
||||||
tablet: '768px',
|
|
||||||
mobile: '375px',
|
|
||||||
};
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-preview',
|
|
||||||
standalone: true,
|
|
||||||
imports: [NgIf, NgFor, MatButtonToggleModule, MatIconModule],
|
|
||||||
template: `
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
<div class="flex items-center justify-between px-4 py-2 border-b bg-white">
|
|
||||||
<h3 class="text-sm font-medium text-gray-700">Preview</h3>
|
|
||||||
<mat-button-toggle-group [(value)]="device" (change)="onDeviceChange()" class="scale-75 origin-right">
|
|
||||||
<mat-button-toggle value="desktop" aria-label="Desktop">
|
|
||||||
<mat-icon>desktop_windows</mat-icon>
|
|
||||||
</mat-button-toggle>
|
|
||||||
<mat-button-toggle value="tablet" aria-label="Tablet">
|
|
||||||
<mat-icon>tablet_mac</mat-icon>
|
|
||||||
</mat-button-toggle>
|
|
||||||
<mat-button-toggle value="mobile" aria-label="Mobile">
|
|
||||||
<mat-icon>phone_iphone</mat-icon>
|
|
||||||
</mat-button-toggle>
|
|
||||||
</mat-button-toggle-group>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-auto bg-gray-200 p-4">
|
|
||||||
<div class="bg-white shadow-lg transition-all duration-300 min-h-[600px]"
|
|
||||||
[style.width]="deviceWidth"
|
|
||||||
style="margin: 0 auto;">
|
|
||||||
<div *ngIf="!previewHtml" class="flex items-center justify-center h-64 text-gray-400 text-sm">
|
|
||||||
No components to preview
|
|
||||||
</div>
|
|
||||||
<div *ngIf="previewHtml" [innerHTML]="previewHtml"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class PreviewComponent implements OnChanges {
|
|
||||||
@Input() components: ComponentItem[] = [];
|
|
||||||
@Input() theme: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
private sanitizer = inject(DomSanitizer);
|
|
||||||
|
|
||||||
device: DeviceType = 'desktop';
|
|
||||||
previewHtml: SafeHtml = '';
|
|
||||||
|
|
||||||
get deviceWidth(): string {
|
|
||||||
return DEVICE_WIDTHS[this.device];
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
|
||||||
this.renderPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
onDeviceChange(): void {
|
|
||||||
// width change is handled by deviceWidth getter
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderPreview(): void {
|
|
||||||
const html = this.buildHtml();
|
|
||||||
this.previewHtml = this.sanitizer.bypassSecurityTrustHtml(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildHtml(): string {
|
|
||||||
const tc = this.theme;
|
|
||||||
const styleVars = `
|
|
||||||
--primary-color: ${tc['primaryColor'] || '#1976d2'};
|
|
||||||
--secondary-color: ${tc['secondaryColor'] || '#dc004e'};
|
|
||||||
--background-color: ${tc['backgroundColor'] || '#ffffff'};
|
|
||||||
--text-color: ${tc['textColor'] || '#1a1a1a'};
|
|
||||||
--heading-font: ${tc['headingFont'] || 'Roboto'};
|
|
||||||
--body-font: ${tc['bodyFont'] || 'Roboto'};
|
|
||||||
--border-radius: ${tc['borderRadius'] || '4px'};
|
|
||||||
--spacing: ${tc['spacing'] || '16px'};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const componentHtml = this.components.map(c => this.renderComponent(c)).join('\n');
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div style="font-family: var(--body-font); color: var(--text-color); background: var(--background-color); padding: var(--spacing); ${styleVars}">
|
|
||||||
<style>
|
|
||||||
img, video, iframe, embed { max-width: 100%; height: auto; }
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
h1, h2, h3, h4, h5, h6 { word-break: break-word; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
${componentHtml}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderComponent(comp: ComponentItem): string {
|
|
||||||
const s = this.styleObjToStr(comp.styles);
|
|
||||||
return this.renderByType(comp.type, comp.config, s, comp.styles);
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderByType(type: ComponentType, config: Record<string, unknown>, styles: string, styleObj: Record<string, unknown> = {}): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'HEADING': {
|
|
||||||
const level = (config['level'] as string) || 'h2';
|
|
||||||
const text = config['text'] || '';
|
|
||||||
return `<${level} style="font-family: var(--heading-font); ${styles}">${text}</${level}>`;
|
|
||||||
}
|
|
||||||
case 'TEXT':
|
|
||||||
return `<div style="${styles}">${config['content'] || ''}</div>`;
|
|
||||||
case 'IMAGE': {
|
|
||||||
const src = config['src'] || '';
|
|
||||||
const alt = config['alt'] || '';
|
|
||||||
const link = config['linkTo'] as string;
|
|
||||||
const img = `<img src="${src}" alt="${alt}" style="${styles}" />`;
|
|
||||||
return link ? `<a href="${link}" target="_blank">${img}</a>` : img;
|
|
||||||
}
|
|
||||||
case 'BUTTON': {
|
|
||||||
const text = config['text'] || 'Button';
|
|
||||||
const link = config['link'] || '#';
|
|
||||||
return `<a href="${link}" style="display: inline-block; text-decoration: none; cursor: pointer; font-family: var(--body-font); ${styles}">${text}</a>`;
|
|
||||||
}
|
|
||||||
case 'DIVIDER': {
|
|
||||||
const borderStyle = (config['style'] as string) || 'solid';
|
|
||||||
const color = (styleObj['color'] as string) || '#e0e0e0';
|
|
||||||
const thickness = (styleObj['thickness'] as string) || '1px';
|
|
||||||
const margin = (styleObj['margin'] as string) || '1.5rem 0';
|
|
||||||
return `<hr style="border: none; border-top: ${thickness} ${borderStyle} ${color}; margin: ${margin};" />`;
|
|
||||||
}
|
|
||||||
case 'GALLERY': {
|
|
||||||
const images = (config['images'] as any[]) || [];
|
|
||||||
const cols = (styleObj['columns'] as string) || '3';
|
|
||||||
const gap = (styleObj['gap'] as string) || '8px';
|
|
||||||
const br = (styleObj['borderRadius'] as string) || '4px';
|
|
||||||
const items = images.map((img: any) =>
|
|
||||||
`<img src="${img.src}" alt="${img.alt || ''}" style="border-radius: ${br}; width: 100%; height: auto; object-fit: cover;" />`
|
|
||||||
).join('');
|
|
||||||
return `<div style="display: grid; grid-template-columns: repeat(${cols}, 1fr); gap: ${gap};">${items}</div>`;
|
|
||||||
}
|
|
||||||
case 'VIDEO': {
|
|
||||||
const src = config['src'] as string;
|
|
||||||
const controls = config['controls'] !== false ? 'controls' : '';
|
|
||||||
const autoplay = config['autoplay'] ? 'autoplay' : '';
|
|
||||||
const w = (styleObj['width'] as string) || '100%';
|
|
||||||
const mw = (styleObj['maxWidth'] as string) || '800px';
|
|
||||||
const br2 = (styleObj['borderRadius'] as string) || '4px';
|
|
||||||
return `<video src="${src}" ${controls} ${autoplay} style="width: ${w}; max-width: ${mw}; border-radius: ${br2}; max-width: 100%;"></video>`;
|
|
||||||
}
|
|
||||||
case 'CONTACT_FORM': {
|
|
||||||
const fields = (config['fields'] as string[]) || ['name', 'email', 'message'];
|
|
||||||
const submitText = config['submitText'] || 'Send';
|
|
||||||
const bg = (styleObj['backgroundColor'] as string) || '#f5f5f5';
|
|
||||||
const pad = (styleObj['padding'] as string) || '2rem';
|
|
||||||
const br3 = (styleObj['borderRadius'] as string) || '8px';
|
|
||||||
const inputs = fields.map(f =>
|
|
||||||
f === 'message'
|
|
||||||
? `<div style="margin-bottom: 1rem;"><label style="display: block; font-size: 0.875rem; margin-bottom: 0.25rem; text-transform: capitalize;">${f}</label><textarea rows="4" placeholder="${f}" style="width: 100%; border: 1px solid #d1d5db; border-radius: 4px; padding: 0.5rem; font-size: 0.875rem;"></textarea></div>`
|
|
||||||
: `<div style="margin-bottom: 1rem;"><label style="display: block; font-size: 0.875rem; margin-bottom: 0.25rem; text-transform: capitalize;">${f}</label><input type="${f === 'email' ? 'email' : 'text'}" placeholder="${f}" style="width: 100%; border: 1px solid #d1d5db; border-radius: 4px; padding: 0.5rem; font-size: 0.875rem;" /></div>`
|
|
||||||
).join('');
|
|
||||||
return `<form style="background: ${bg}; padding: ${pad}; border-radius: ${br3};">${inputs}<button type="submit" style="background-color: var(--primary-color); color: white; padding: 0.5rem 1.5rem; border: none; border-radius: 4px; cursor: pointer;">${submitText}</button></form>`;
|
|
||||||
}
|
|
||||||
case 'MAP': {
|
|
||||||
const addr = (config['address'] as string) || 'New York, NY';
|
|
||||||
const h = (styleObj['height'] as string) || '400px';
|
|
||||||
const br4 = (styleObj['borderRadius'] as string) || '4px';
|
|
||||||
const encoded = encodeURIComponent(addr);
|
|
||||||
return `<div style="display: flex; align-items: center; justify-content: center; width: 100%; height: ${h}; border-radius: ${br4}; background: #e8f4f8; color: #333; font-family: sans-serif;">
|
|
||||||
<div style="text-align: center; padding: 2rem;">
|
|
||||||
<div style="font-size: 2rem; margin-bottom: 0.5rem;">📍</div>
|
|
||||||
<p style="margin: 0 0 0.5rem; font-weight: 600;">${addr}</p>
|
|
||||||
<a href="https://www.openstreetmap.org/search?query=${encoded}" target="_blank" style="font-size: 0.85rem; color: #1976d2; text-decoration: underline;">View on OpenStreetMap</a>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
case 'CUSTOM_HTML':
|
|
||||||
return `<div style="${styles}">${(config['html'] as string) || ''}</div>`;
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private styleObjToStr(styles: Record<string, unknown>): string {
|
|
||||||
return Object.entries(styles)
|
|
||||||
.map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`)
|
|
||||||
.join('; ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
import { Component, OnDestroy } from '@angular/core';
|
|
||||||
import { NgIf, NgFor, NgSwitch, NgSwitchCase, KeyValuePipe } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MatExpansionModule } from '@angular/material/expansion';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
|
||||||
import { Subscription } from 'rxjs';
|
|
||||||
import { BuilderStateService } from '../services/builder-state.service';
|
|
||||||
import { ComponentItem, getComponentLabel } from '../../core/models/component';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-property-panel',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
NgIf, NgFor, NgSwitch, NgSwitchCase, KeyValuePipe, FormsModule,
|
|
||||||
MatExpansionModule, MatFormFieldModule, MatInputModule, MatSelectModule,
|
|
||||||
MatButtonModule, MatIconModule, MatSlideToggleModule,
|
|
||||||
],
|
|
||||||
template: `
|
|
||||||
<div class="p-3 h-full overflow-y-auto">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">Properties</h3>
|
|
||||||
|
|
||||||
<div *ngIf="!component" class="flex flex-col items-center justify-center h-40 text-gray-400">
|
|
||||||
<mat-icon style="font-size: 36px; width: 36px; height: 36px;" class="mb-2">tune</mat-icon>
|
|
||||||
<p class="text-xs">Select a component to edit</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngIf="component">
|
|
||||||
<div class="mb-4 px-2">
|
|
||||||
<p class="text-sm font-medium text-gray-700">{{ componentLabel }}</p>
|
|
||||||
<p class="text-xs text-gray-400">{{ component.type }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<mat-accordion>
|
|
||||||
<mat-expansion-panel expanded>
|
|
||||||
<mat-expansion-panel-header>
|
|
||||||
<mat-panel-title class="text-sm font-medium">Content</mat-panel-title>
|
|
||||||
</mat-expansion-panel-header>
|
|
||||||
|
|
||||||
<ng-container [ngSwitch]="component.type">
|
|
||||||
|
|
||||||
<div *ngSwitchCase="'HEADING'" class="space-y-3">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Text</mat-label>
|
|
||||||
<input matInput [(ngModel)]="component.config['text']" (ngModelChange)="onConfigChange()" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Level</mat-label>
|
|
||||||
<mat-select [(value)]="component.config['level']" (selectionChange)="onConfigChange()">
|
|
||||||
<mat-option value="h1">H1</mat-option>
|
|
||||||
<mat-option value="h2">H2</mat-option>
|
|
||||||
<mat-option value="h3">H3</mat-option>
|
|
||||||
<mat-option value="h4">H4</mat-option>
|
|
||||||
<mat-option value="h5">H5</mat-option>
|
|
||||||
<mat-option value="h6">H6</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngSwitchCase="'TEXT'" class="space-y-3">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Content</mat-label>
|
|
||||||
<textarea matInput rows="4" [(ngModel)]="component.config['content']" (ngModelChange)="onConfigChange()"></textarea>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngSwitchCase="'IMAGE'" class="space-y-3">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Image URL</mat-label>
|
|
||||||
<input matInput [(ngModel)]="component.config['src']" (ngModelChange)="onConfigChange()" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Alt Text</mat-label>
|
|
||||||
<input matInput [(ngModel)]="component.config['alt']" (ngModelChange)="onConfigChange()" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Link To (optional)</mat-label>
|
|
||||||
<input matInput [(ngModel)]="component.config['linkTo']" (ngModelChange)="onConfigChange()" />
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngSwitchCase="'BUTTON'" class="space-y-3">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Button Text</mat-label>
|
|
||||||
<input matInput [(ngModel)]="component.config['text']" (ngModelChange)="onConfigChange()" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Link</mat-label>
|
|
||||||
<input matInput [(ngModel)]="component.config['link']" (ngModelChange)="onConfigChange()" />
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngSwitchCase="'DIVIDER'" class="space-y-3">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Style</mat-label>
|
|
||||||
<mat-select [(value)]="component.config['style']" (selectionChange)="onConfigChange()">
|
|
||||||
<mat-option value="solid">Solid</mat-option>
|
|
||||||
<mat-option value="dashed">Dashed</mat-option>
|
|
||||||
<mat-option value="dotted">Dotted</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngSwitchCase="'VIDEO'" class="space-y-3">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Video URL</mat-label>
|
|
||||||
<input matInput [(ngModel)]="component.config['src']" (ngModelChange)="onConfigChange()" />
|
|
||||||
</mat-form-field>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<mat-slide-toggle [(ngModel)]="component.config['autoplay']" (ngModelChange)="onConfigChange()">
|
|
||||||
Autoplay
|
|
||||||
</mat-slide-toggle>
|
|
||||||
<mat-slide-toggle [(ngModel)]="component.config['controls']" (ngModelChange)="onConfigChange()">
|
|
||||||
Controls
|
|
||||||
</mat-slide-toggle>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngSwitchCase="'CONTACT_FORM'" class="space-y-3">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Submit Button Text</mat-label>
|
|
||||||
<input matInput [(ngModel)]="component.config['submitText']" (ngModelChange)="onConfigChange()" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Email To</mat-label>
|
|
||||||
<input matInput [(ngModel)]="component.config['emailTo']" (ngModelChange)="onConfigChange()" />
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngSwitchCase="'MAP'" class="space-y-3">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Address</mat-label>
|
|
||||||
<input matInput [(ngModel)]="component.config['address']" (ngModelChange)="onConfigChange()" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Zoom Level</mat-label>
|
|
||||||
<mat-select [(value)]="component.config['zoom']" (selectionChange)="onConfigChange()">
|
|
||||||
<mat-option *ngFor="let z of [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]" [value]="z">{{ z }}</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngSwitchCase="'GALLERY'" class="space-y-3">
|
|
||||||
<div *ngFor="let img of getImages(); let i = index" class="flex items-center gap-2 p-2 border rounded">
|
|
||||||
<span class="text-xs truncate flex-1">{{ img.src }}</span>
|
|
||||||
<button mat-icon-button type="button" (click)="removeImage(i)" class="w-6 h-6 flex items-center justify-center">
|
|
||||||
<mat-icon style="font-size: 14px;">close</mat-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<mat-form-field appearance="outline" class="flex-1">
|
|
||||||
<mat-label>Image URL</mat-label>
|
|
||||||
<input matInput [(ngModel)]="newImageUrl" placeholder="https://..." (keydown.enter)="addImage()" />
|
|
||||||
</mat-form-field>
|
|
||||||
<button mat-stroked-button type="button" (click)="addImage()">Add</button>
|
|
||||||
</div>
|
|
||||||
<p *ngIf="getImages().length === 0" class="text-xs text-gray-400 px-1">
|
|
||||||
No images. Add image URLs above.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngSwitchCase="'CUSTOM_HTML'" class="space-y-3">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>HTML</mat-label>
|
|
||||||
<textarea matInput rows="6" [(ngModel)]="component.config['html']" (ngModelChange)="onConfigChange()"></textarea>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</ng-container>
|
|
||||||
</mat-expansion-panel>
|
|
||||||
|
|
||||||
<mat-expansion-panel expanded>
|
|
||||||
<mat-expansion-panel-header>
|
|
||||||
<mat-panel-title class="text-sm font-medium">Styles</mat-panel-title>
|
|
||||||
</mat-expansion-panel-header>
|
|
||||||
|
|
||||||
<div class="space-y-3" *ngIf="component">
|
|
||||||
<div *ngFor="let entry of styleKeys">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>{{ formatKey(entry) }}</mat-label>
|
|
||||||
<input matInput [value]="component.styles[entry]"
|
|
||||||
(input)="onStyleChange(entry, $any($event.target).value)" />
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mat-expansion-panel>
|
|
||||||
</mat-accordion>
|
|
||||||
|
|
||||||
<div class="mt-4 px-2">
|
|
||||||
<button mat-stroked-button color="warn" size="small" (click)="removeComponent()" class="w-full">
|
|
||||||
<mat-icon class="mr-1">delete</mat-icon>
|
|
||||||
Remove Component
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class PropertyPanelComponent implements OnDestroy {
|
|
||||||
private sub: Subscription;
|
|
||||||
component: ComponentItem | null = null;
|
|
||||||
newImageUrl = '';
|
|
||||||
|
|
||||||
constructor(private state: BuilderStateService) {
|
|
||||||
this.sub = this.state.selectedComponentId$.subscribe(() => {
|
|
||||||
this.component = this.state.selectedComponent ? { ...this.state.selectedComponent } : null;
|
|
||||||
this.newImageUrl = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get componentLabel(): string {
|
|
||||||
return this.component ? getComponentLabel(this.component.type) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
get styleKeys(): string[] {
|
|
||||||
return this.component ? Object.keys(this.component.styles) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
formatKey(key: string): string {
|
|
||||||
return key.replace(/[A-Z]/g, c => ' ' + c.toLowerCase()).replace(/^./, s => s.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
getImages(): { src: string; alt?: string }[] {
|
|
||||||
return (this.component?.config['images'] as any[]) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
addImage(): void {
|
|
||||||
const url = this.newImageUrl?.trim();
|
|
||||||
if (!url || !this.component) return;
|
|
||||||
const images = [...this.getImages(), { src: url, alt: '' }];
|
|
||||||
this.component.config['images'] = images;
|
|
||||||
this.onConfigChange();
|
|
||||||
this.newImageUrl = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
removeImage(index: number): void {
|
|
||||||
if (!this.component) return;
|
|
||||||
const images = this.getImages().filter((_, i) => i !== index);
|
|
||||||
this.component.config['images'] = images;
|
|
||||||
this.onConfigChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
onConfigChange(): void {
|
|
||||||
if (this.component) {
|
|
||||||
this.state.updateComponent(this.component.id, { config: { ...this.component.config } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onStyleChange(key: string, value: string): void {
|
|
||||||
if (this.component) {
|
|
||||||
const updatedStyles = { ...this.component.styles, [key]: value };
|
|
||||||
this.component = { ...this.component, styles: updatedStyles };
|
|
||||||
this.state.updateComponent(this.component.id, { styles: updatedStyles });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeComponent(): void {
|
|
||||||
if (this.component) {
|
|
||||||
this.state.removeComponent(this.component.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.sub?.unsubscribe();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-button-renderer',
|
|
||||||
standalone: true,
|
|
||||||
template: `
|
|
||||||
<a [href]="config['link'] || '#'" class="inline-block text-center no-underline cursor-pointer"
|
|
||||||
[style]="styleStr">
|
|
||||||
{{ config['text'] }}
|
|
||||||
</a>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class ButtonRenderer {
|
|
||||||
@Input() config: Record<string, unknown> = {};
|
|
||||||
@Input() styles: Record<string, unknown> = {};
|
|
||||||
get styleStr(): string {
|
|
||||||
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
|
||||||
import { NgFor, NgIf } from '@angular/common';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-contact-form-renderer',
|
|
||||||
standalone: true,
|
|
||||||
imports: [NgFor, NgIf],
|
|
||||||
template: `
|
|
||||||
<form [style]="styleStr" class="space-y-4">
|
|
||||||
<div *ngFor="let field of fields">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1 capitalize">{{ field }}</label>
|
|
||||||
<input *ngIf="field !== 'message'" [type]="field === 'email' ? 'email' : 'text'"
|
|
||||||
[placeholder]="field"
|
|
||||||
class="w-full border border-gray-300 rounded px-3 py-2 text-sm" />
|
|
||||||
<textarea *ngIf="field === 'message'" rows="4" placeholder="Message"
|
|
||||||
class="w-full border border-gray-300 rounded px-3 py-2 text-sm"></textarea>
|
|
||||||
</div>
|
|
||||||
<button type="submit" [style]="btnStyle">{{ config['submitText'] || 'Send' }}</button>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class ContactFormRenderer {
|
|
||||||
@Input() config: Record<string, unknown> = {};
|
|
||||||
@Input() styles: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
get fields(): string[] {
|
|
||||||
return (this.config['fields'] as string[]) || ['name', 'email', 'message'];
|
|
||||||
}
|
|
||||||
|
|
||||||
get styleStr(): string {
|
|
||||||
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
|
|
||||||
}
|
|
||||||
|
|
||||||
get btnStyle(): string {
|
|
||||||
return 'background-color: #1976d2; color: white; padding: 0.5rem 1.5rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem;';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-custom-html-renderer',
|
|
||||||
standalone: true,
|
|
||||||
template: `<div [style]="styleStr" [innerHTML]="safeHtml"></div>`,
|
|
||||||
})
|
|
||||||
export class CustomHtmlRenderer {
|
|
||||||
@Input() config: Record<string, unknown> = {};
|
|
||||||
@Input() styles: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
constructor(private sanitizer: DomSanitizer) {}
|
|
||||||
|
|
||||||
get safeHtml(): SafeHtml {
|
|
||||||
return this.sanitizer.bypassSecurityTrustHtml((this.config['html'] as string) || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
get styleStr(): string {
|
|
||||||
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-divider-renderer',
|
|
||||||
standalone: true,
|
|
||||||
template: `<hr [style]="styleStr" />`,
|
|
||||||
})
|
|
||||||
export class DividerRenderer {
|
|
||||||
@Input() config: Record<string, unknown> = {};
|
|
||||||
@Input() styles: Record<string, unknown> = {};
|
|
||||||
get styleStr(): string {
|
|
||||||
const borderStyle = this.config['style'] || 'solid';
|
|
||||||
const color = this.styles['color'] || '#e0e0e0';
|
|
||||||
const thickness = this.styles['thickness'] || '1px';
|
|
||||||
const margin = this.styles['margin'] || '1.5rem 0';
|
|
||||||
return `border: none; border-top: ${thickness} ${borderStyle} ${color}; margin: ${margin};`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
|
||||||
import { NgFor, NgIf } from '@angular/common';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-gallery-renderer',
|
|
||||||
standalone: true,
|
|
||||||
imports: [NgFor, NgIf],
|
|
||||||
template: `
|
|
||||||
<div [style]="gridStyle">
|
|
||||||
<img *ngFor="let img of images" [src]="img.src" [alt]="img.alt || ''"
|
|
||||||
[style]="imgStyle" class="w-full h-auto object-cover" />
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class GalleryRenderer {
|
|
||||||
@Input() config: Record<string, unknown> = {};
|
|
||||||
@Input() styles: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
get images(): { src: string; alt?: string }[] {
|
|
||||||
return (this.config['images'] as any[]) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
get gridStyle(): string {
|
|
||||||
const cols = this.styles['columns'] || '3';
|
|
||||||
const gap = this.styles['gap'] || '8px';
|
|
||||||
return `display: grid; grid-template-columns: repeat(${cols}, 1fr); gap: ${gap};`;
|
|
||||||
}
|
|
||||||
|
|
||||||
get imgStyle(): string {
|
|
||||||
const br = this.styles['borderRadius'] || '4px';
|
|
||||||
return `border-radius: ${br};`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
|
||||||
import { NgIf } from '@angular/common';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-heading-renderer',
|
|
||||||
standalone: true,
|
|
||||||
imports: [NgIf],
|
|
||||||
template: `
|
|
||||||
<h1 *ngIf="config['level'] === 'h1'" [style]="styleStr">{{ config['text'] }}</h1>
|
|
||||||
<h2 *ngIf="config['level'] === 'h2' || !config['level']" [style]="styleStr">{{ config['text'] }}</h2>
|
|
||||||
<h3 *ngIf="config['level'] === 'h3'" [style]="styleStr">{{ config['text'] }}</h3>
|
|
||||||
<h4 *ngIf="config['level'] === 'h4'" [style]="styleStr">{{ config['text'] }}</h4>
|
|
||||||
<h5 *ngIf="config['level'] === 'h5'" [style]="styleStr">{{ config['text'] }}</h5>
|
|
||||||
<h6 *ngIf="config['level'] === 'h6'" [style]="styleStr">{{ config['text'] }}</h6>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class HeadingRenderer {
|
|
||||||
@Input() config: Record<string, unknown> = {};
|
|
||||||
@Input() styles: Record<string, unknown> = {};
|
|
||||||
get styleStr(): string {
|
|
||||||
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
|
||||||
import { NgIf } from '@angular/common';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-image-renderer',
|
|
||||||
standalone: true,
|
|
||||||
imports: [NgIf],
|
|
||||||
template: `
|
|
||||||
<a *ngIf="config['linkTo']" [href]="config['linkTo']" target="_blank">
|
|
||||||
<img [src]="config['src'] || ''" [alt]="config['alt'] || ''" [style]="styleStr" />
|
|
||||||
</a>
|
|
||||||
<img *ngIf="!config['linkTo']" [src]="config['src'] || ''" [alt]="config['alt'] || ''" [style]="styleStr" />
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class ImageRenderer {
|
|
||||||
@Input() config: Record<string, unknown> = {};
|
|
||||||
@Input() styles: Record<string, unknown> = {};
|
|
||||||
get styleStr(): string {
|
|
||||||
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core';
|
|
||||||
import * as L from 'leaflet';
|
|
||||||
|
|
||||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)['_getIconUrl'];
|
|
||||||
L.Icon.Default.mergeOptions({
|
|
||||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
|
||||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
|
||||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
|
||||||
});
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-map-renderer',
|
|
||||||
standalone: true,
|
|
||||||
template: `
|
|
||||||
<div [style]="styleStr" class="h-full">
|
|
||||||
<div #mapContainer class="w-full" [style.height]="mapHeight"></div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class MapRenderer implements AfterViewInit, OnDestroy {
|
|
||||||
@Input() config: Record<string, unknown> = {};
|
|
||||||
@Input() styles: Record<string, unknown> = {};
|
|
||||||
@ViewChild('mapContainer') mapContainer!: ElementRef;
|
|
||||||
|
|
||||||
private map: L.Map | null = null;
|
|
||||||
|
|
||||||
get mapHeight(): string {
|
|
||||||
return (this.styles['height'] as string) || '400px';
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
this.initMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.map?.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initMap(): Promise<void> {
|
|
||||||
const address = (this.config['address'] as string) || 'New York, NY';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1`
|
|
||||||
);
|
|
||||||
const data = await res.json();
|
|
||||||
if (data?.length > 0) {
|
|
||||||
const lat = parseFloat(data[0].lat);
|
|
||||||
const lon = parseFloat(data[0].lon);
|
|
||||||
this.createMap(lat, lon);
|
|
||||||
} else {
|
|
||||||
this.createMap(40.7128, -74.006);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
this.createMap(40.7128, -74.006);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createMap(lat: number, lon: number): void {
|
|
||||||
if (this.map || !this.mapContainer) return;
|
|
||||||
const el = this.mapContainer.nativeElement;
|
|
||||||
this.map = L.map(el, { zoomControl: true }).setView([lat, lon], 13);
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
||||||
maxZoom: 19,
|
|
||||||
}).addTo(this.map);
|
|
||||||
L.marker([lat, lon]).addTo(this.map);
|
|
||||||
}
|
|
||||||
|
|
||||||
get styleStr(): string {
|
|
||||||
return Object.entries(this.styles)
|
|
||||||
.map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`)
|
|
||||||
.join('; ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-text-renderer',
|
|
||||||
standalone: true,
|
|
||||||
template: `<div [style]="styleStr" [innerHTML]="safeContent"></div>`,
|
|
||||||
})
|
|
||||||
export class TextRenderer {
|
|
||||||
@Input() config: Record<string, unknown> = {};
|
|
||||||
@Input() styles: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
constructor(private sanitizer: DomSanitizer) {}
|
|
||||||
|
|
||||||
get safeContent(): SafeHtml {
|
|
||||||
return this.sanitizer.bypassSecurityTrustHtml((this.config['content'] as string) || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
get styleStr(): string {
|
|
||||||
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
|
||||||
import { NgIf } from '@angular/common';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-video-renderer',
|
|
||||||
standalone: true,
|
|
||||||
imports: [NgIf],
|
|
||||||
template: `
|
|
||||||
<div *ngIf="isEmbed" [style]="styleStr" class="aspect-video">
|
|
||||||
<iframe [src]="embedUrl" class="w-full h-full border-0" allowfullscreen></iframe>
|
|
||||||
</div>
|
|
||||||
<video *ngIf="!isEmbed" [src]="config['src']" [style]="styleStr"
|
|
||||||
[autoplay]="config['autoplay'] || false"
|
|
||||||
[controls]="config['controls'] !== false" class="max-w-full"></video>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class VideoRenderer {
|
|
||||||
@Input() config: Record<string, unknown> = {};
|
|
||||||
@Input() styles: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
get isEmbed(): boolean {
|
|
||||||
const src = (this.config['src'] as string) || '';
|
|
||||||
return src.includes('youtube') || src.includes('vimeo');
|
|
||||||
}
|
|
||||||
|
|
||||||
get embedUrl(): string {
|
|
||||||
const src = (this.config['src'] as string) || '';
|
|
||||||
if (src.includes('youtube') || src.includes('youtu.be')) {
|
|
||||||
const match = src.match(/(?:v=|youtu\.be\/)([\w-]+)/);
|
|
||||||
return match ? `https://www.youtube.com/embed/${match[1]}` : src;
|
|
||||||
}
|
|
||||||
if (src.includes('vimeo')) {
|
|
||||||
const match = src.match(/vimeo\.com\/(\d+)/);
|
|
||||||
return match ? `https://player.vimeo.com/video/${match[1]}` : src;
|
|
||||||
}
|
|
||||||
return src;
|
|
||||||
}
|
|
||||||
|
|
||||||
get styleStr(): string {
|
|
||||||
return Object.entries(this.styles).map(([k, v]) => `${k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}: ${v}`).join('; ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { Injectable, OnDestroy, inject } from '@angular/core';
|
|
||||||
import { Subject, Subscription, debounceTime, tap, switchMap } from 'rxjs';
|
|
||||||
import { BuilderApiService } from '../builder-api.service';
|
|
||||||
import { BuilderStateService } from './builder-state.service';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class AutoSaveService implements OnDestroy {
|
|
||||||
private api = inject(BuilderApiService);
|
|
||||||
private state = inject(BuilderStateService);
|
|
||||||
private saveSubject = new Subject<void>();
|
|
||||||
private subscription: Subscription;
|
|
||||||
saveStatus = new Subject<'saving' | 'saved' | 'idle'>();
|
|
||||||
private currentStatus: 'saving' | 'saved' | 'idle' = 'idle';
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.subscription = this.saveSubject.pipe(
|
|
||||||
debounceTime(2000),
|
|
||||||
tap(() => {
|
|
||||||
this.state.setSaving(true);
|
|
||||||
this.setStatus('saving');
|
|
||||||
}),
|
|
||||||
switchMap(() => this.performSave()),
|
|
||||||
).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.state.setSaving(false);
|
|
||||||
this.setStatus('saved');
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.state.setSaving(false);
|
|
||||||
this.setStatus('idle');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerSave(): void {
|
|
||||||
this.saveSubject.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
private performSave(): Promise<void> {
|
|
||||||
const siteId = this.state.siteId;
|
|
||||||
if (!siteId) return Promise.resolve();
|
|
||||||
const pages = this.state.pages;
|
|
||||||
const saves = pages.map(page => {
|
|
||||||
const req = this.state.toSaveRequest(page.id);
|
|
||||||
return this.api.saveComponents(siteId, req.pageId, { components: req.components }).toPromise();
|
|
||||||
});
|
|
||||||
return Promise.all(saves).then(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
private setStatus(status: 'saving' | 'saved' | 'idle'): void {
|
|
||||||
this.currentStatus = status;
|
|
||||||
this.saveStatus.next(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.subscription?.unsubscribe();
|
|
||||||
this.saveSubject.complete();
|
|
||||||
this.saveStatus.complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { BehaviorSubject, Observable } from 'rxjs';
|
|
||||||
import { ComponentItem, ComponentType, getDefaultConfig, getDefaultStyles, PageWithComponentsResponse } from '../../core/models/component';
|
|
||||||
|
|
||||||
interface Snapshot {
|
|
||||||
pages: PageState[];
|
|
||||||
selectedPageId: string | null;
|
|
||||||
selectedComponentId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageState {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
components: ComponentItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HistoryEntry {
|
|
||||||
snapshot: Snapshot;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_HISTORY = 50;
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class BuilderStateService {
|
|
||||||
private pagesSubject = new BehaviorSubject<PageState[]>([]);
|
|
||||||
private selectedPageIdSubject = new BehaviorSubject<string | null>(null);
|
|
||||||
private selectedComponentIdSubject = new BehaviorSubject<string | null>(null);
|
|
||||||
private themeConfigSubject = new BehaviorSubject<Record<string, unknown>>({});
|
|
||||||
private loadingSubject = new BehaviorSubject<boolean>(false);
|
|
||||||
private savingSubject = new BehaviorSubject<boolean>(false);
|
|
||||||
private siteIdSubject = new BehaviorSubject<string | null>(null);
|
|
||||||
|
|
||||||
pages$ = this.pagesSubject.asObservable();
|
|
||||||
selectedPageId$ = this.selectedPageIdSubject.asObservable();
|
|
||||||
selectedComponentId$ = this.selectedComponentIdSubject.asObservable();
|
|
||||||
themeConfig$ = this.themeConfigSubject.asObservable();
|
|
||||||
loading$ = this.loadingSubject.asObservable();
|
|
||||||
saving$ = this.savingSubject.asObservable();
|
|
||||||
|
|
||||||
private undoStack: HistoryEntry[] = [];
|
|
||||||
private redoStack: HistoryEntry[] = [];
|
|
||||||
private skipHistory = false;
|
|
||||||
|
|
||||||
get pages(): PageState[] { return this.pagesSubject.value; }
|
|
||||||
get selectedPageId(): string | null { return this.selectedPageIdSubject.value; }
|
|
||||||
get selectedComponentId(): string | null { return this.selectedComponentIdSubject.value; }
|
|
||||||
get selectedPage(): PageState | null {
|
|
||||||
return this.pages.find(p => p.id === this.selectedPageId) ?? null;
|
|
||||||
}
|
|
||||||
get selectedComponent(): ComponentItem | null {
|
|
||||||
const page = this.selectedPage;
|
|
||||||
if (!page || !this.selectedComponentId) return null;
|
|
||||||
return page.components.find(c => c.id === this.selectedComponentId) ?? null;
|
|
||||||
}
|
|
||||||
get themeConfig(): Record<string, unknown> { return this.themeConfigSubject.value; }
|
|
||||||
get siteId(): string | null { return this.siteIdSubject.value; }
|
|
||||||
|
|
||||||
init(siteId: string, pages: PageWithComponentsResponse[]): void {
|
|
||||||
this.siteIdSubject.next(siteId);
|
|
||||||
const pageStates = pages.map(p => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
slug: p.slug,
|
|
||||||
components: p.components.map(c => ({
|
|
||||||
id: c.id,
|
|
||||||
pageId: c.pageId,
|
|
||||||
type: c.type,
|
|
||||||
config: c.config,
|
|
||||||
styles: c.styles,
|
|
||||||
orderIndex: c.orderIndex,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
this.pagesSubject.next(pageStates);
|
|
||||||
if (pageStates.length > 0) {
|
|
||||||
this.selectedPageIdSubject.next(pageStates[0].id);
|
|
||||||
}
|
|
||||||
this.selectedComponentIdSubject.next(null);
|
|
||||||
this.undoStack = [];
|
|
||||||
this.redoStack = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
setThemeConfig(theme: Record<string, unknown>): void {
|
|
||||||
this.themeConfigSubject.next(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateThemeConfig(partial: Record<string, unknown>): void {
|
|
||||||
const current = { ...this.themeConfigSubject.value, ...partial };
|
|
||||||
this.themeConfigSubject.next(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
selectPage(pageId: string): void {
|
|
||||||
this.selectedPageIdSubject.next(pageId);
|
|
||||||
this.selectedComponentIdSubject.next(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
selectComponent(componentId: string | null): void {
|
|
||||||
this.selectedComponentIdSubject.next(componentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
addComponent(type: ComponentType, pageId?: string): void {
|
|
||||||
this.pushHistory('Add ' + type);
|
|
||||||
const targetPageId = pageId || this.selectedPageId;
|
|
||||||
if (!targetPageId) return;
|
|
||||||
const pages = this.pagesSubject.value.map(p => {
|
|
||||||
if (p.id !== targetPageId) return p;
|
|
||||||
const newComponent: ComponentItem = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
type,
|
|
||||||
config: getDefaultConfig(type),
|
|
||||||
styles: getDefaultStyles(type),
|
|
||||||
orderIndex: p.components.length,
|
|
||||||
};
|
|
||||||
return { ...p, components: [...p.components, newComponent] };
|
|
||||||
});
|
|
||||||
this.pagesSubject.next(pages);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeComponent(componentId: string): void {
|
|
||||||
this.pushHistory('Remove component');
|
|
||||||
const pages = this.pagesSubject.value.map(p => ({
|
|
||||||
...p,
|
|
||||||
components: p.components.filter(c => c.id !== componentId).map((c, i) => ({ ...c, orderIndex: i })),
|
|
||||||
}));
|
|
||||||
this.pagesSubject.next(pages);
|
|
||||||
if (this.selectedComponentId === componentId) {
|
|
||||||
this.selectedComponentIdSubject.next(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateComponent(componentId: string, changes: Partial<ComponentItem>): void {
|
|
||||||
if (!this.skipHistory) this.pushHistory('Update component');
|
|
||||||
const pages = this.pagesSubject.value.map(p => ({
|
|
||||||
...p,
|
|
||||||
components: p.components.map(c => c.id === componentId ? { ...c, ...changes } : c),
|
|
||||||
}));
|
|
||||||
this.pagesSubject.next(pages);
|
|
||||||
}
|
|
||||||
|
|
||||||
reorderComponents(pageId: string, components: ComponentItem[]): void {
|
|
||||||
this.pushHistory('Reorder');
|
|
||||||
const reordered = components.map((c, i) => ({ ...c, orderIndex: i }));
|
|
||||||
const pages = this.pagesSubject.value.map(p =>
|
|
||||||
p.id === pageId ? { ...p, components: reordered } : p
|
|
||||||
);
|
|
||||||
this.pagesSubject.next(pages);
|
|
||||||
}
|
|
||||||
|
|
||||||
moveComponentToPage(componentId: string, targetPageId: string): void {
|
|
||||||
this.pushHistory('Move component');
|
|
||||||
const component = this.selectedComponent;
|
|
||||||
if (!component) return;
|
|
||||||
const pages = this.pagesSubject.value.map(p => {
|
|
||||||
if (p.id === this.selectedPageId) {
|
|
||||||
return { ...p, components: p.components.filter(c => c.id !== componentId) };
|
|
||||||
}
|
|
||||||
if (p.id === targetPageId) {
|
|
||||||
return { ...p, components: [...p.components, { ...component, pageId: targetPageId, orderIndex: p.components.length }] };
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
});
|
|
||||||
this.pagesSubject.next(pages);
|
|
||||||
this.selectedComponentIdSubject.next(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
pushHistory(label: string): void {
|
|
||||||
const snapshot = this.takeSnapshot();
|
|
||||||
this.undoStack.push({ snapshot, label });
|
|
||||||
if (this.undoStack.length > MAX_HISTORY) this.undoStack.shift();
|
|
||||||
this.redoStack = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
undo(): void {
|
|
||||||
if (this.undoStack.length === 0) return;
|
|
||||||
const current = this.takeSnapshot();
|
|
||||||
this.redoStack.push({ snapshot: current, label: 'Redo' });
|
|
||||||
const entry = this.undoStack.pop()!;
|
|
||||||
this.restoreSnapshot(entry.snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
redo(): void {
|
|
||||||
if (this.redoStack.length === 0) return;
|
|
||||||
const current = this.takeSnapshot();
|
|
||||||
this.undoStack.push({ snapshot: current, label: 'Undo' });
|
|
||||||
const entry = this.redoStack.pop()!;
|
|
||||||
this.restoreSnapshot(entry.snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
canUndo(): boolean { return this.undoStack.length > 0; }
|
|
||||||
canRedo(): boolean { return this.redoStack.length > 0; }
|
|
||||||
|
|
||||||
private takeSnapshot(): Snapshot {
|
|
||||||
return {
|
|
||||||
pages: JSON.parse(JSON.stringify(this.pagesSubject.value)),
|
|
||||||
selectedPageId: this.selectedPageIdSubject.value,
|
|
||||||
selectedComponentId: this.selectedComponentIdSubject.value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private restoreSnapshot(snapshot: Snapshot): void {
|
|
||||||
this.skipHistory = true;
|
|
||||||
this.pagesSubject.next(snapshot.pages);
|
|
||||||
this.selectedPageIdSubject.next(snapshot.selectedPageId);
|
|
||||||
this.selectedComponentIdSubject.next(snapshot.selectedComponentId);
|
|
||||||
this.skipHistory = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(loading: boolean): void { this.loadingSubject.next(loading); }
|
|
||||||
setSaving(saving: boolean): void { this.savingSubject.next(saving); }
|
|
||||||
|
|
||||||
toSaveRequest(pageId: string): { pageId: string; components: { id?: string; type: ComponentType; config: Record<string, unknown>; styles: Record<string, unknown>; orderIndex: number }[] } {
|
|
||||||
const page = this.pages.find(p => p.id === pageId);
|
|
||||||
return {
|
|
||||||
pageId,
|
|
||||||
components: (page?.components ?? []).map(c => ({
|
|
||||||
id: c.id,
|
|
||||||
type: c.type,
|
|
||||||
config: c.config,
|
|
||||||
styles: c.styles,
|
|
||||||
orderIndex: c.orderIndex,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
|
||||||
import { NgFor, NgIf, KeyValuePipe } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MatExpansionModule } from '@angular/material/expansion';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
|
|
||||||
const DEFAULT_THEME: Record<string, any> = {
|
|
||||||
primaryColor: '#1976d2',
|
|
||||||
secondaryColor: '#dc004e',
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
textColor: '#1a1a1a',
|
|
||||||
headingFont: 'Roboto',
|
|
||||||
bodyFont: 'Roboto',
|
|
||||||
borderRadius: '4px',
|
|
||||||
spacing: '16px',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FONTS = ['Roboto', 'Inter', 'Open Sans', 'Lato', 'Montserrat', 'Poppins', 'Playfair Display', 'Source Sans Pro'];
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-theme-editor',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
NgFor, NgIf, KeyValuePipe, FormsModule,
|
|
||||||
MatExpansionModule, MatFormFieldModule, MatInputModule, MatSelectModule,
|
|
||||||
MatButtonModule,
|
|
||||||
],
|
|
||||||
template: `
|
|
||||||
<div class="p-3 h-full overflow-y-auto">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">Theme</h3>
|
|
||||||
|
|
||||||
<mat-accordion>
|
|
||||||
<mat-expansion-panel expanded>
|
|
||||||
<mat-expansion-panel-header>
|
|
||||||
<mat-panel-title class="text-sm font-medium">Colors</mat-panel-title>
|
|
||||||
</mat-expansion-panel-header>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<label class="text-xs text-gray-500 w-24">Primary</label>
|
|
||||||
<div class="flex items-center gap-2 flex-1">
|
|
||||||
<input type="color" [value]="theme['primaryColor'] || ''"
|
|
||||||
(input)="updateKey('primaryColor', $any($event.target).value)"
|
|
||||||
class="w-8 h-8 rounded cursor-pointer border" />
|
|
||||||
<input class="flex-1 border rounded px-2 py-1 text-xs font-mono"
|
|
||||||
[value]="theme['primaryColor'] || ''"
|
|
||||||
(input)="updateKey('primaryColor', $any($event.target).value)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<label class="text-xs text-gray-500 w-24">Secondary</label>
|
|
||||||
<div class="flex items-center gap-2 flex-1">
|
|
||||||
<input type="color" [value]="theme['secondaryColor'] || ''"
|
|
||||||
(input)="updateKey('secondaryColor', $any($event.target).value)"
|
|
||||||
class="w-8 h-8 rounded cursor-pointer border" />
|
|
||||||
<input class="flex-1 border rounded px-2 py-1 text-xs font-mono"
|
|
||||||
[value]="theme['secondaryColor'] || ''"
|
|
||||||
(input)="updateKey('secondaryColor', $any($event.target).value)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<label class="text-xs text-gray-500 w-24">Background</label>
|
|
||||||
<div class="flex items-center gap-2 flex-1">
|
|
||||||
<input type="color" [value]="theme['backgroundColor'] || ''"
|
|
||||||
(input)="updateKey('backgroundColor', $any($event.target).value)"
|
|
||||||
class="w-8 h-8 rounded cursor-pointer border" />
|
|
||||||
<input class="flex-1 border rounded px-2 py-1 text-xs font-mono"
|
|
||||||
[value]="theme['backgroundColor'] || ''"
|
|
||||||
(input)="updateKey('backgroundColor', $any($event.target).value)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<label class="text-xs text-gray-500 w-24">Text</label>
|
|
||||||
<div class="flex items-center gap-2 flex-1">
|
|
||||||
<input type="color" [value]="theme['textColor'] || ''"
|
|
||||||
(input)="updateKey('textColor', $any($event.target).value)"
|
|
||||||
class="w-8 h-8 rounded cursor-pointer border" />
|
|
||||||
<input class="flex-1 border rounded px-2 py-1 text-xs font-mono"
|
|
||||||
[value]="theme['textColor'] || ''"
|
|
||||||
(input)="updateKey('textColor', $any($event.target).value)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mat-expansion-panel>
|
|
||||||
|
|
||||||
<mat-expansion-panel>
|
|
||||||
<mat-expansion-panel-header>
|
|
||||||
<mat-panel-title class="text-sm font-medium">Typography</mat-panel-title>
|
|
||||||
</mat-expansion-panel-header>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Heading Font</mat-label>
|
|
||||||
<mat-select [value]="theme['headingFont']" (selectionChange)="updateKey('headingFont', $event.value)">
|
|
||||||
<mat-option *ngFor="let font of fonts" [value]="font">{{ font }}</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Body Font</mat-label>
|
|
||||||
<mat-select [value]="theme['bodyFont']" (selectionChange)="updateKey('bodyFont', $event.value)">
|
|
||||||
<mat-option *ngFor="let font of fonts" [value]="font">{{ font }}</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
</mat-expansion-panel>
|
|
||||||
|
|
||||||
<mat-expansion-panel>
|
|
||||||
<mat-expansion-panel-header>
|
|
||||||
<mat-panel-title class="text-sm font-medium">Layout</mat-panel-title>
|
|
||||||
</mat-expansion-panel-header>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Border Radius</mat-label>
|
|
||||||
<input matInput [value]="theme['borderRadius']" (input)="updateKey('borderRadius', $any($event.target).value)" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Spacing</mat-label>
|
|
||||||
<input matInput [value]="theme['spacing']" (input)="updateKey('spacing', $any($event.target).value)" />
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
</mat-expansion-panel>
|
|
||||||
</mat-accordion>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class ThemeEditorComponent {
|
|
||||||
@Input() theme: Record<string, unknown> = { ...DEFAULT_THEME };
|
|
||||||
@Output() themeChange = new EventEmitter<Record<string, unknown>>();
|
|
||||||
fonts = FONTS;
|
|
||||||
|
|
||||||
updateKey(key: string, value: string): void {
|
|
||||||
this.theme = { ...this.theme, [key]: value };
|
|
||||||
this.themeChange.emit(this.theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,37 +11,6 @@ export interface ComponentItem {
|
|||||||
orderIndex: number;
|
orderIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentResponse {
|
|
||||||
id: string;
|
|
||||||
pageId: string;
|
|
||||||
type: ComponentType;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
styles: Record<string, unknown>;
|
|
||||||
orderIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PageWithComponentsResponse {
|
|
||||||
id: string;
|
|
||||||
siteId: string;
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
seoTitle: string;
|
|
||||||
seoDescription: string;
|
|
||||||
orderIndex: number;
|
|
||||||
createdAt: string;
|
|
||||||
components: ComponentResponse[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SaveComponentsRequest {
|
|
||||||
components: {
|
|
||||||
id?: string;
|
|
||||||
type: ComponentType;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
styles: Record<string, unknown>;
|
|
||||||
orderIndex: number;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultConfig(type: ComponentType): Record<string, unknown> {
|
export function getDefaultConfig(type: ComponentType): Record<string, unknown> {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'HEADING': return { text: 'Heading', level: 'h2' };
|
case 'HEADING': return { text: 'Heading', level: 'h2' };
|
||||||
@@ -72,8 +41,7 @@ export function getDefaultStyles(type: ComponentType): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getComponentLabel(type: ComponentType): string {
|
const LABELS: Record<string, string> = {
|
||||||
const labels: Record<ComponentType, string> = {
|
|
||||||
HEADING: 'Heading',
|
HEADING: 'Heading',
|
||||||
TEXT: 'Text',
|
TEXT: 'Text',
|
||||||
IMAGE: 'Image',
|
IMAGE: 'Image',
|
||||||
@@ -85,11 +53,8 @@ export function getComponentLabel(type: ComponentType): string {
|
|||||||
MAP: 'Map',
|
MAP: 'Map',
|
||||||
CUSTOM_HTML: 'Custom HTML',
|
CUSTOM_HTML: 'Custom HTML',
|
||||||
};
|
};
|
||||||
return labels[type];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getComponentIcon(type: ComponentType): string {
|
const ICONS: Record<string, string> = {
|
||||||
const icons: Record<ComponentType, string> = {
|
|
||||||
HEADING: 'title',
|
HEADING: 'title',
|
||||||
TEXT: 'text_fields',
|
TEXT: 'text_fields',
|
||||||
IMAGE: 'image',
|
IMAGE: 'image',
|
||||||
@@ -101,5 +66,11 @@ export function getComponentIcon(type: ComponentType): string {
|
|||||||
MAP: 'map',
|
MAP: 'map',
|
||||||
CUSTOM_HTML: 'code',
|
CUSTOM_HTML: 'code',
|
||||||
};
|
};
|
||||||
return icons[type];
|
|
||||||
|
export function getComponentLabel(type: string): string {
|
||||||
|
return LABELS[type] || LABELS[type.toUpperCase()] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComponentIcon(type: string): string {
|
||||||
|
return ICONS[type] || ICONS[type.toUpperCase()] || 'code';
|
||||||
}
|
}
|
||||||
|
|||||||
76
frontend/src/app/core/models/llm.ts
Normal file
76
frontend/src/app/core/models/llm.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { ComponentType } from './component';
|
||||||
|
|
||||||
|
export interface LLMGenerateRequest {
|
||||||
|
prompt: string;
|
||||||
|
siteId: string;
|
||||||
|
references?: Reference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMRefineRequest {
|
||||||
|
prompt: string;
|
||||||
|
siteId: string;
|
||||||
|
currentStructure: SiteStructure;
|
||||||
|
references?: Reference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Reference {
|
||||||
|
type: 'URL' | 'IMAGE';
|
||||||
|
url: string;
|
||||||
|
altText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteStructure {
|
||||||
|
theme: ThemeConfig;
|
||||||
|
pages: PageStructure[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeConfig {
|
||||||
|
colors: Record<string, string>;
|
||||||
|
fonts: Record<string, string>;
|
||||||
|
spacing: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageStructure {
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
seoTitle?: string;
|
||||||
|
seoDescription?: string;
|
||||||
|
orderIndex: number;
|
||||||
|
components: ComponentStructure[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentStructure {
|
||||||
|
id?: string;
|
||||||
|
type: ComponentType;
|
||||||
|
orderIndex: number;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
styles: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUsage {
|
||||||
|
prompt: number;
|
||||||
|
completion: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMSession {
|
||||||
|
id?: string;
|
||||||
|
siteId: string;
|
||||||
|
prompt: string;
|
||||||
|
generatedStructure: SiteStructure | null;
|
||||||
|
refinedPrompt: string | null;
|
||||||
|
acceptedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationVersion {
|
||||||
|
id: string;
|
||||||
|
siteId: string;
|
||||||
|
userId: string;
|
||||||
|
versionNumber: number;
|
||||||
|
prompt: string;
|
||||||
|
references: Reference[];
|
||||||
|
fullStructure: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
29
frontend/src/app/core/services/llm-api.service.ts
Normal file
29
frontend/src/app/core/services/llm-api.service.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { LLMGenerateRequest, LLMRefineRequest, SiteStructure, GenerationVersion } from '../models/llm';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class LlmApiService {
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
generate(request: LLMGenerateRequest): Observable<SiteStructure> {
|
||||||
|
return this.http.post<SiteStructure>('/api/llm/generate', request);
|
||||||
|
}
|
||||||
|
|
||||||
|
refine(request: LLMRefineRequest): Observable<SiteStructure> {
|
||||||
|
return this.http.post<SiteStructure>('/api/llm/refine', request);
|
||||||
|
}
|
||||||
|
|
||||||
|
getVersions(siteId: string): Observable<GenerationVersion[]> {
|
||||||
|
return this.http.get<GenerationVersion[]>(`/api/llm/versions/${siteId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreVersion(versionId: string): Observable<SiteStructure> {
|
||||||
|
return this.http.post<SiteStructure>(`/api/llm/versions/${versionId}/restore`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteVersion(versionId: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`/api/llm/versions/${versionId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,7 +131,7 @@ export class DashboardComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openBuilder(site: SiteResponse): void {
|
openBuilder(site: SiteResponse): void {
|
||||||
this.router.navigate(['/builder', site.id]);
|
this.router.navigate(['/llm', site.id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-delete-version-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [MatDialogModule, MatButtonModule],
|
||||||
|
template: `
|
||||||
|
<h2 mat-dialog-title>Delete Version</h2>
|
||||||
|
<mat-dialog-content>
|
||||||
|
<p>Are you sure you want to delete version <strong>v{{ data.versionNumber }}</strong>?</p>
|
||||||
|
<p class="text-red-600 text-sm mt-2">This action cannot be undone.</p>
|
||||||
|
</mat-dialog-content>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button mat-dialog-close>Cancel</button>
|
||||||
|
<button mat-flat-button color="warn" [mat-dialog-close]="true">Delete</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class DeleteVersionDialog {
|
||||||
|
dialogRef = inject(MatDialogRef<DeleteVersionDialog>);
|
||||||
|
data = inject<{ versionNumber: number }>(MAT_DIALOG_DATA);
|
||||||
|
}
|
||||||
102
frontend/src/app/llm-workspace/llm-session.service.ts
Normal file
102
frontend/src/app/llm-workspace/llm-session.service.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
import { SiteStructure, GenerationVersion } from '../core/models/llm';
|
||||||
|
|
||||||
|
export interface PromptEntry {
|
||||||
|
originalPrompt: string;
|
||||||
|
refinedPrompt: string | null;
|
||||||
|
result: SiteStructure | null;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class LlmSessionService {
|
||||||
|
private currentPromptSubject = new BehaviorSubject<string>('');
|
||||||
|
private currentResultSubject = new BehaviorSubject<SiteStructure | null>(null);
|
||||||
|
private historySubject = new BehaviorSubject<PromptEntry[]>([]);
|
||||||
|
private generatingSubject = new BehaviorSubject<boolean>(false);
|
||||||
|
private errorSubject = new BehaviorSubject<string | null>(null);
|
||||||
|
private versionsSubject = new BehaviorSubject<GenerationVersion[]>([]);
|
||||||
|
private currentVersionIdSubject = new BehaviorSubject<string | null>(null);
|
||||||
|
private loadingVersionsSubject = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
|
currentPrompt$ = this.currentPromptSubject.asObservable();
|
||||||
|
currentResult$ = this.currentResultSubject.asObservable();
|
||||||
|
history$ = this.historySubject.asObservable();
|
||||||
|
generating$ = this.generatingSubject.asObservable();
|
||||||
|
error$ = this.errorSubject.asObservable();
|
||||||
|
versions$ = this.versionsSubject.asObservable();
|
||||||
|
currentVersionId$ = this.currentVersionIdSubject.asObservable();
|
||||||
|
loadingVersions$ = this.loadingVersionsSubject.asObservable();
|
||||||
|
|
||||||
|
get currentPrompt(): string { return this.currentPromptSubject.value; }
|
||||||
|
get currentResult(): SiteStructure | null { return this.currentResultSubject.value; }
|
||||||
|
get generating(): boolean { return this.generatingSubject.value; }
|
||||||
|
get error(): string | null { return this.errorSubject.value; }
|
||||||
|
get versions(): GenerationVersion[] { return this.versionsSubject.value; }
|
||||||
|
get currentVersionId(): string | null { return this.currentVersionIdSubject.value; }
|
||||||
|
|
||||||
|
setPrompt(prompt: string): void {
|
||||||
|
this.currentPromptSubject.next(prompt);
|
||||||
|
this.errorSubject.next(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult(result: SiteStructure): void {
|
||||||
|
this.currentResultSubject.next(result);
|
||||||
|
const entry: PromptEntry = {
|
||||||
|
originalPrompt: this.currentPromptSubject.value,
|
||||||
|
refinedPrompt: null,
|
||||||
|
result,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
const history = this.historySubject.value;
|
||||||
|
this.historySubject.next([...history, entry]);
|
||||||
|
this.generatingSubject.next(false);
|
||||||
|
this.errorSubject.next(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearResult(): void {
|
||||||
|
this.currentResultSubject.next(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setGenerating(generating: boolean): void {
|
||||||
|
this.generatingSubject.next(generating);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(error: string): void {
|
||||||
|
this.errorSubject.next(error);
|
||||||
|
this.generatingSubject.next(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError(): void {
|
||||||
|
this.errorSubject.next(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHistory(): void {
|
||||||
|
this.historySubject.next([]);
|
||||||
|
this.currentResultSubject.next(null);
|
||||||
|
this.currentPromptSubject.next('');
|
||||||
|
this.currentVersionIdSubject.next(null);
|
||||||
|
this.errorSubject.next(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setVersions(versions: GenerationVersion[]): void {
|
||||||
|
this.versionsSubject.next(versions);
|
||||||
|
this.loadingVersionsSubject.next(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingVersions(loading: boolean): void {
|
||||||
|
this.loadingVersionsSubject.next(loading);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentVersionId(id: string | null): void {
|
||||||
|
this.currentVersionIdSubject.next(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadVersionData(structure: SiteStructure, versionId: string): void {
|
||||||
|
this.currentResultSubject.next(structure);
|
||||||
|
this.currentVersionIdSubject.next(versionId);
|
||||||
|
this.generatingSubject.next(false);
|
||||||
|
this.errorSubject.next(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
459
frontend/src/app/llm-workspace/llm-workspace.component.ts
Normal file
459
frontend/src/app/llm-workspace/llm-workspace.component.ts
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||||
|
import { NgIf, NgFor, AsyncPipe, KeyValuePipe, DatePipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
|
import { MatExpansionModule } from '@angular/material/expansion';
|
||||||
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
import { LlmApiService } from '../core/services/llm-api.service';
|
||||||
|
import { LlmSessionService } from './llm-session.service';
|
||||||
|
import { SiteStructure, GenerationVersion } from '../core/models/llm';
|
||||||
|
import { getComponentLabel } from '../core/models/component';
|
||||||
|
import { PreviewDialogComponent } from './preview-dialog/preview-dialog.component';
|
||||||
|
import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dialog';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-llm-workspace',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
NgIf, NgFor, AsyncPipe, KeyValuePipe, DatePipe, FormsModule, RouterModule,
|
||||||
|
MatToolbarModule, MatButtonModule, MatIconModule,
|
||||||
|
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule,
|
||||||
|
MatSnackBarModule, MatCardModule,
|
||||||
|
MatDividerModule, MatExpansionModule, MatDialogModule,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="h-screen flex flex-col bg-gray-50">
|
||||||
|
<mat-toolbar color="primary" class="h-14 flex items-center justify-between px-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button mat-icon-button (click)="goToDashboard()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
</button>
|
||||||
|
<span class="text-lg font-bold">AI Site Generator</span>
|
||||||
|
</div>
|
||||||
|
</mat-toolbar>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<div class="px-6 py-6 space-y-6">
|
||||||
|
|
||||||
|
<mat-card class="p-4">
|
||||||
|
<mat-card-content class="!p-0">
|
||||||
|
<mat-form-field appearance="outline" class="w-full">
|
||||||
|
<mat-label>Describe your website</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
rows="5"
|
||||||
|
placeholder="e.g. A personal portfolio for a photographer with a gallery page, about section, and contact form. Dark theme with accent gold."
|
||||||
|
[(ngModel)]="prompt"
|
||||||
|
[disabled]="(session.generating$ | async) === true"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="flex items-center justify-between mt-2">
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
Describe the pages, layout, theme, and content you want.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
[disabled]="!prompt.trim() || (session.generating$ | async)"
|
||||||
|
(click)="generate()"
|
||||||
|
>
|
||||||
|
<mat-icon>auto_awesome</mat-icon>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<div *ngIf="session.generating$ | async" class="flex flex-col items-center justify-center py-12 space-y-4">
|
||||||
|
<mat-spinner diameter="48"></mat-spinner>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-lg font-medium text-gray-700">Generating your site...</p>
|
||||||
|
<p class="text-sm text-gray-400">This usually takes 15-30 seconds</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="session.error$ | async as error" class="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<mat-icon class="text-red-500 mt-0.5">error_outline</mat-icon>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-red-800">Generation failed</p>
|
||||||
|
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
|
||||||
|
<button mat-button color="warn" class="mt-2" (click)="retry()">Try Again</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="session.currentResult$ | async as result" class="flex gap-6">
|
||||||
|
<div class="w-96 shrink-0">
|
||||||
|
<mat-card class="p-4" appearance="outlined">
|
||||||
|
<mat-card-content class="!p-0">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">History</h4>
|
||||||
|
<span class="text-xs text-gray-400">{{ versions.length }} version{{ versions.length !== 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="session.loadingVersions$ | async" class="flex justify-center py-4">
|
||||||
|
<mat-spinner diameter="20"></mat-spinner>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 max-h-[calc(100vh-20rem)] overflow-y-auto overflow-x-hidden">
|
||||||
|
<div
|
||||||
|
*ngFor="let v of versions"
|
||||||
|
(click)="switchToVersion(v)"
|
||||||
|
class="w-full p-3 rounded-lg border transition-colors duration-150 flex flex-col gap-0.5 cursor-pointer group min-w-0"
|
||||||
|
[class.border-blue-500]="isCurrentVersion(v.id)"
|
||||||
|
[class.bg-blue-50]="isCurrentVersion(v.id)"
|
||||||
|
[class.border-gray-200]="!isCurrentVersion(v.id)"
|
||||||
|
[class.hover:bg-gray-50]="!isCurrentVersion(v.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span class="text-xs font-bold text-gray-900 shrink-0">v{{ v.versionNumber }}</span>
|
||||||
|
<span class="text-[10px] text-gray-400 shrink-0">{{ v.createdAt | date:'short' }}</span>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
class="!w-6 !h-6 !min-w-0 !p-0 !leading-none opacity-40 hover:opacity-100 transition-opacity shrink-0"
|
||||||
|
(click)="$event.stopPropagation(); deleteVersion(v)"
|
||||||
|
title="Delete version"
|
||||||
|
>
|
||||||
|
<mat-icon class="!text-sm !w-4 !h-4 text-red-500">delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 truncate">{{ truncatePrompt(v.prompt, 50) }}</p>
|
||||||
|
</div>
|
||||||
|
<p *ngIf="versions.length === 0 && !(session.loadingVersions$ | async)" class="text-xs text-gray-400 py-2 text-center">
|
||||||
|
No versions yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0 space-y-4">
|
||||||
|
<mat-card class="p-4" appearance="outlined">
|
||||||
|
<mat-card-content class="!p-0">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Generated Site Structure</h3>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{{ result.pages.length }} page{{ result.pages.length !== 1 ? 's' : '' }}
|
||||||
|
·
|
||||||
|
{{ totalComponents(result) }} component{{ totalComponents(result) !== 1 ? 's' : '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button mat-stroked-button color="primary" (click)="openPreview()">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button color="primary" (click)="showRefine = !showRefine">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Refine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card *ngIf="currentVersionPrompt" class="p-4" appearance="outlined">
|
||||||
|
<mat-card-content class="!p-0">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">Prompt</h4>
|
||||||
|
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{ currentVersionPrompt }}</p>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card *ngIf="showRefine" class="p-4" appearance="outlined">
|
||||||
|
<mat-card-content class="!p-0">
|
||||||
|
<mat-form-field appearance="outline" class="w-full">
|
||||||
|
<mat-label>Refinement instructions</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
rows="3"
|
||||||
|
placeholder="e.g. Make it more minimal, change the primary color to teal, add a testimonials section..."
|
||||||
|
[(ngModel)]="refinePrompt"
|
||||||
|
[disabled]="(session.generating$ | async) === true"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="flex justify-end mt-2">
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="accent"
|
||||||
|
[disabled]="!refinePrompt.trim() || (session.generating$ | async)"
|
||||||
|
(click)="refine()"
|
||||||
|
>
|
||||||
|
<mat-icon>auto_fix_high</mat-icon>
|
||||||
|
Refine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="p-4" appearance="outlined">
|
||||||
|
<mat-card-content class="!p-0">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Theme</h4>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<div *ngIf="result.theme.colors" class="space-y-1">
|
||||||
|
<span class="text-xs text-gray-500 block mb-1">Colors</span>
|
||||||
|
<div *ngFor="let item of result.theme.colors | keyvalue" class="flex items-center gap-2">
|
||||||
|
<span class="w-5 h-5 rounded-full border" [style.background]="item.value"></span>
|
||||||
|
<span class="text-xs text-gray-600">{{ item.key }}:</span>
|
||||||
|
<span class="text-xs font-mono text-gray-500">{{ item.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="result.theme.fonts" class="space-y-1">
|
||||||
|
<span class="text-xs text-gray-500 block mb-1">Fonts</span>
|
||||||
|
<div *ngFor="let item of result.theme.fonts | keyvalue" class="flex items-center gap-2 text-xs text-gray-600">
|
||||||
|
<span>{{ item.key }}:</span>
|
||||||
|
<span class="font-mono text-gray-500">{{ item.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="result.theme.spacing" class="space-y-1">
|
||||||
|
<span class="text-xs text-gray-500 block mb-1">Spacing</span>
|
||||||
|
<div *ngFor="let item of result.theme.spacing | keyvalue" class="flex items-center gap-2 text-xs text-gray-600">
|
||||||
|
<span>{{ item.key }}:</span>
|
||||||
|
<span class="font-mono text-gray-500">{{ item.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-accordion [multi]="true">
|
||||||
|
<mat-expansion-panel *ngFor="let page of result.pages" class="mb-2">
|
||||||
|
<mat-expansion-panel-header>
|
||||||
|
<mat-panel-title class="text-sm font-medium">
|
||||||
|
{{ page.title }}
|
||||||
|
</mat-panel-title>
|
||||||
|
<mat-panel-description class="text-xs text-gray-400">
|
||||||
|
/{{ page.slug }}
|
||||||
|
·
|
||||||
|
{{ page.components.length }} component{{ page.components.length !== 1 ? 's' : '' }}
|
||||||
|
</mat-panel-description>
|
||||||
|
</mat-expansion-panel-header>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div *ngFor="let comp of page.components" class="flex items-center gap-2 py-1">
|
||||||
|
<mat-icon class="text-gray-400 text-lg">circle</mat-icon>
|
||||||
|
<span class="text-sm text-gray-700">{{ getLabel(comp.type) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-expansion-panel>
|
||||||
|
</mat-accordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="!(session.generating$ | async) && !(session.currentResult$ | async) && !(session.error$ | async)" class="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||||
|
<mat-icon class="text-6xl mb-4" style="width: auto; height: auto; font-size: 4rem;">auto_awesome</mat-icon>
|
||||||
|
<h3 class="text-xl font-medium text-gray-500 mb-2">Describe your dream site</h3>
|
||||||
|
<p class="text-sm text-gray-400 text-center max-w-md">
|
||||||
|
Tell the AI what kind of website you want — pages, style, content — and it will generate a complete site structure ready for you to customize.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private api = inject(LlmApiService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
session = inject(LlmSessionService);
|
||||||
|
|
||||||
|
prompt = '';
|
||||||
|
refinePrompt = '';
|
||||||
|
showRefine = false;
|
||||||
|
siteId: string | null = null;
|
||||||
|
versions: GenerationVersion[] = [];
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.siteId = this.route.snapshot.paramMap.get('siteId');
|
||||||
|
const existingPrompt = this.session.currentPrompt;
|
||||||
|
if (existingPrompt) {
|
||||||
|
this.prompt = existingPrompt;
|
||||||
|
}
|
||||||
|
this.loadVersions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadVersions(): void {
|
||||||
|
if (!this.siteId) return;
|
||||||
|
this.session.setLoadingVersions(true);
|
||||||
|
this.api.getVersions(this.siteId)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: (v) => {
|
||||||
|
this.versions = v;
|
||||||
|
this.session.setVersions(v);
|
||||||
|
if (v.length > 0 && !this.session.currentResult) {
|
||||||
|
this.api.restoreVersion(v[0].id)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: (structure) => {
|
||||||
|
this.session.loadVersionData(structure, v[0].id);
|
||||||
|
},
|
||||||
|
error: () => this.session.setLoadingVersions(false),
|
||||||
|
});
|
||||||
|
} else if (v.length > 0 && !this.session.currentVersionId) {
|
||||||
|
this.session.setCurrentVersionId(v[0].id);
|
||||||
|
} else {
|
||||||
|
this.session.setLoadingVersions(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => this.session.setLoadingVersions(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
generate(): void {
|
||||||
|
if (!this.prompt.trim() || !this.siteId) return;
|
||||||
|
this.showRefine = false;
|
||||||
|
this.session.setPrompt(this.prompt);
|
||||||
|
this.session.setGenerating(true);
|
||||||
|
this.session.clearError();
|
||||||
|
|
||||||
|
this.api.generate({ prompt: this.prompt, siteId: this.siteId })
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.session.setResult(data);
|
||||||
|
this.session.setCurrentVersionId(null);
|
||||||
|
this.loadVersions();
|
||||||
|
this.snackBar.open('Site generated successfully!', 'Close', { duration: 3000 });
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
|
||||||
|
this.session.setError(msg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refine(): void {
|
||||||
|
const result = this.session.currentResult;
|
||||||
|
if (!result || !this.refinePrompt.trim() || !this.siteId) return;
|
||||||
|
this.session.setGenerating(true);
|
||||||
|
this.session.clearError();
|
||||||
|
|
||||||
|
this.api.refine({
|
||||||
|
prompt: this.refinePrompt,
|
||||||
|
siteId: this.siteId,
|
||||||
|
currentStructure: result,
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.session.setResult(data);
|
||||||
|
this.session.setCurrentVersionId(null);
|
||||||
|
this.loadVersions();
|
||||||
|
this.refinePrompt = '';
|
||||||
|
this.showRefine = false;
|
||||||
|
this.snackBar.open('Site refined successfully!', 'Close', { duration: 3000 });
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
|
||||||
|
this.session.setError(msg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteVersion(version: GenerationVersion): void {
|
||||||
|
if (this.versions.length <= 1) {
|
||||||
|
this.snackBar.open('Cannot delete the last version', 'Close', { duration: 3000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ref = this.dialog.open(DeleteVersionDialog, {
|
||||||
|
width: '400px',
|
||||||
|
data: { versionNumber: version.versionNumber },
|
||||||
|
});
|
||||||
|
ref.afterClosed().subscribe((confirmed) => {
|
||||||
|
if (!confirmed) return;
|
||||||
|
this.api.deleteVersion(version.id)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
if (this.session.currentVersionId === version.id) {
|
||||||
|
this.session.setCurrentVersionId(null);
|
||||||
|
}
|
||||||
|
this.loadVersions();
|
||||||
|
this.snackBar.open('Version deleted', 'Close', { duration: 2000 });
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.snackBar.open('Failed to delete version', 'Close', { duration: 3000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switchToVersion(version: GenerationVersion): void {
|
||||||
|
if (version.id === this.session.currentVersionId) return;
|
||||||
|
this.session.setGenerating(true);
|
||||||
|
this.api.restoreVersion(version.id)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: (structure) => {
|
||||||
|
this.session.loadVersionData(structure, version.id);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
const msg = err.error?.error || err.statusText || 'Failed to restore version';
|
||||||
|
this.session.setError(msg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openPreview(): void {
|
||||||
|
const result = this.session.currentResult;
|
||||||
|
if (!result) return;
|
||||||
|
this.dialog.open(PreviewDialogComponent, {
|
||||||
|
data: result,
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
maxWidth: '100vw',
|
||||||
|
maxHeight: '100vh',
|
||||||
|
panelClass: 'fullscreen-dialog',
|
||||||
|
autoFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
retry(): void {
|
||||||
|
this.session.clearError();
|
||||||
|
this.generate();
|
||||||
|
}
|
||||||
|
|
||||||
|
goToDashboard(): void {
|
||||||
|
this.router.navigate(['/dashboard']);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalComponents(structure: SiteStructure): number {
|
||||||
|
return structure.pages.reduce((sum, p) => sum + p.components.length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLabel(type: string): string {
|
||||||
|
return getComponentLabel(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
isCurrentVersion(versionId: string): boolean {
|
||||||
|
return this.session.currentVersionId === versionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentVersionPrompt(): string | null {
|
||||||
|
const version = this.versions.find(v => v.id === this.session.currentVersionId);
|
||||||
|
return version ? version.prompt : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
truncatePrompt(prompt: string, maxLen: number = 60): string {
|
||||||
|
return prompt.length > maxLen ? prompt.slice(0, maxLen) + '...' : prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { Component, Inject } from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
|
import { NgIf, NgFor, AsyncPipe } from '@angular/common';
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
import { SiteStructure, PageStructure, ComponentStructure } from '../../core/models/llm';
|
||||||
|
|
||||||
|
type Device = 'desktop' | 'tablet' | 'mobile';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-preview-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
MatDialogModule, MatButtonModule, MatIconModule, MatToolbarModule,
|
||||||
|
NgIf, NgFor, AsyncPipe,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="h-screen flex flex-col bg-gray-900">
|
||||||
|
<mat-toolbar class="h-12 flex items-center justify-between px-4 bg-gray-800 text-white">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button mat-icon-button class="text-white" (click)="close()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
<span class="text-sm font-medium">Preview</span>
|
||||||
|
<span class="text-xs text-gray-400 ml-1">{{ data.pages.length }} pages</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button mat-icon-button class="text-white" [class.text-blue-400]="device === 'desktop'" (click)="device = 'desktop'">
|
||||||
|
<mat-icon>desktop_windows</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button class="text-white" [class.text-blue-400]="device === 'tablet'" (click)="device = 'tablet'">
|
||||||
|
<mat-icon>tablet_mac</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button class="text-white" [class.text-blue-400]="device === 'mobile'" (click)="device = 'mobile'">
|
||||||
|
<mat-icon>phone_android</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="w-px h-5 bg-gray-600 mx-1"></span>
|
||||||
|
|
||||||
|
<button *ngFor="let page of data.pages; let i = index" mat-stroked-button class="!text-white !border-gray-500 !text-xs"
|
||||||
|
[class.!bg-blue-600]="selectedPage === i"
|
||||||
|
(click)="selectPage(i)">
|
||||||
|
{{ page.title }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-toolbar>
|
||||||
|
|
||||||
|
<div class="flex-1 flex items-start justify-center p-4 overflow-auto bg-gray-900">
|
||||||
|
<div class="bg-white transition-all duration-300"
|
||||||
|
[style.width]="device === 'desktop' ? '100%' : device === 'tablet' ? '768px' : '375px'"
|
||||||
|
[style.maxWidth]="device === 'desktop' ? '1280px' : ''"
|
||||||
|
[style.minHeight]="'calc(100vh - 6rem)'">
|
||||||
|
<iframe [srcdoc]="sanitizedHtml" class="w-full h-full border-0" style="min-height: calc(100vh - 6rem);"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class PreviewDialogComponent {
|
||||||
|
device: Device = 'desktop';
|
||||||
|
selectedPage = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: SiteStructure,
|
||||||
|
private dialogRef: MatDialogRef<PreviewDialogComponent>,
|
||||||
|
private sanitizer: DomSanitizer,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get sanitizedHtml(): SafeHtml {
|
||||||
|
return this.sanitizer.bypassSecurityTrustHtml(this.buildHtml());
|
||||||
|
}
|
||||||
|
|
||||||
|
selectPage(index: number): void {
|
||||||
|
this.selectedPage = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildHtml(): string {
|
||||||
|
const page = this.data.pages[this.selectedPage];
|
||||||
|
const theme = this.data.theme;
|
||||||
|
|
||||||
|
const colors = theme.colors || {};
|
||||||
|
const fonts = theme.fonts || {};
|
||||||
|
const spacing = theme.spacing || {};
|
||||||
|
|
||||||
|
const cssVars = Object.entries(colors).map(([k, v]) => ` --color-${k}: ${v};`).join('\n')
|
||||||
|
+ Object.entries(fonts).map(([k, v]) => ` --font-${k}: ${v};`).join('\n')
|
||||||
|
+ Object.entries(spacing).map(([k, v]) => ` --spacing-${k}: ${v};`).join('\n');
|
||||||
|
|
||||||
|
const sections: { bg: string; indices: number[] }[] = [];
|
||||||
|
for (let i = 0; i < page.components.length; i++) {
|
||||||
|
const c = page.components[i];
|
||||||
|
const bg = String((c.styles && c.styles['backgroundColor']) || '');
|
||||||
|
if (bg || i === 0) {
|
||||||
|
sections.push({ bg, indices: [i] });
|
||||||
|
} else {
|
||||||
|
sections[sections.length - 1].indices.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lastIndices = new Set(sections.map(s => s.indices[s.indices.length - 1]));
|
||||||
|
const sectionsHtml = sections.map(s => {
|
||||||
|
const bgStyle = s.bg ? ` style="background:${s.bg}"` : '';
|
||||||
|
const inner = s.indices.map(idx => this.renderComponent(page.components[idx], idx)).join('\n');
|
||||||
|
return `<section${bgStyle}>\n <div class="container">\n${inner}\n </div>\n</section>`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${page.title}</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@400;500;600;700&family=Merriweather:wght@300;400;700&family=Lato:wght@300;400;700&family=Open+Sans:wght@300;400;600;700&family=DM+Sans:wght@300;400;500;600;700&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
${cssVars}
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body, 'Inter', sans-serif);
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
background: var(--color-background, #ffffff);
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.container { max-width: var(--spacing-containerWidth, 1100px); margin: 0 auto; padding: 0 1.5rem; }
|
||||||
|
section { padding: var(--spacing-sectionPadding, 3rem) 0; }
|
||||||
|
${this.buildPageStyles(page.components, lastIndices)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${sectionsHtml}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPageStyles(components: ComponentStructure[], lastIndices: Set<number>): string {
|
||||||
|
return components.map((c, i) => {
|
||||||
|
const s = c.styles || {};
|
||||||
|
let entries = Object.entries(s).filter(([k]) => k !== 'backgroundColor');
|
||||||
|
if (lastIndices.has(i)) {
|
||||||
|
entries = entries.filter(([k]) => k !== 'marginBottom');
|
||||||
|
}
|
||||||
|
if (entries.length === 0) return '';
|
||||||
|
return `.comp-${i} {\n${entries.map(([k, v]) => ` ${this.cssProperty(k)}: ${v};`).join('\n')}\n}`;
|
||||||
|
}).filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderComponent(c: ComponentStructure, i?: number): string {
|
||||||
|
const idx = i ?? 0;
|
||||||
|
const config = c.config || {};
|
||||||
|
const type = (c.type || '').toUpperCase();
|
||||||
|
const cls = `comp-${idx}`;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'HEADING': {
|
||||||
|
const level = (config['level'] as string) || 'h2';
|
||||||
|
const text = (config['text'] as string) || 'Heading';
|
||||||
|
const tag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(level) ? level : 'h2';
|
||||||
|
return `<${tag} class="${cls}">${this.esc(text)}</${tag}>`;
|
||||||
|
}
|
||||||
|
case 'TEXT': {
|
||||||
|
const content = (config['content'] as string) || '';
|
||||||
|
return `<div class="${cls}">${content}</div>`;
|
||||||
|
}
|
||||||
|
case 'IMAGE': {
|
||||||
|
const src = (config['src'] as string) || '';
|
||||||
|
const alt = (config['alt'] as string) || '';
|
||||||
|
const caption = (config['caption'] as string) || '';
|
||||||
|
const linkTo = (config['linkTo'] as string) || '';
|
||||||
|
const img = `<img src="${this.esc(src)}" alt="${this.esc(alt)}" class="${cls}" style="max-width:100%;height:auto">`;
|
||||||
|
const imgHtml = linkTo ? `<a href="${this.esc(linkTo)}">${img}</a>` : img;
|
||||||
|
return caption ? `<figure style="margin:0">${imgHtml}<figcaption style="text-align:center;font-size:0.875rem;color:#6b7280;margin-top:0.5rem">${this.esc(caption)}</figcaption></figure>` : imgHtml;
|
||||||
|
}
|
||||||
|
case 'BUTTON': {
|
||||||
|
const text = (config['text'] as string) || 'Button';
|
||||||
|
const link = (config['link'] as string) || '#';
|
||||||
|
const variant = (config['variant'] as string) || 'primary';
|
||||||
|
let btnStyle = 'display:inline-block;text-decoration:none;font-weight:500;font-size:1rem;padding:0.75rem 2rem;border-radius:8px;transition:all 0.2s;cursor:pointer;border:2px solid transparent';
|
||||||
|
if (variant === 'outline') {
|
||||||
|
btnStyle += `;background:transparent;color:var(--color-primary,#4f46e5);border-color:var(--color-primary,#4f46e5)`;
|
||||||
|
} else if (variant === 'secondary') {
|
||||||
|
btnStyle += `;background:var(--color-secondary,#6366f1);color:#ffffff`;
|
||||||
|
} else {
|
||||||
|
btnStyle += `;background:var(--color-primary,#4f46e5);color:#ffffff`;
|
||||||
|
}
|
||||||
|
return `<a href="${this.esc(link)}" class="${cls}" style="${btnStyle}">${this.esc(text)}</a>`;
|
||||||
|
}
|
||||||
|
case 'DIVIDER': {
|
||||||
|
const style = (config['style'] as string) || 'solid';
|
||||||
|
const color = (config['color'] as string) || 'var(--color-text, #d1d5db)';
|
||||||
|
return `<hr class="${cls}" style="border:none;border-top:1px ${style} ${color}">`;
|
||||||
|
}
|
||||||
|
case 'GALLERY': {
|
||||||
|
const images = (config['images'] as string[]) || [];
|
||||||
|
const columns = Math.min(Math.max((config['columns'] as number) || 3, 1), 6);
|
||||||
|
if (images.length === 0) return `<div class="${cls}">Gallery (no images)</div>`;
|
||||||
|
return `<div class="${cls}" style="display:grid;grid-template-columns:repeat(${columns},1fr);gap:1rem">
|
||||||
|
${images.map(src => `<img src="${this.esc(src)}" style="width:100%;height:250px;object-fit:cover;border-radius:8px">`).join('\n')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
case 'VIDEO': {
|
||||||
|
const src = (config['src'] as string) || '';
|
||||||
|
const controls = config['controls'] !== false;
|
||||||
|
const autoplay = config['autoplay'] === true;
|
||||||
|
return `<video src="${this.esc(src)}" class="${cls}" ${controls ? 'controls' : ''} ${autoplay ? 'autoplay' : ''} muted style="max-width:100%"></video>`;
|
||||||
|
}
|
||||||
|
case 'CONTACT_FORM': {
|
||||||
|
const fields = (config['fields'] as string[]) || ['name', 'email', 'message'];
|
||||||
|
const submitText = (config['submitText'] as string) || 'Send';
|
||||||
|
return `<form class="${cls}">
|
||||||
|
${fields.map(f => `
|
||||||
|
<div style="margin-bottom:1rem">
|
||||||
|
<label style="display:block;font-size:0.875rem;font-weight:600;margin-bottom:0.375rem;color:var(--color-text,#374151)">${this.esc(this.capitalize(f))}</label>
|
||||||
|
${f === 'message' ? `<textarea rows="4" style="width:100%;padding:0.75rem;border:1px solid #d1d5db;border-radius:8px;font-family:inherit;font-size:0.95rem;transition:border-color 0.2s;outline:none" onfocus="this.style.borderColor='var(--color-primary,#4f46e5)'" onblur="this.style.borderColor='#d1d5db'"></textarea>`
|
||||||
|
: `<input type="${f === 'email' ? 'email' : 'text'}" style="width:100%;padding:0.75rem;border:1px solid #d1d5db;border-radius:8px;font-family:inherit;font-size:0.95rem;transition:border-color 0.2s;outline:none" onfocus="this.style.borderColor='var(--color-primary,#4f46e5)'" onblur="this.style.borderColor='#d1d5db'">`}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
<button type="submit" style="padding:0.75rem 2rem;background:var(--color-primary,#4f46e5);color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:1rem;font-weight:500;transition:opacity 0.2s" onmouseover="this.style.opacity='0.9'" onmouseout="this.style.opacity='1'">${this.esc(submitText)}</button>
|
||||||
|
</form>`;
|
||||||
|
}
|
||||||
|
case 'MAP': {
|
||||||
|
const address = (config['address'] as string) || 'New York, NY';
|
||||||
|
return `<div class="${cls}" style="display:flex;align-items:center;justify-content:center;background:#f3f4f6">
|
||||||
|
<span style="color:#9ca3af">Map: ${this.esc(address)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
case 'CUSTOM_HTML': {
|
||||||
|
const html = (config['html'] as string) || '';
|
||||||
|
return `<div class="${cls}">${html}</div>`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return `<div class="${cls}">[${this.esc(type)}]</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cssProperty(key: string): string {
|
||||||
|
return key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private esc(s: string): string {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = s;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,3 +8,12 @@ body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
|
|||||||
mat-form-field { width: 100%; }
|
mat-form-field { width: 100%; }
|
||||||
|
|
||||||
input.mat-input-element { margin-top: 0 !important; }
|
input.mat-input-element { margin-top: 0 !important; }
|
||||||
|
|
||||||
|
.fullscreen-dialog .mat-mdc-dialog-container {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-dialog .mat-mdc-dialog-surface {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user