publish to github or gitlab pages

This commit is contained in:
Krrish Ghimire
2026-07-04 23:38:17 +05:45
parent 0fea26d653
commit bfc61ed660
93 changed files with 2367 additions and 193 deletions

View File

@@ -25,5 +25,15 @@ export const routes: Routes = [
loadComponent: () => import('./llm-workspace/llm-workspace.component').then(m => m.LlmWorkspaceComponent),
canActivate: [AuthGuard],
},
{
path: 'llm/:siteId/settings',
loadComponent: () => import('./llm-workspace/settings/settings.component').then(m => m.SettingsComponent),
canActivate: [AuthGuard],
},
{
path: 'llm/:siteId/git',
loadComponent: () => import('./llm-workspace/git-settings/git-settings.component').then(m => m.GitSettingsComponent),
canActivate: [AuthGuard],
},
{ path: '**', redirectTo: '/dashboard' },
];

View File

@@ -0,0 +1,25 @@
export interface GitConnectRequest {
provider: 'GITHUB' | 'GITLAB';
remoteUrl: string;
token: string;
}
export interface GitStatusResponse {
connected: boolean;
provider: string | null;
remoteUrl: string | null;
hasKeys: boolean;
commits: CommitInfo[];
}
export interface CommitInfo {
hash: string;
message: string;
timestamp: string;
author: string;
}
export interface PublishResponse {
message: string;
url: string;
}

View File

