publish site to custom server

This commit is contained in:
Krrish Ghimire
2026-07-06 11:15:03 +05:45
parent 51d887a005
commit a498d55e8c
23 changed files with 966 additions and 25 deletions

View File

@@ -23,3 +23,27 @@ export interface PublishResponse {
message: string;
url: string;
}
export interface SelfHostedConfigRequest {
domain: string;
sshHost: string;
sshPort: number;
sshUser: string;
deployPath: string;
nginxSitesPath: string;
}
export interface SelfHostedConfigResponse {
id: string;
siteId: string;
domain: string;
sshHost: string;
sshPort: number;
sshUser: string;
deployPath: string;
nginxSitesPath: string;
publicKey: string;
deployedAt: string | null;
createdAt: string;
updatedAt: string;
}

View File

@@ -7,6 +7,7 @@ export interface SiteResponse {
publishedUrl: string | null;
themeConfig: Record<string, unknown>;
status: 'DRAFT' | 'PUBLISHED';
deploymentTarget: 'GIT_PAGES' | 'SELF_HOSTED' | 'NONE';
publishedAt: string | null;
createdAt: string;
updatedAt: string;
@@ -16,5 +17,6 @@ export interface SiteRequest {
name: string;
description: string;
subdomain: string;
deploymentTarget?: string;
themeConfig?: Record<string, unknown>;
}

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { SiteResponse, SiteRequest } from '../models/site';
import { SelfHostedConfigRequest, SelfHostedConfigResponse } from '../models/deployment';
@Injectable({ providedIn: 'root' })
export class SiteService {
@@ -26,4 +27,18 @@ export class SiteService {
delete(id: string): Observable<void> {
return this.http.delete<void>(`/api/sites/${id}`);
}
getSelfHostedConfig(id: string): Observable<SelfHostedConfigResponse | { configured: false }> {
return this.http.get<SelfHostedConfigResponse | { configured: false }>(
`/api/sites/${id}/self-hosted`
);
}
saveSelfHostedConfig(id: string, config: SelfHostedConfigRequest): Observable<SelfHostedConfigResponse> {
return this.http.put<SelfHostedConfigResponse>(`/api/sites/${id}/self-hosted`, config);
}
deleteSelfHostedConfig(id: string): Observable<void> {
return this.http.delete<void>(`/api/sites/${id}/self-hosted`);
}
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { NgIf, NgFor, DatePipe } from '@angular/common';
import { NgIf, NgFor, NgSwitch, NgSwitchCase, NgSwitchDefault, DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
@@ -7,6 +7,7 @@ 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 { MatSelectModule } from '@angular/material/select';
import { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
@@ -15,16 +16,16 @@ 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';
import { SelfHostedConfigResponse } from '../../core/models/deployment';
@Component({
selector: 'app-settings',
standalone: true,
imports: [
NgIf, NgFor, DatePipe, FormsModule, RouterModule,
NgIf, NgFor, NgSwitch, NgSwitchCase, NgSwitchDefault, DatePipe, FormsModule, RouterModule,
MatToolbarModule, MatButtonModule, MatIconModule,
MatInputModule, MatFormFieldModule, MatCardModule,
MatDividerModule, MatSnackBarModule, MatProgressSpinnerModule,
MatInputModule, MatFormFieldModule, MatSelectModule,
MatCardModule, MatDividerModule, MatSnackBarModule, MatProgressSpinnerModule,
],
template: `
<div class="h-screen flex flex-col bg-gray-50">
@@ -66,6 +67,38 @@ import { SiteResponse } from '../../core/models/site';
</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">Deployment Target</h4>
<p class="text-sm text-gray-600 mb-3">
Choose where your site is published. Git push always runs if a remote is configured.
</p>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Target</mat-label>
<mat-select [(ngModel)]="deploymentTarget" (selectionChange)="onDeploymentTargetChange()">
<mat-option value="NONE">None</mat-option>
<mat-option value="GIT_PAGES">GitHub / GitLab Pages</mat-option>
<mat-option value="SELF_HOSTED">Self-Hosted Server</mat-option>
</mat-select>
</mat-form-field>
<div class="text-xs text-gray-500 space-y-1 mt-1">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full shrink-0"
[class.bg-green-500]="hasGitRemote"
[class.bg-gray-300]="!hasGitRemote"></span>
<span>Git Remote: {{ hasGitRemote ? 'Connected' : 'Not configured' }}</span>
<button mat-button (click)="goToGitSettings()" class="!text-xs !min-w-0 !px-1">Manage</button>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full shrink-0"
[class.bg-green-500]="hasSelfHostedConfig"
[class.bg-gray-300]="!hasSelfHostedConfig"></span>
<span>Self-Hosted: {{ hasSelfHostedConfig ? 'Configured' : 'Not configured' }}</span>
</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>
@@ -92,7 +125,13 @@ import { SiteResponse } from '../../core/models/site';
</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.
Publish your site to make it live
<ng-container [ngSwitch]="deploymentTarget">
<span *ngSwitchCase="'GIT_PAGES'">on GitHub/GitLab Pages</span>
<span *ngSwitchCase="'SELF_HOSTED'">on your own server</span>
<span *ngSwitchDefault>.</span>
</ng-container>
. 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>
@@ -114,6 +153,66 @@ import { SiteResponse } from '../../core/models/site';
</mat-card-content>
</mat-card>
<mat-card class="p-4" appearance="outlined" *ngIf="deploymentTarget === 'SELF_HOSTED'">
<mat-card-content class="!p-0">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Self-Hosted Server</h4>
<div class="space-y-3">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Domain</mat-label>
<input matInput [(ngModel)]="shDomain" [disabled]="shSaving" placeholder="www.example.com" />
</mat-form-field>
<div class="flex gap-3">
<mat-form-field appearance="outline" class="flex-1">
<mat-label>SSH Host</mat-label>
<input matInput [(ngModel)]="shSshHost" [disabled]="shSaving" placeholder="192.168.1.1" />
</mat-form-field>
<mat-form-field appearance="outline" class="w-24">
<mat-label>Port</mat-label>
<input matInput type="number" [(ngModel)]="shSshPort" [disabled]="shSaving" />
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="w-full">
<mat-label>SSH User</mat-label>
<input matInput [(ngModel)]="shSshUser" [disabled]="shSaving" placeholder="deploy" />
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Deploy Path</mat-label>
<input matInput [(ngModel)]="shDeployPath" [disabled]="shSaving" />
<mat-hint>Use {{ '{' }}domain{{ '}' }} as placeholder: /var/www/{{ '{' }}domain{{ '}' }}/public</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Nginx Sites Path</mat-label>
<input matInput [(ngModel)]="shNginxSitesPath" [disabled]="shSaving" />
<mat-hint>Path to nginx sites-available directory</mat-hint>
</mat-form-field>
<div class="bg-gray-50 rounded-lg p-3 space-y-2" *ngIf="shPublicKey">
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wide">Public Key</div>
<p class="text-xs text-gray-600">
Add this key to <code class="bg-gray-200 px-1 rounded">~/.ssh/authorized_keys</code> on your server for the SSH user above.
</p>
<pre class="text-xs bg-gray-800 text-green-300 rounded p-2 overflow-x-auto whitespace-pre-wrap break-all max-h-28 overflow-y-auto">{{ shPublicKey }}</pre>
<button mat-stroked-button (click)="copyPublicKey()" class="!text-xs">
<mat-icon>content_copy</mat-icon>
{{ shPublicKeyCopied ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs text-gray-500" *ngIf="shDeployedAt">
Last deployed: {{ shDeployedAt }}
</p>
<div class="flex gap-3">
<button mat-flat-button color="primary" [disabled]="shSaving || !shDomain.trim() || !shSshHost.trim() || !shSshUser.trim()" (click)="saveSelfHostedConfig()">
<mat-icon>save</mat-icon>
{{ shSaving ? 'Saving...' : 'Save Config' }}
</button>
<button mat-stroked-button color="warn" *ngIf="hasSelfHostedConfig" [disabled]="shSaving" (click)="removeSelfHostedConfig()">
<mat-icon>delete</mat-icon>
Remove
</button>
</div>
</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">
@@ -124,7 +223,7 @@ import { SiteResponse } from '../../core/models/site';
</button>
</div>
<p class="text-sm text-gray-600 mt-2">
Connect a GitHub or GitLab repository to automatically push changes when you publish.
Connect a GitHub or GitLab repository. Git push runs on every publish regardless of deployment target.
</p>
</mat-card-content>
</mat-card>
@@ -147,10 +246,25 @@ export class SettingsComponent implements OnInit, OnDestroy {
siteSubdomain = '';
siteStatus: 'DRAFT' | 'PUBLISHED' = 'DRAFT';
liveUrl = '';
deploymentTarget: 'GIT_PAGES' | 'SELF_HOSTED' | 'NONE' = 'NONE';
hasGitRemote = false;
hasSelfHostedConfig = false;
saving = false;
exporting = false;
publishing = false;
publishStatusText = '';
shDomain = '';
shSshHost = '';
shSshPort = 22;
shSshUser = '';
shDeployPath = '/var/www/{domain}/public';
shNginxSitesPath = '/etc/nginx/sites-available';
shPublicKey = '';
shPublicKeyCopied = false;
shDeployedAt: string | null = null;
shSaving = false;
private destroy$ = new Subject<void>();
ngOnInit(): void {
@@ -173,11 +287,64 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.siteSubdomain = site.subdomain || '';
this.siteStatus = site.status;
this.liveUrl = site.publishedUrl || '';
this.deploymentTarget = site.deploymentTarget || 'NONE';
this.loadGitStatus();
this.loadSelfHostedConfig();
},
error: () => this.snackBar.open('Failed to load site', 'Close', { duration: 3000 }),
});
}
private loadGitStatus(): void {
if (!this.siteId) return;
this.deploymentService.getGitStatus(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (status) => { this.hasGitRemote = status.connected; },
error: () => {},
});
}
private loadSelfHostedConfig(): void {
if (!this.siteId) return;
this.siteService.getSelfHostedConfig(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (config) => {
if ('configured' in config && !config.configured) {
this.hasSelfHostedConfig = false;
return;
}
const c = config as SelfHostedConfigResponse;
this.hasSelfHostedConfig = true;
this.shDomain = c.domain;
this.shSshHost = c.sshHost;
this.shSshPort = c.sshPort;
this.shSshUser = c.sshUser;
this.shDeployPath = c.deployPath;
this.shNginxSitesPath = c.nginxSitesPath;
this.shPublicKey = c.publicKey;
this.shDeployedAt = c.deployedAt;
},
error: () => { this.hasSelfHostedConfig = false; },
});
}
onDeploymentTargetChange(): void {
if (!this.siteId) return;
this.saving = true;
this.siteService.update(this.siteId, {
name: this.siteName,
description: this.siteDescription,
subdomain: this.siteSubdomain,
deploymentTarget: this.deploymentTarget,
}).pipe(takeUntil(this.destroy$))
.subscribe({
next: () => { this.saving = false; },
error: () => { this.saving = false; },
});
}
saveSettings(): void {
if (!this.siteId || !this.siteName.trim()) return;
this.saving = true;
@@ -185,6 +352,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
name: this.siteName,
description: this.siteDescription,
subdomain: this.siteSubdomain,
deploymentTarget: this.deploymentTarget,
}).pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
@@ -198,6 +366,63 @@ export class SettingsComponent implements OnInit, OnDestroy {
});
}
saveSelfHostedConfig(): void {
if (!this.siteId) return;
this.shSaving = true;
this.siteService.saveSelfHostedConfig(this.siteId, {
domain: this.shDomain,
sshHost: this.shSshHost,
sshPort: this.shSshPort,
sshUser: this.shSshUser,
deployPath: this.shDeployPath,
nginxSitesPath: this.shNginxSitesPath,
}).pipe(takeUntil(this.destroy$))
.subscribe({
next: (config) => {
this.shSaving = false;
this.hasSelfHostedConfig = true;
this.shPublicKey = config.publicKey;
this.shDeployedAt = config.deployedAt;
this.deploymentTarget = 'SELF_HOSTED';
this.snackBar.open('Self-hosted config saved', 'Close', { duration: 2000 });
},
error: () => {
this.shSaving = false;
this.snackBar.open('Failed to save self-hosted config', 'Close', { duration: 3000 });
},
});
}
removeSelfHostedConfig(): void {
if (!this.siteId) return;
this.shSaving = true;
this.siteService.deleteSelfHostedConfig(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.shSaving = false;
this.hasSelfHostedConfig = false;
this.shDomain = '';
this.shSshHost = '';
this.shSshPort = 22;
this.shSshUser = '';
this.shDeployPath = '/var/www/{domain}/public';
this.shNginxSitesPath = '/etc/nginx/sites-available';
this.shPublicKey = '';
this.shPublicKeyCopied = false;
this.shDeployedAt = null;
if (this.deploymentTarget === 'SELF_HOSTED') {
this.deploymentTarget = 'NONE';
}
this.snackBar.open('Self-hosted config removed', 'Close', { duration: 2000 });
},
error: () => {
this.shSaving = false;
this.snackBar.open('Failed to remove self-hosted config', 'Close', { duration: 3000 });
},
});
}
exportSite(): void {
if (!this.siteId) return;
this.exporting = true;
@@ -263,6 +488,14 @@ export class SettingsComponent implements OnInit, OnDestroy {
});
}
copyPublicKey(): void {
if (!this.shPublicKey) return;
navigator.clipboard.writeText(this.shPublicKey).then(() => {
this.shPublicKeyCopied = true;
setTimeout(() => { this.shPublicKeyCopied = false; }, 2000);
});
}
goBack(): void {
if (this.siteId) this.router.navigate(['/llm', this.siteId]);
}