register, login, create and manage sites and pages

This commit is contained in:
Krrish Ghimire
2026-06-30 11:13:55 +05:45
commit 65f38d68c5
110 changed files with 28049 additions and 0 deletions

16
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx ng build
FROM nginx:alpine
COPY --from=build /app/dist/indie-frontend/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

27
frontend/README.md Normal file
View File

@@ -0,0 +1,27 @@
# IndieFrontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.3.17.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.

128
frontend/angular.json Normal file
View File

@@ -0,0 +1,128 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"indie-frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:component": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/indie-frontend",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "indie-frontend:build:production"
},
"development": {
"buildTarget": "indie-frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "indie-frontend:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.css"
],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": false
}
}

22
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,22 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8080;
}
location /oauth2/ {
proxy_pass http://backend:8080;
}
location /login/ {
proxy_pass http://backend:8080;
}
}

13609
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
frontend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "indie-frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^17.3.0",
"@angular/cdk": "^17.3.10",
"@angular/common": "^17.3.0",
"@angular/compiler": "^17.3.0",
"@angular/core": "^17.3.0",
"@angular/forms": "^17.3.0",
"@angular/material": "^17.3.10",
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.3.17",
"@angular/cli": "^17.3.17",
"@angular/compiler-cli": "^17.3.0",
"@types/jasmine": "~5.1.0",
"autoprefixer": "^10.5.2",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.5.16",
"tailwindcss": "^3.4.19",
"typescript": "~5.4.2"
}
}

8446
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

14
frontend/proxy.conf.json Normal file
View File

@@ -0,0 +1,14 @@
{
"/api": {
"target": "http://localhost:8080",
"secure": false
},
"/oauth2": {
"target": "http://localhost:8080",
"secure": false
},
"/login": {
"target": "http://localhost:8080",
"secure": false
}
}

View File

View File

@@ -0,0 +1 @@
<router-outlet></router-outlet>

View 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 {}

View 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 },
],
};

View 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' },
];

View 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;
},
});
}
}

View File

@@ -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']);
}
}
}

View 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;
},
});
}
}

View 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');
}
}

View 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);
}
}

View 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';
}

View 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;
}

View 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>;
}

View 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;
}
}
}

View 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}`);
}
}

View 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}`);
}
}

View File

@@ -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;
},
});
}
}

View 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']);
},
});
}
}

View File

@@ -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);
}

View File

BIN
frontend/src/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

15
frontend/src/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Indie</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<app-root></app-root>
</body>
</html>

6
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

10
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
mat-form-field { width: 100%; }
input.mat-input-element { margin-top: 0 !important; }

View File

@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{html,ts}",
],
corePlugins: {
preflight: false,
},
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -0,0 +1,14 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

32
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": [
"ES2022",
"dom"
]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,14 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}