@@ -4,6 +4,7 @@ export interface SiteResponse {
name: string;
description: string;
subdomain: string;
publishedUrl: string | null;
themeConfig: Record<string, unknown>;
status: 'DRAFT' | 'PUBLISHED';
publishedAt: string | null;

View File

@@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { GitConnectRequest, GitStatusResponse, PublishResponse } from '../models/deployment';
@Injectable({ providedIn: 'root' })
export class DeploymentService {
constructor(private http: HttpClient) {}
exportSite(siteId: string): Observable<Blob> {
return this.http.get(`/api/sites/${siteId}/export`, { responseType: 'blob' });
}
getGitStatus(siteId: string): Observable<GitStatusResponse> {
return this.http.get<GitStatusResponse>(`/api/sites/${siteId}/git/status`);
}
connectGit(siteId: string, request: GitConnectRequest): Observable<Record<string, string>> {
return this.http.post<Record<string, string>>(`/api/sites/${siteId}/git/connect`, request);
}
disconnectGit(siteId: string): Observable<Record<string, string>> {
return this.http.post<Record<string, string>>(`/api/sites/${siteId}/git/disconnect`, {});
}
publish(siteId: string): Observable<PublishResponse> {
return this.http.post<PublishResponse>(`/api/sites/${siteId}/publish`, {});
}
unpublish(siteId: string): Observable<Record<string, string>> {
return this.http.post<Record<string, string>>(`/api/sites/${siteId}/unpublish`, {});
}
}

View File

@@ -23,7 +23,6 @@ import { SiteService } from '../../core/services/site.service';
<mat-form-field appearance="outline" class="w-full">
<mat-label>Subdomain</mat-label>
<span matTextSuffix>.indie.com</span>
<input matInput formControlName="subdomain" placeholder="my-site" />
<mat-error *ngIf="form.get('subdomain')?.hasError('required')">Subdomain is required</mat-error>
<mat-error *ngIf="form.get('subdomain')?.hasError('pattern')">

View File

@@ -61,7 +61,7 @@ import { DeleteSiteDialog } from './delete-site-dialog/delete-site-dialog';
<mat-card *ngFor="let site of sites" class="cursor-pointer hover:shadow-lg transition-shadow" (click)="openBuilder(site)">
<mat-card-header>
<mat-card-title>{{ site.name }}</mat-card-title>
<mat-card-subtitle *ngIf="site.subdomain">{{ site.subdomain }}.indie.com</mat-card-subtitle>
<mat-card-subtitle *ngIf="site.subdomain">{{ site.subdomain }}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<p class="text-gray-600 text-sm mt-2">{{ site.description || 'No description' }}</p>

View File

@@ -0,0 +1,236 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { NgIf, NgFor, DatePipe, SlicePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } 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 { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { DeploymentService } from '../../core/services/deployment.service';
import { GitStatusResponse, CommitInfo } from '../../core/models/deployment';
@Component({
selector: 'app-git-settings',
standalone: true,
imports: [
NgIf, NgFor, DatePipe, SlicePipe, FormsModule,
MatToolbarModule, MatButtonModule, MatIconModule,
MatInputModule, MatFormFieldModule, MatCardModule,
MatDividerModule, MatSnackBarModule, MatProgressSpinnerModule,
MatSelectModule,
],
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)="goBack()">
<mat-icon>arrow_back</mat-icon>
</button>
<span class="text-lg font-bold">Git Settings</span>
</div>
</mat-toolbar>
<div class="flex-1 overflow-y-auto">
<div class="max-w-2xl mx-auto px-6 py-6 space-y-6">
<div *ngIf="loading" class="flex justify-center py-12">
<mat-spinner diameter="40"></mat-spinner>
</div>
<ng-container *ngIf="!loading">
<mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Connection</h4>
<span class="text-xs px-2 py-0.5 rounded-full"
[class.bg-green-100]="status?.connected"
[class.text-green-700]="status?.connected"
[class.bg-gray-100]="!status?.connected"
[class.text-gray-600]="!status?.connected">
{{ status?.connected ? 'Connected' : 'Not Connected' }}
</span>
</div>
<div *ngIf="!status?.connected" class="space-y-3">
<p class="text-sm text-gray-600">Connect a remote Git repository to automatically push changes when you publish.</p>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Provider</mat-label>
<mat-select [(ngModel)]="connectProvider">
<mat-option value="GITHUB">GitHub</mat-option>
<mat-option value="GITLAB">GitLab</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Remote URL</mat-label>
<input matInput placeholder="git@github.com:user/repo.git" [(ngModel)]="connectRemoteUrl" />
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Access Token</mat-label>
<input matInput type="password" placeholder="GitHub/GitLab personal access token" [(ngModel)]="connectToken" />
</mat-form-field>
<div class="flex justify-end">
<button mat-flat-button color="primary" [disabled]="connecting || !connectRemoteUrl.trim() || !connectToken.trim()" (click)="connect()">
<mat-icon>link</mat-icon>
{{ connecting ? 'Connecting...' : 'Connect' }}
</button>
</div>
</div>
<div *ngIf="status?.connected" class="space-y-3">
<div class="bg-gray-50 rounded-lg p-3 space-y-2">
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-500 font-medium">Provider:</span>
<span class="text-gray-800">{{ status?.provider }}</span>
</div>
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-500 font-medium">Remote:</span>
<span class="text-gray-800 font-mono text-xs">{{ status?.remoteUrl }}</span>
</div>
<div class="flex items-center gap-2 text-sm" *ngIf="status?.hasKeys">
<mat-icon class="text-green-600 text-lg">vpn_key</mat-icon>
<span class="text-green-700">SSH keys configured</span>
</div>
</div>
<div class="flex justify-end">
<button mat-stroked-button color="warn" [disabled]="disconnecting" (click)="disconnect()">
<mat-icon>link_off</mat-icon>
{{ disconnecting ? 'Disconnecting...' : 'Disconnect' }}
</button>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="p-4" appearance="outlined" *ngIf="status?.connected && status?.commits && status!.commits.length > 0">
<mat-card-content class="!p-0">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Commit History</h4>
<div class="space-y-2">
<div *ngFor="let c of status!.commits" class="flex items-start gap-3 p-2 rounded-lg hover:bg-gray-50">
<mat-icon class="text-gray-400 text-lg mt-0.5">commit</mat-icon>
<div class="flex-1 min-w-0">
<p class="text-sm text-gray-800 truncate">{{ c.message }}</p>
<div class="flex items-center gap-2 text-xs text-gray-400 mt-0.5">
<span class="font-mono">{{ c.hash | slice:0:8 }}</span>
<span>{{ c.author }}</span>
<span>{{ c.timestamp | date:'short' }}</span>
</div>
</div>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="p-4" appearance="outlined" *ngIf="status?.connected && (!status?.commits || status!.commits.length === 0)">
<mat-card-content class="!p-0">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Commit History</h4>
<p class="text-sm text-gray-500">No commits yet. Publish your site to create the first commit.</p>
</mat-card-content>
</mat-card>
</ng-container>
</div>
</div>
</div>
`,
})
export class GitSettingsComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private deploymentService = inject(DeploymentService);
private snackBar = inject(MatSnackBar);
siteId: string | null = null;
status: GitStatusResponse | null = null;
loading = true;
connectProvider: 'GITHUB' | 'GITLAB' = 'GITHUB';
connectRemoteUrl = '';
connectToken = '';
connecting = false;
disconnecting = false;
private destroy$ = new Subject<void>();
ngOnInit(): void {
this.route.paramMap
.pipe(takeUntil(this.destroy$))
.subscribe(params => {
this.siteId = params.get('siteId');
if (this.siteId) this.loadStatus();
});
}
private loadStatus(): void {
if (!this.siteId) return;
this.loading = true;
this.deploymentService.getGitStatus(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (s) => {
this.status = s;
this.loading = false;
},
error: () => {
this.loading = false;
this.snackBar.open('Failed to load git status', 'Close', { duration: 3000 });
},
});
}
connect(): void {
if (!this.siteId || !this.connectRemoteUrl.trim() || !this.connectToken.trim()) return;
this.connecting = true;
this.deploymentService.connectGit(this.siteId, {
provider: this.connectProvider,
remoteUrl: this.connectRemoteUrl.trim(),
token: this.connectToken.trim(),
}).pipe(takeUntil(this.destroy$))
.subscribe({
next: (res) => {
this.connecting = false;
this.snackBar.open(res['message'] || 'Connected', 'Close', { duration: 3000 });
this.loadStatus();
},
error: (err) => {
this.connecting = false;
const msg = err.error?.error || 'Failed to connect';
this.snackBar.open(msg, 'Close', { duration: 5000 });
},
});
}
disconnect(): void {
if (!this.siteId) return;
this.disconnecting = true;
this.deploymentService.disconnectGit(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (res) => {
this.disconnecting = false;
this.snackBar.open(res['message'] || 'Disconnected', 'Close', { duration: 3000 });
this.loadStatus();
},
error: () => {
this.disconnecting = false;
this.snackBar.open('Failed to disconnect', 'Close', { duration: 3000 });
},
});
}
goBack(): void {
if (this.siteId) this.router.navigate(['/llm', this.siteId, 'settings']);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -41,6 +41,9 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
</button>
<span class="text-lg font-bold">AI Site Generator</span>
</div>
<button mat-icon-button (click)="goToSettings()" title="Settings">
<mat-icon>settings</mat-icon>
</button>
</mat-toolbar>
<div class="flex-1 overflow-y-auto">
@@ -439,6 +442,10 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
this.router.navigate(['/dashboard']);
}
goToSettings(): void {
if (this.siteId) this.router.navigate(['/llm', this.siteId, 'settings']);
}
totalComponents(structure: SiteStructure): number {
return structure.pages.reduce((sum, p) => sum + p.components.length, 0);
}

View File

@@ -0,0 +1,278 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { NgIf, NgFor, 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 { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { SiteService } from '../../core/services/site.service';
import { DeploymentService } from '../../core/services/deployment.service';
import { SiteResponse } from '../../core/models/site';
@Component({
selector: 'app-settings',
standalone: true,
imports: [
NgIf, NgFor, DatePipe, FormsModule, RouterModule,
MatToolbarModule, MatButtonModule, MatIconModule,
MatInputModule, MatFormFieldModule, MatCardModule,
MatDividerModule, MatSnackBarModule, MatProgressSpinnerModule,
],
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)="goBack()">
<mat-icon>arrow_back</mat-icon>
</button>
<span class="text-lg font-bold">Site Settings</span>
</div>
</mat-toolbar>
<div class="flex-1 overflow-y-auto">
<div class="max-w-2xl mx-auto px-6 py-6 space-y-6">
<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-4">General</h4>
<div class="space-y-3">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Site Name</mat-label>
<input matInput [(ngModel)]="siteName" [disabled]="saving" />
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Description</mat-label>
<textarea matInput rows="2" [(ngModel)]="siteDescription" [disabled]="saving"></textarea>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Subdomain</mat-label>
<input matInput [(ngModel)]="siteSubdomain" [disabled]="saving" />
</mat-form-field>
<div class="flex justify-end">
<button mat-flat-button color="primary" [disabled]="saving || !siteName.trim()" (click)="saveSettings()">
<mat-icon>save</mat-icon>
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</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-4">Export</h4>
<p class="text-sm text-gray-600 mb-3">
Download your site as a ZIP file containing all HTML, CSS, JS, and assets, plus the full .git directory.
</p>
<button mat-stroked-button color="primary" [disabled]="exporting" (click)="exportSite()">
<mat-icon>download</mat-icon>
{{ exporting ? 'Exporting...' : 'Download ZIP' }}
</button>
</mat-card-content>
</mat-card>
<mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Publishing</h4>
<span class="text-xs px-2 py-0.5 rounded-full"
[class.bg-green-100]="siteStatus === 'PUBLISHED'"
[class.text-green-700]="siteStatus === 'PUBLISHED'"
[class.bg-gray-100]="siteStatus === 'DRAFT'"
[class.text-gray-600]="siteStatus === 'DRAFT'">
{{ siteStatus === 'PUBLISHED' ? 'Published' : 'Draft' }}
</span>
</div>
<p class="text-sm text-gray-600 mb-3" *ngIf="siteStatus === 'DRAFT'">
Publish your site to make it live at your subdomain. The latest version will be rendered and deployed.
</p>
<p class="text-sm text-gray-600 mb-3" *ngIf="siteStatus === 'PUBLISHED' && liveUrl">
Your site is live at <a [href]="liveUrl" target="_blank" class="text-blue-600 underline">{{ liveUrl }}</a>
</p>
<div class="flex gap-3" *ngIf="!publishing">
<button mat-flat-button color="primary" *ngIf="siteStatus === 'DRAFT'" [disabled]="publishing" (click)="publishSite()">
<mat-icon>publish</mat-icon>
Publish
</button>
<button mat-stroked-button color="warn" *ngIf="siteStatus === 'PUBLISHED'" [disabled]="publishing" (click)="unpublishSite()">
<mat-icon>unpublish</mat-icon>
Unpublish
</button>
</div>
<div *ngIf="publishing" class="flex items-center gap-3">
<mat-spinner diameter="20"></mat-spinner>
<span class="text-sm text-gray-500">{{ publishStatusText }}</span>
</div>
</mat-card-content>
</mat-card>
<mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Git Repository</h4>
<button mat-stroked-button (click)="goToGitSettings()">
<mat-icon>settings</mat-icon>
Manage
</button>
</div>
<p class="text-sm text-gray-600 mt-2">
Connect a GitHub or GitLab repository to automatically push changes when you publish.
</p>
</mat-card-content>
</mat-card>
</div>
</div>
</div>
`,
})
export class SettingsComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private siteService = inject(SiteService);
private deploymentService = inject(DeploymentService);
private snackBar = inject(MatSnackBar);
siteId: string | null = null;
siteName = '';
siteDescription = '';
siteSubdomain = '';
siteStatus: 'DRAFT' | 'PUBLISHED' = 'DRAFT';
liveUrl = '';
saving = false;
exporting = false;
publishing = false;
publishStatusText = '';
private destroy$ = new Subject<void>();
ngOnInit(): void {
this.route.paramMap
.pipe(takeUntil(this.destroy$))
.subscribe(params => {
this.siteId = params.get('siteId');
if (this.siteId) this.loadSite();
});
}
private loadSite(): void {
if (!this.siteId) return;
this.siteService.get(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (site) => {
this.siteName = site.name;
this.siteDescription = site.description || '';
this.siteSubdomain = site.subdomain || '';
this.siteStatus = site.status;
this.liveUrl = site.publishedUrl || '';
},
error: () => this.snackBar.open('Failed to load site', 'Close', { duration: 3000 }),
});
}
saveSettings(): void {
if (!this.siteId || !this.siteName.trim()) return;
this.saving = true;
this.siteService.update(this.siteId, {
name: this.siteName,
description: this.siteDescription,
subdomain: this.siteSubdomain,
}).pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.saving = false;
this.snackBar.open('Settings saved', 'Close', { duration: 2000 });
},
error: () => {
this.saving = false;
this.snackBar.open('Failed to save settings', 'Close', { duration: 3000 });
},
});
}
exportSite(): void {
if (!this.siteId) return;
this.exporting = true;
this.deploymentService.exportSite(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob) => {
this.exporting = false;
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const name = this.siteSubdomain || this.siteId || 'site';
a.download = `site-export-${name}.zip`;
a.click();
window.URL.revokeObjectURL(url);
this.snackBar.open('Export downloaded', 'Close', { duration: 2000 });
},
error: () => {
this.exporting = false;
this.snackBar.open('Export failed', 'Close', { duration: 3000 });
},
});
}
publishSite(): void {
if (!this.siteId) return;
this.publishing = true;
this.publishStatusText = 'Publishing site...';
this.deploymentService.publish(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (res) => {
this.publishing = false;
this.siteStatus = 'PUBLISHED';
this.liveUrl = res.url;
this.snackBar.open('Site published!', 'Close', { duration: 3000 });
},
error: (err) => {
this.publishing = false;
const msg = err.error?.error || 'Publish failed';
this.snackBar.open(msg, 'Close', { duration: 5000 });
},
});
}
unpublishSite(): void {
if (!this.siteId) return;
this.publishing = true;
this.publishStatusText = 'Unpublishing site...';
this.deploymentService.unpublish(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.publishing = false;
this.siteStatus = 'DRAFT';
this.liveUrl = '';
this.snackBar.open('Site unpublished', 'Close', { duration: 3000 });
},
error: () => {
this.publishing = false;
this.snackBar.open('Unpublish failed', 'Close', { duration: 3000 });
},
});
}
goBack(): void {
if (this.siteId) this.router.navigate(['/llm', this.siteId]);
}
goToGitSettings(): void {
if (this.siteId) this.router.navigate(['/llm', this.siteId, 'git']);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}