register, login, create and manage sites and pages
This commit is contained in:
0
frontend/src/app/app.component.css
Normal file
0
frontend/src/app/app.component.css
Normal file
1
frontend/src/app/app.component.html
Normal file
1
frontend/src/app/app.component.html
Normal file
@@ -0,0 +1 @@
|
||||
<router-outlet></router-outlet>
|
||||
10
frontend/src/app/app.component.ts
Normal file
10
frontend/src/app/app.component.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet],
|
||||
template: `<router-outlet></router-outlet>`,
|
||||
})
|
||||
export class AppComponent {}
|
||||
16
frontend/src/app/app.config.ts
Normal file
16
frontend/src/app/app.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ApplicationConfig } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { JwtInterceptor } from './core/interceptors/jwt.interceptor';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
provideAnimationsAsync(),
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },
|
||||
],
|
||||
};
|
||||
24
frontend/src/app/app.routes.ts
Normal file
24
frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { AuthGuard } from './core/guards/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () => import('./auth/login/login.component').then(m => m.LoginComponent),
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
loadComponent: () => import('./auth/register/register.component').then(m => m.RegisterComponent),
|
||||
},
|
||||
{
|
||||
path: 'oauth2/callback',
|
||||
loadComponent: () => import('./auth/oauth-callback/oauth-callback.component').then(m => m.OAuthCallbackComponent),
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
{ path: '**', redirectTo: '/dashboard' },
|
||||
];
|
||||
99
frontend/src/app/auth/login/login.component.ts
Normal file
99
frontend/src/app/auth/login/login.component.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { NgIf } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink, ReactiveFormsModule, NgIf,
|
||||
MatCardModule, MatFormFieldModule, MatInputModule, MatButtonModule,
|
||||
MatProgressSpinnerModule, MatDividerModule,
|
||||
],
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<mat-card class="w-full max-w-md p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-indigo-600">Indie</h1>
|
||||
<p class="text-gray-500 mt-1">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" class="flex flex-col gap-4">
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Email</mat-label>
|
||||
<input matInput type="email" formControlName="email" placeholder="you@example.com" />
|
||||
<mat-error *ngIf="loginForm.get('email')?.hasError('required')">Email is required</mat-error>
|
||||
<mat-error *ngIf="loginForm.get('email')?.hasError('email')">Invalid email format</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Password</mat-label>
|
||||
<input matInput type="password" formControlName="password" />
|
||||
<mat-error *ngIf="loginForm.get('password')?.hasError('required')">Password is required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div *ngIf="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="loading" class="w-full py-2">
|
||||
<mat-spinner *ngIf="loading" diameter="20" class="inline-block mr-2"></mat-spinner>
|
||||
{{ loading ? 'Signing in...' : 'Sign In' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="my-4">
|
||||
<mat-divider></mat-divider>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<a mat-stroked-button href="/oauth2/authorization/google" class="w-full">
|
||||
Sign in with Google
|
||||
</a>
|
||||
<a mat-stroked-button href="/oauth2/authorization/github" class="w-full">
|
||||
Sign in with GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="text-center mt-6 text-sm text-gray-500">
|
||||
Don't have an account?
|
||||
<a routerLink="/register" class="text-indigo-600 font-medium hover:underline">Register</a>
|
||||
</p>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent {
|
||||
loginForm = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', Validators.required],
|
||||
});
|
||||
loading = false;
|
||||
error = '';
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private authService: AuthService,
|
||||
private router: Router,
|
||||
) {}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.loginForm.invalid) return;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
const { email, password } = this.loginForm.value;
|
||||
this.authService.login(email!, password!).subscribe({
|
||||
next: () => this.router.navigate(['/dashboard']),
|
||||
error: (err) => {
|
||||
this.error = err.error?.message || err.error?.error || 'Invalid email or password';
|
||||
this.loading = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
|
||||
@Component({
|
||||
selector: 'app-oauth-callback',
|
||||
standalone: true,
|
||||
imports: [MatProgressSpinnerModule],
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div class="text-center">
|
||||
<mat-spinner diameter="40" class="mx-auto mb-4"></mat-spinner>
|
||||
<p class="text-gray-500">Completing sign in...</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class OAuthCallbackComponent implements OnInit {
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
const accessToken = this.route.snapshot.queryParamMap.get('accessToken');
|
||||
const refreshToken = this.route.snapshot.queryParamMap.get('refreshToken');
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
this.authService.setSessionFromOAuth(accessToken, refreshToken);
|
||||
this.router.navigate(['/dashboard']);
|
||||
} else {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
}
|
||||
93
frontend/src/app/auth/register/register.component.ts
Normal file
93
frontend/src/app/auth/register/register.component.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { NgIf } from '@angular/common';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink, ReactiveFormsModule, NgIf,
|
||||
MatCardModule, MatFormFieldModule, MatInputModule, MatButtonModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<mat-card class="w-full max-w-md p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-indigo-600">Indie</h1>
|
||||
<p class="text-gray-500 mt-1">Create your account</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()" class="flex flex-col gap-4">
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="Your name" />
|
||||
<mat-error *ngIf="registerForm.get('name')?.hasError('required')">Name is required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Email</mat-label>
|
||||
<input matInput type="email" formControlName="email" placeholder="you@example.com" />
|
||||
<mat-error *ngIf="registerForm.get('email')?.hasError('required')">Email is required</mat-error>
|
||||
<mat-error *ngIf="registerForm.get('email')?.hasError('email')">Invalid email format</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Password</mat-label>
|
||||
<input matInput type="password" formControlName="password" />
|
||||
<mat-error *ngIf="registerForm.get('password')?.hasError('required')">Password is required</mat-error>
|
||||
<mat-error *ngIf="registerForm.get('password')?.hasError('minlength')">At least 8 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div *ngIf="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="loading" class="w-full py-2">
|
||||
<mat-spinner *ngIf="loading" diameter="20" class="inline-block mr-2"></mat-spinner>
|
||||
{{ loading ? 'Creating account...' : 'Create Account' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center mt-6 text-sm text-gray-500">
|
||||
Already have an account?
|
||||
<a routerLink="/login" class="text-indigo-600 font-medium hover:underline">Sign in</a>
|
||||
</p>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RegisterComponent {
|
||||
registerForm = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', [Validators.required, Validators.minLength(8)]],
|
||||
});
|
||||
loading = false;
|
||||
error = '';
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private authService: AuthService,
|
||||
private router: Router,
|
||||
) {}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.registerForm.invalid) return;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
const { name, email, password } = this.registerForm.value;
|
||||
this.authService.register(name!, email!, password!).subscribe({
|
||||
next: () => this.router.navigate(['/dashboard']),
|
||||
error: (err) => {
|
||||
this.error = err.error?.message || err.error?.error || 'Registration failed';
|
||||
this.loading = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
15
frontend/src/app/core/guards/auth.guard.ts
Normal file
15
frontend/src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CanActivate, Router, UrlTree } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private authService: AuthService, private router: Router) {}
|
||||
|
||||
canActivate(): boolean | UrlTree {
|
||||
if (this.authService.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
return this.router.parseUrl('/login');
|
||||
}
|
||||
}
|
||||
19
frontend/src/app/core/interceptors/jwt.interceptor.ts
Normal file
19
frontend/src/app/core/interceptors/jwt.interceptor.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtInterceptor implements HttpInterceptor {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
const token = this.authService.getAccessToken();
|
||||
if (token) {
|
||||
req = req.clone({
|
||||
setHeaders: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
return next.handle(req);
|
||||
}
|
||||
}
|
||||
14
frontend/src/app/core/models/auth-response.ts
Normal file
14
frontend/src/app/core/models/auth-response.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface AuthResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
tokenType: string;
|
||||
user: UserInfo;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
provider: 'LOCAL' | 'GOOGLE' | 'GITHUB' | 'OPENID';
|
||||
}
|
||||
18
frontend/src/app/core/models/page.ts
Normal file
18
frontend/src/app/core/models/page.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface PageResponse {
|
||||
id: string;
|
||||
siteId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
seoTitle: string;
|
||||
seoDescription: string;
|
||||
orderIndex: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PageRequest {
|
||||
title: string;
|
||||
slug: string;
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
orderIndex: number;
|
||||
}
|
||||
19
frontend/src/app/core/models/site.ts
Normal file
19
frontend/src/app/core/models/site.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface SiteResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
subdomain: string;
|
||||
themeConfig: Record<string, unknown>;
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SiteRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
subdomain: string;
|
||||
themeConfig?: Record<string, unknown>;
|
||||
}
|
||||
101
frontend/src/app/core/services/auth.service.ts
Normal file
101
frontend/src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BehaviorSubject, Observable, map, tap } from 'rxjs';
|
||||
import { AuthResponse, UserInfo } from '../models/auth-response';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly ACCESS_TOKEN_KEY = 'indie_access_token';
|
||||
private readonly REFRESH_TOKEN_KEY = 'indie_refresh_token';
|
||||
private readonly USER_KEY = 'indie_user';
|
||||
|
||||
private userSubject = new BehaviorSubject<UserInfo | null>(this.loadUser());
|
||||
user$ = this.userSubject.asObservable();
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
register(name: string, email: string, password: string): Observable<AuthResponse> {
|
||||
return this.http.post<AuthResponse>('/api/auth/register', { name, email, password })
|
||||
.pipe(tap(res => this.setSession(res)));
|
||||
}
|
||||
|
||||
login(email: string, password: string): Observable<AuthResponse> {
|
||||
return this.http.post<AuthResponse>('/api/auth/login', { email, password })
|
||||
.pipe(tap(res => this.setSession(res)));
|
||||
}
|
||||
|
||||
refreshToken(refreshToken: string): Observable<AuthResponse> {
|
||||
return this.http.post<AuthResponse>('/api/auth/refresh', { refreshToken })
|
||||
.pipe(tap(res => this.setSession(res)));
|
||||
}
|
||||
|
||||
logout(): Observable<void> {
|
||||
return this.http.post<void>('/api/auth/logout', {})
|
||||
.pipe(tap(() => this.clearSession()));
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return localStorage.getItem(this.ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
return localStorage.getItem(this.REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
getUser(): UserInfo | null {
|
||||
return this.userSubject.value;
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.getAccessToken();
|
||||
}
|
||||
|
||||
setSessionFromOAuth(accessToken: string, refreshToken: string): void {
|
||||
localStorage.setItem(this.ACCESS_TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken);
|
||||
const payload = this.decodeToken(accessToken);
|
||||
if (payload) {
|
||||
const user: UserInfo = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
avatarUrl: payload.avatarUrl || '',
|
||||
provider: payload.provider || 'LOCAL',
|
||||
};
|
||||
localStorage.setItem(this.USER_KEY, JSON.stringify(user));
|
||||
this.userSubject.next(user);
|
||||
}
|
||||
}
|
||||
|
||||
clearSessionForLogout(): void {
|
||||
this.clearSession();
|
||||
}
|
||||
|
||||
private setSession(res: AuthResponse): void {
|
||||
localStorage.setItem(this.ACCESS_TOKEN_KEY, res.accessToken);
|
||||
localStorage.setItem(this.REFRESH_TOKEN_KEY, res.refreshToken);
|
||||
localStorage.setItem(this.USER_KEY, JSON.stringify(res.user));
|
||||
this.userSubject.next(res.user);
|
||||
}
|
||||
|
||||
private clearSession(): void {
|
||||
localStorage.removeItem(this.ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(this.REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(this.USER_KEY);
|
||||
this.userSubject.next(null);
|
||||
}
|
||||
|
||||
private loadUser(): UserInfo | null {
|
||||
const data = localStorage.getItem(this.USER_KEY);
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
private decodeToken(token: string): any {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
return JSON.parse(atob(parts[1]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
frontend/src/app/core/services/page.service.ts
Normal file
25
frontend/src/app/core/services/page.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { PageResponse, PageRequest } from '../models/page';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PageService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
list(siteId: string): Observable<PageResponse[]> {
|
||||
return this.http.get<PageResponse[]>(`/api/sites/${siteId}/pages`);
|
||||
}
|
||||
|
||||
create(siteId: string, request: PageRequest): Observable<PageResponse> {
|
||||
return this.http.post<PageResponse>(`/api/sites/${siteId}/pages`, request);
|
||||
}
|
||||
|
||||
update(siteId: string, pageId: string, request: PageRequest): Observable<PageResponse> {
|
||||
return this.http.put<PageResponse>(`/api/sites/${siteId}/pages/${pageId}`, request);
|
||||
}
|
||||
|
||||
delete(siteId: string, pageId: string): Observable<void> {
|
||||
return this.http.delete<void>(`/api/sites/${siteId}/pages/${pageId}`);
|
||||
}
|
||||
}
|
||||
29
frontend/src/app/core/services/site.service.ts
Normal file
29
frontend/src/app/core/services/site.service.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SiteResponse, SiteRequest } from '../models/site';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SiteService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
list(): Observable<SiteResponse[]> {
|
||||
return this.http.get<SiteResponse[]>('/api/sites');
|
||||
}
|
||||
|
||||
create(request: SiteRequest): Observable<SiteResponse> {
|
||||
return this.http.post<SiteResponse>('/api/sites', request);
|
||||
}
|
||||
|
||||
get(id: string): Observable<SiteResponse> {
|
||||
return this.http.get<SiteResponse>(`/api/sites/${id}`);
|
||||
}
|
||||
|
||||
update(id: string, request: SiteRequest): Observable<SiteResponse> {
|
||||
return this.http.put<SiteResponse>(`/api/sites/${id}`, request);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`/api/sites/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { NgIf } from '@angular/common';
|
||||
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { SiteService } from '../../core/services/site.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-site-dialog',
|
||||
standalone: true,
|
||||
imports: [NgIf, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Create New Site</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>Site Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="My Awesome Site" />
|
||||
<mat-error>Name is required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<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')">
|
||||
Lowercase letters, numbers, and hyphens only
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Description (optional)</mat-label>
|
||||
<textarea matInput formControlName="description" rows="3" placeholder="What's your site about?"></textarea>
|
||||
</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 CreateSiteDialog {
|
||||
private fb = inject(FormBuilder);
|
||||
private dialogRef = inject(MatDialogRef<CreateSiteDialog>);
|
||||
private siteService = inject(SiteService);
|
||||
|
||||
form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
subdomain: ['', [Validators.pattern(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/)]],
|
||||
description: [''],
|
||||
});
|
||||
loading = false;
|
||||
|
||||
onCreate(): void {
|
||||
if (this.form.invalid) return;
|
||||
this.loading = true;
|
||||
this.siteService.create(this.form.value as any).subscribe({
|
||||
next: (site) => {
|
||||
this.dialogRef.close(site);
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
142
frontend/src/app/dashboard/dashboard.component.ts
Normal file
142
frontend/src/app/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgFor, NgIf, DatePipe } from '@angular/common';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { AuthService } from '../core/services/auth.service';
|
||||
import { SiteService } from '../core/services/site.service';
|
||||
import { SiteResponse } from '../core/models/site';
|
||||
import { CreateSiteDialog } from './create-site-dialog/create-site-dialog';
|
||||
import { DeleteSiteDialog } from './delete-site-dialog/delete-site-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgFor, NgIf, DatePipe,
|
||||
MatToolbarModule, MatButtonModule, MatCardModule, MatIconModule,
|
||||
MatMenuModule, MatDialogModule, MatProgressSpinnerModule,
|
||||
],
|
||||
template: `
|
||||
<mat-toolbar color="primary" class="flex justify-between">
|
||||
<span class="text-xl font-bold">Indie</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm">{{ user?.name }}</span>
|
||||
<button mat-icon-button [matMenuTriggerFor]="menu">
|
||||
<mat-icon>account_circle</mat-icon>
|
||||
</button>
|
||||
<mat-menu #menu="matMenu">
|
||||
<button mat-menu-item (click)="logout()">
|
||||
<mat-icon>logout</mat-icon>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
</mat-toolbar>
|
||||
|
||||
<div class="max-w-6xl mx-auto p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-800">My Sites</h2>
|
||||
<button mat-flat-button color="primary" (click)="openCreateDialog()">
|
||||
<mat-icon class="mr-1">add</mat-icon>
|
||||
New Site
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="loading" class="flex justify-center py-20">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading && sites.length === 0" class="text-center py-20 text-gray-400">
|
||||
<mat-icon style="font-size: 64px; width: 64px; height: 64px;" class="mb-4">web</mat-icon>
|
||||
<p class="text-lg">No sites yet. Create your first site!</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<mat-card *ngFor="let site of sites" class="cursor-pointer hover:shadow-lg transition-shadow">
|
||||
<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-header>
|
||||
<mat-card-content>
|
||||
<p class="text-gray-600 text-sm mt-2">{{ site.description || 'No description' }}</p>
|
||||
<div class="flex items-center gap-2 mt-3">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full"
|
||||
[class.bg-green-100]="site.status === 'PUBLISHED'"
|
||||
[class.text-green-700]="site.status === 'PUBLISHED'"
|
||||
[class.bg-gray-100]="site.status === 'DRAFT'"
|
||||
[class.text-gray-600]="site.status === 'DRAFT'">
|
||||
{{ site.status === 'PUBLISHED' ? 'Published' : 'Draft' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{{ site.createdAt | date:'mediumDate' }}</span>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
<mat-card-actions class="flex justify-end">
|
||||
<button mat-icon-button color="warn" (click)="$event.stopPropagation(); openDeleteDialog(site)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
private authService = inject(AuthService);
|
||||
private siteService = inject(SiteService);
|
||||
private dialog = inject(MatDialog);
|
||||
private router = inject(Router);
|
||||
|
||||
user = this.authService.getUser();
|
||||
sites: SiteResponse[] = [];
|
||||
loading = true;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSites();
|
||||
}
|
||||
|
||||
loadSites(): void {
|
||||
this.loading = true;
|
||||
this.siteService.list().subscribe({
|
||||
next: (sites) => {
|
||||
this.sites = sites;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => this.loading = false,
|
||||
});
|
||||
}
|
||||
|
||||
openCreateDialog(): void {
|
||||
const ref = this.dialog.open(CreateSiteDialog, { width: '480px' });
|
||||
ref.afterClosed().subscribe((site) => {
|
||||
if (site) this.loadSites();
|
||||
});
|
||||
}
|
||||
|
||||
openDeleteDialog(site: SiteResponse): void {
|
||||
const ref = this.dialog.open(DeleteSiteDialog, {
|
||||
width: '400px',
|
||||
data: { name: site.name },
|
||||
});
|
||||
ref.afterClosed().subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
this.siteService.delete(site.id).subscribe(() => this.loadSites());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.authService.logout().subscribe({
|
||||
next: () => this.router.navigate(['/login']),
|
||||
error: () => {
|
||||
this.authService.clearSessionForLogout();
|
||||
this.router.navigate(['/login']);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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-site-dialog',
|
||||
standalone: true,
|
||||
imports: [MatDialogModule, MatButtonModule],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Delete Site</h2>
|
||||
<mat-dialog-content>
|
||||
<p>Are you sure you want to delete <strong>{{ data.name }}</strong>?</p>
|
||||
<p class="text-red-600 text-sm mt-2">This action cannot be undone. All pages and content will be permanently removed.</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 DeleteSiteDialog {
|
||||
dialogRef = inject(MatDialogRef<DeleteSiteDialog>);
|
||||
data = inject<{ name: string }>(MAT_DIALOG_DATA);
|
||||
}
|
||||
Reference in New Issue
Block a user