wip: add facades, password confirmation
This commit is contained in:
parent
fdc458e167
commit
e0cef27f51
@ -10,6 +10,7 @@
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
@ -17,7 +18,7 @@
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
|
||||
@ -60,6 +60,7 @@ services:
|
||||
command: sh -c "npm install && npx ng serve --host 0.0.0.0 --poll 2000"
|
||||
networks:
|
||||
- app-network
|
||||
user: 1000:1000
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
|
||||
@ -6,11 +6,15 @@ export const routes: Routes = [
|
||||
loadComponent: () => import('./chat/chat').then((m) => m.Chat),
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
loadComponent: () => import('./chat/chat').then((m) => m.Chat),
|
||||
path: 'settings',
|
||||
loadComponent: () => import('./settings/settings').then((m) => m.Settings),
|
||||
},
|
||||
{
|
||||
path: 'user',
|
||||
loadChildren: () => import('./auth/auth.routes').then((m) => m.authRoutes),
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
loadComponent: () => import('./chat/chat').then((m) => m.Chat),
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import {inject, Injectable } from '@angular/core';
|
||||
import {HttpClient, HttpHeaders} from '@angular/common/http';
|
||||
import {environment} from '../../environments/environment';
|
||||
import {LoginRequest, RegisterRequest, User} from './auth.types';
|
||||
import {Observable, switchMap} from 'rxjs';
|
||||
import {API_URL, BASE_URL} from '../core/tokens/api-urls';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { LoginRequest, PasswordConfirmationRequest, RegisterRequest, User } from './auth.types';
|
||||
import { Observable, switchMap } from 'rxjs';
|
||||
import { API_URL, BASE_URL } from '../core/tokens/api-urls';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@ -13,29 +12,33 @@ export class AuthService {
|
||||
private baseUrl = inject(BASE_URL);
|
||||
private apiUrl = inject(API_URL);
|
||||
|
||||
public login (credentials: LoginRequest){
|
||||
public login(credentials: LoginRequest) {
|
||||
return this.getSanctumCookie().pipe(
|
||||
switchMap(() => this.http.post(`${this.baseUrl}/login`, credentials))
|
||||
)
|
||||
}
|
||||
|
||||
public register (credentials: RegisterRequest ){
|
||||
return this.getSanctumCookie().pipe(
|
||||
switchMap(() => this.http.post(`${this.baseUrl}/register`, credentials))
|
||||
switchMap(() => this.http.post(`${this.baseUrl}/login`, credentials)),
|
||||
);
|
||||
}
|
||||
|
||||
public logout(){
|
||||
public register(credentials: RegisterRequest) {
|
||||
return this.getSanctumCookie().pipe(
|
||||
switchMap(() => this.http.post(`${this.baseUrl}/register`, credentials)),
|
||||
);
|
||||
}
|
||||
|
||||
public logout() {
|
||||
return this.http.post(`${this.baseUrl}/logout`, {});
|
||||
}
|
||||
|
||||
public getCurrentUser():Observable<User>
|
||||
{
|
||||
public getCurrentUser(): Observable<User> {
|
||||
return this.http.get<User>(`${this.apiUrl}/me`);
|
||||
}
|
||||
|
||||
private getSanctumCookie(): Observable<null>
|
||||
{
|
||||
public confirmPassword(data: PasswordConfirmationRequest): Observable<any> {
|
||||
return this.getSanctumCookie().pipe(
|
||||
switchMap(() => this.http.post(`${this.baseUrl}/user/confirm-password`, data)),
|
||||
);
|
||||
}
|
||||
|
||||
private getSanctumCookie(): Observable<null> {
|
||||
return this.http.get<null>(`${this.baseUrl}/sanctum/csrf-cookie`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,34 @@
|
||||
import {Routes} from '@angular/router';
|
||||
import { Routes } from '@angular/router';
|
||||
import { PASSWORD_FLOW_FACADE } from '../core/facades/passwordFlow.facade';
|
||||
import { PrivayStore } from '../settings/privacy/privacy.store';
|
||||
import { inject } from '@angular/core';
|
||||
|
||||
export const authRoutes : Routes = [
|
||||
export const authRoutes: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () => import('./login/login').then(m => m.Login),
|
||||
loadComponent: () => import('./login/login').then((m) => m.Login),
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
loadComponent: () => import('./register/register').then(m => m.Register),
|
||||
loadComponent: () => import('./register/register').then((m) => m.Register),
|
||||
},
|
||||
]
|
||||
{
|
||||
path: 'privacy/2fa/confirm-password',
|
||||
loadComponent: () =>
|
||||
import('./password-confirmation/password-confirmation').then((m) => m.PasswordConfirmation),
|
||||
providers: [
|
||||
{
|
||||
provide: PASSWORD_FLOW_FACADE,
|
||||
useFactory: () => {
|
||||
const store = inject(PrivayStore);
|
||||
return {
|
||||
isLoading: store.isLoading,
|
||||
validationErrors: store.validationErrors,
|
||||
action: store.toggleTwoFA,
|
||||
backLink: '/settings',
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { HttpStoreState } from '../core/types/http';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -22,9 +24,11 @@ export interface RegisterRequest {
|
||||
password_confirmation: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
validationErrors: Record<string, string[]> | null;
|
||||
export interface PasswordConfirmationRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthState extends HttpStoreState {
|
||||
user: User | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
@keyframes slideUpFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(40px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(79, 172, 254, 0.4); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(79, 172, 254, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(79, 172, 254, 0); }
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
<div
|
||||
class="w-90 mx-auto mt-27 flex flex-col bg-white/5 backdrop-blur-[20px] border border-white/10 rounded-3xl shadow-[0_25px_50px_-12px_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.1)] overflow-hidden animate-[slideUpFade_0.8s_cubic-bezier(0.16,1,0.3,1)] p-8"
|
||||
>
|
||||
<div class="text-center mb-8">
|
||||
<div
|
||||
class="w-16 h-16 mx-auto mb-4 rounded-full bg-linear-to-br from-[#00f2fe] to-[#4facfe] flex items-center justify-center font-semibold text-2xl shadow-[0_0_20px_rgba(79,172,254,0.4)] animate-[pulse_2s_infinite]"
|
||||
>
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
class="m-0 text-3xl font-semibold tracking-wide bg-linear-to-r from-white to-indigo-300 bg-clip-text text-transparent"
|
||||
>
|
||||
Confirm Password
|
||||
</h1>
|
||||
<p class="m-0 mt-2 text-sm text-slate-400">
|
||||
This is a secure area. Please confirm your password before continuing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<app-validation-errors [errors]="allErrors()" />
|
||||
|
||||
<form class="flex flex-col gap-5">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-sm font-medium text-slate-300 ml-1">Password</label>
|
||||
<div
|
||||
class="flex bg-white/5 px-4 py-3 rounded-xl border border-white/10 transition-all duration-300 focus-within:bg-white/10 focus-within:border-[#4facfe]/50 focus-within:shadow-[0_0_20px_rgba(79,172,254,0.2)]"
|
||||
>
|
||||
<input
|
||||
class="flex-1 bg-transparent border-none text-white text-base outline-none placeholder-slate-500"
|
||||
type="password"
|
||||
[formField]="passwordForm.password"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="confirm()"
|
||||
class="mt-4 bg-linear-to-br from-[#00f2fe] to-[#4facfe] border-none py-3.5 rounded-xl font-semibold text-white text-lg transition-all duration-200 hover:scale-[1.02] hover:-translate-y-0.5 hover:shadow-[0_10px_20px_rgba(79,172,254,0.3)] active:scale-[0.98] disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
|
||||
[disabled]="facade.isLoading() || passwordForm().invalid()"
|
||||
>
|
||||
@if (facade.isLoading()) {
|
||||
<div class="w-full flex justify-center">
|
||||
<app-loader class="w-8!" />
|
||||
</div>
|
||||
} @else { Confirm }
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-8 text-center text-sm text-slate-400">
|
||||
<a
|
||||
[routerLink]="facade.backLink"
|
||||
class="text-[#4facfe] hover:text-[#00f2fe] font-medium transition-colors"
|
||||
>← Back</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { PasswordConfirmation } from './password-confirmation';
|
||||
|
||||
describe('PasswordConfirmation', () => {
|
||||
let component: PasswordConfirmation;
|
||||
let fixture: ComponentFixture<PasswordConfirmation>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PasswordConfirmation],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PasswordConfirmation);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,36 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ValidationErrors } from '../../shared/validation-errors/validation-errors';
|
||||
import { getFormValidationErrors, getMergedValidationErrors } from '../../core/helpers/forms';
|
||||
import { PasswordConfirmationRequest } from '../auth.types';
|
||||
import { form, required, FormField } from '@angular/forms/signals';
|
||||
import { Loader } from '../../shared/loader/loader';
|
||||
import { PASSWORD_FLOW_FACADE } from '../../core/facades/passwordFlow.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-password-confirmation',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, ValidationErrors, Loader, FormField],
|
||||
templateUrl: './password-confirmation.html',
|
||||
styleUrl: './password-confirmation.css',
|
||||
})
|
||||
export class PasswordConfirmation {
|
||||
passwordModel = signal<PasswordConfirmationRequest>({ password: '' });
|
||||
passwordForm = form(this.passwordModel, (schema) => {
|
||||
required(schema.password);
|
||||
});
|
||||
|
||||
private formErrors = signal<Record<string, string[]>>({});
|
||||
protected facade = inject(PASSWORD_FLOW_FACADE);
|
||||
protected readonly allErrors = getMergedValidationErrors(
|
||||
this.formErrors,
|
||||
this.facade.validationErrors,
|
||||
);
|
||||
|
||||
confirm() {
|
||||
if (this.passwordForm().valid()) {
|
||||
this.facade.action(this.passwordForm.password().value());
|
||||
}
|
||||
}
|
||||
}
|
||||
11
frontend/src/app/core/facades/passwordFlow.facade.ts
Normal file
11
frontend/src/app/core/facades/passwordFlow.facade.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { InjectionToken, Signal } from '@angular/core';
|
||||
import { HttpStoreState } from '../types/http';
|
||||
|
||||
export interface PasswordFlowFacade {
|
||||
isLoading: Signal<boolean>;
|
||||
validationErrors: Signal<Record<string, string[]> | null>;
|
||||
action: (password: string) => void;
|
||||
backLink: string;
|
||||
}
|
||||
|
||||
export const PASSWORD_FLOW_FACADE = new InjectionToken<PasswordFlowFacade>('PASSWORD_FLOW_FACADE');
|
||||
@ -230,14 +230,22 @@
|
||||
@if(isCogMenuOpen()){
|
||||
<div
|
||||
id="cog-menu"
|
||||
class="bg-black/9 backdrop-blur-sm text-xs text-gray-400 min-w-30 px-2 py-2 rounded-xl absolute right-0 bottom-4"
|
||||
class="bg-black/9 backdrop-blur-sm shadow-2xl border border-[#2a3a5c] text-xs text-gray-400 min-w-30 px-2 py-2 rounded-xl absolute right-0 bottom-4"
|
||||
>
|
||||
<ul class="">
|
||||
<li>
|
||||
<button class="w-full py-2 rounded-xl hover:bg-white/4 hover:text-red-400">
|
||||
<ul class="space-y-1">
|
||||
<li class="border-b border-[#2a3a5c]">
|
||||
<button class="w-full py-1.5 mb-1 rounded-xl hover:bg-white/4 hover:text-red-400">
|
||||
Logout
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
routerLink="/settings"
|
||||
class="block text-center w-full py-1.5 rounded-xl hover:bg-white/4 hover:text-gray-100"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
16
frontend/src/app/settings/privacy/privacy-service.spec.ts
Normal file
16
frontend/src/app/settings/privacy/privacy-service.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { PrivacyService } from './privacy-service';
|
||||
|
||||
describe('PrivacyService', () => {
|
||||
let service: PrivacyService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(PrivacyService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
20
frontend/src/app/settings/privacy/privacy-service.ts
Normal file
20
frontend/src/app/settings/privacy/privacy-service.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { inject } from '@angular/core/primitives/di';
|
||||
import { BASE_URL } from '../../core/tokens/api-urls';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PrivacyService {
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = inject<string>(BASE_URL);
|
||||
|
||||
public enableTwoFA() {
|
||||
return this.http.get<void>(`${this.baseUrl}/user/two-factor-authentication`);
|
||||
}
|
||||
|
||||
public disableTwoFA() {
|
||||
return this.http.delete<void>(`${this.baseUrl}/user/two-factor-authentication`);
|
||||
}
|
||||
}
|
||||
3
frontend/src/app/settings/privacy/privacy.css
Normal file
3
frontend/src/app/settings/privacy/privacy.css
Normal file
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
27
frontend/src/app/settings/privacy/privacy.html
Normal file
27
frontend/src/app/settings/privacy/privacy.html
Normal file
@ -0,0 +1,27 @@
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<h3>Privacy & Security</h3>
|
||||
<p>Control who can see your information and how your data is used.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-info">
|
||||
<label>Two-Factor Authentication</label>
|
||||
<span class="hint">Add an extra layer of security to your account.</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" (change)="toggle2fa()" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="danger-zone">
|
||||
<h4>Danger Zone</h4>
|
||||
<p>Irreversible actions. Please proceed with caution.</p>
|
||||
<div class="danger-actions">
|
||||
<button class="btn btn-danger-outline" type="button">Delete Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
22
frontend/src/app/settings/privacy/privacy.spec.ts
Normal file
22
frontend/src/app/settings/privacy/privacy.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Privacy } from './privacy';
|
||||
|
||||
describe('Privacy', () => {
|
||||
let component: Privacy;
|
||||
let fixture: ComponentFixture<Privacy>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Privacy],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Privacy);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
49
frontend/src/app/settings/privacy/privacy.store.ts
Normal file
49
frontend/src/app/settings/privacy/privacy.store.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
||||
import { PrivacyState } from './privacy.types';
|
||||
import { Router } from '@angular/router';
|
||||
import { inject } from '@angular/core';
|
||||
import { PrivacyService } from './privacy-service';
|
||||
import { AuthService } from '../../auth/auth-service';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
|
||||
const initialState: PrivacyState = {
|
||||
twoFactor: {
|
||||
enabled: false,
|
||||
qrCode: null,
|
||||
recoveryCodes: [],
|
||||
},
|
||||
validationErrors: null,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
export const PrivayStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState(initialState),
|
||||
withMethods((store) => {
|
||||
const router = inject(Router);
|
||||
const authService = inject(AuthService);
|
||||
|
||||
return {
|
||||
toggleTwoFA: (password: string) => {
|
||||
patchState(store, { isLoading: true });
|
||||
authService.confirmPassword({ password: password }).subscribe({
|
||||
next: () => {
|
||||
const privacyService = inject(PrivacyService);
|
||||
|
||||
if (store.twoFactor().enabled) {
|
||||
privacyService.disableTwoFA();
|
||||
patchState(store, (state) => {
|
||||
|
||||
})
|
||||
}
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
if (error.status === 422) {
|
||||
patchState(store, { validationErrors: error.error.errors, isLoading: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
15
frontend/src/app/settings/privacy/privacy.ts
Normal file
15
frontend/src/app/settings/privacy/privacy.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-privacy',
|
||||
imports: [],
|
||||
templateUrl: './privacy.html',
|
||||
styleUrl: './privacy.css',
|
||||
})
|
||||
export class Privacy {
|
||||
private router = inject(Router);
|
||||
protected toggle2fa() {
|
||||
this.router.navigate(['/user/privacy/2fa/confirm-password']);
|
||||
}
|
||||
}
|
||||
11
frontend/src/app/settings/privacy/privacy.types.ts
Normal file
11
frontend/src/app/settings/privacy/privacy.types.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { HttpStoreState } from '../../core/types/http';
|
||||
|
||||
export interface TwoFactorAuth {
|
||||
enabled: boolean;
|
||||
qrCode: string | null;
|
||||
recoveryCodes: string[];
|
||||
}
|
||||
|
||||
export interface PrivacyState extends HttpStoreState {
|
||||
twoFactor: TwoFactorAuth;
|
||||
}
|
||||
16
frontend/src/app/settings/settings-service.spec.ts
Normal file
16
frontend/src/app/settings/settings-service.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SettingsService } from './settings-service';
|
||||
|
||||
describe('SettingsService', () => {
|
||||
let service: SettingsService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(SettingsService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
6
frontend/src/app/settings/settings-service.ts
Normal file
6
frontend/src/app/settings/settings-service.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SettingsService {}
|
||||
371
frontend/src/app/settings/settings.css
Normal file
371
frontend/src/app/settings/settings.css
Normal file
@ -0,0 +1,371 @@
|
||||
@import 'tailwindcss';
|
||||
/*
|
||||
Based on TailwindCSS recommendations,
|
||||
consider using classes instead of the `@apply` directive
|
||||
@see https://tailwindcss.com/docs/reusing-styles#avoiding-premature-abstraction
|
||||
*/
|
||||
/* ============================================================
|
||||
Settings Layout
|
||||
============================================================ */
|
||||
:host {
|
||||
@apply block w-full h-screen overflow-hidden;
|
||||
}
|
||||
.settings-container {
|
||||
@apply flex w-full h-full;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Sidebar
|
||||
============================================================ */
|
||||
.settings-sidebar {
|
||||
@apply w-80 min-w-[280px] flex flex-col backdrop-blur-[20px] border-r-[rgba(255,255,255,0.06)] border-r border-solid;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.sidebar-header {
|
||||
@apply flex items-center gap-3 pt-7 pb-5 px-6;
|
||||
}
|
||||
.sidebar-header h2 {
|
||||
@apply text-xl font-semibold text-slate-100 m-0;
|
||||
}
|
||||
.back-link {
|
||||
@apply flex items-center justify-center w-[34px] h-[34px] border text-slate-400 no-underline transition-all duration-[0.2s] ease-[ease] rounded-[10px] border-solid border-[rgba(255,255,255,0.08)] hover:text-slate-200 hover:border-[rgba(255,255,255,0.15)];
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.back-link:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.back-icon {
|
||||
@apply w-[18px] h-[18px];
|
||||
}
|
||||
.sidebar-nav {
|
||||
@apply flex flex-col gap-1 flex-1 overflow-y-auto px-3 py-2;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Nav Tabs
|
||||
============================================================ */
|
||||
.nav-tab {
|
||||
@apply flex items-center gap-3 border text-slate-400 cursor-pointer transition-all duration-[0.25s] ease-in-out text-left w-full px-4 py-3.5 rounded-xl border-solid border-transparent hover:text-slate-300;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
}
|
||||
.nav-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.nav-tab.active {
|
||||
@apply text-slate-200 shadow-[0_0_20px_rgba(99,102,241,0.08)] border-[rgba(99,102,241,0.3)];
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.1) 100%);
|
||||
}
|
||||
.nav-tab.active .nav-icon {
|
||||
@apply text-indigo-400;
|
||||
}
|
||||
.nav-icon {
|
||||
@apply w-[22px] h-[22px] shrink-0 transition-[color] duration-[0.2s] ease-[ease];
|
||||
}
|
||||
.nav-tab-text {
|
||||
@apply flex flex-col min-w-0;
|
||||
}
|
||||
.nav-label {
|
||||
@apply text-[0.9rem] font-medium leading-[1.3];
|
||||
}
|
||||
.nav-description {
|
||||
@apply text-[0.72rem] text-slate-500 whitespace-nowrap overflow-hidden text-ellipsis mt-0.5;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Content Area
|
||||
============================================================ */
|
||||
.settings-content {
|
||||
@apply flex-1 flex flex-col overflow-y-auto px-10 py-8;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
||||
}
|
||||
.content-header {
|
||||
@apply flex items-start justify-between mb-8 pb-6 border-b-[rgba(255,255,255,0.06)] border-b border-solid;
|
||||
}
|
||||
.content-header h2 {
|
||||
@apply text-2xl font-semibold text-slate-100 m-0;
|
||||
}
|
||||
.content-header p {
|
||||
@apply text-[0.85rem] text-slate-500 mt-1 mb-0 mx-0;
|
||||
}
|
||||
.content-actions {
|
||||
@apply flex gap-3 shrink-0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tab Panel
|
||||
============================================================ */
|
||||
.tab-panel {
|
||||
@apply animate-[fadeSlideIn_0.35s_cubic-bezier(0.4,0,0.2,1)];
|
||||
}
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Shared Form Styles (inherited by tab components via ::ng-deep)
|
||||
============================================================ */
|
||||
::ng-deep .settings-section {
|
||||
@apply flex flex-col gap-6;
|
||||
}
|
||||
::ng-deep .section-header h3 {
|
||||
@apply text-[1.1rem] font-semibold text-slate-200 m-0;
|
||||
}
|
||||
::ng-deep .section-header p {
|
||||
@apply text-[0.82rem] text-slate-500 mt-1 mb-0 mx-0;
|
||||
}
|
||||
::ng-deep .form-group {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
::ng-deep .form-group > label {
|
||||
@apply text-[0.85rem] font-medium text-slate-300;
|
||||
}
|
||||
::ng-deep .form-input {
|
||||
@apply border text-slate-200 text-[0.9rem] transition-all duration-[0.2s] ease-[ease] w-full box-border px-4 py-[0.7rem] rounded-[10px] border-solid border-[rgba(255,255,255,0.1)] focus:shadow-[0_0_0_3px_rgba(99,102,241,0.1)] focus:border-[rgba(99,102,241,0.5)];
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
::ng-deep .form-input:focus {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
::ng-deep .form-input::placeholder {
|
||||
@apply text-slate-600;
|
||||
}
|
||||
::ng-deep .form-textarea {
|
||||
@apply resize-y min-h-[80px];
|
||||
}
|
||||
::ng-deep .form-select {
|
||||
@apply appearance-none bg-[url("data:image/svg+xml,%3Csvg_xmlns='http://www.w3.org/2000/svg'_width='24'_height='24'_viewBox='0_0_24_24'_fill='%2394a3b8'%3E%3Cpath_d='M7_10l5_5_5-5z'/%3E%3C/svg%3E")] bg-no-repeat bg-[right_0.75rem_center] bg-[18px] pr-10;
|
||||
}
|
||||
::ng-deep .form-select option {
|
||||
@apply text-slate-200;
|
||||
background: #1e293b;
|
||||
}
|
||||
::ng-deep .hint {
|
||||
@apply text-xs text-slate-500;
|
||||
}
|
||||
::ng-deep .char-count {
|
||||
@apply text-[0.72rem] text-slate-600 text-right;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Toggle Switch
|
||||
============================================================ */
|
||||
::ng-deep .toggle-row {
|
||||
@apply flex items-center justify-between border transition-all duration-[0.2s] ease-[ease] px-5 py-4 rounded-xl border-solid border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.1)];
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
::ng-deep .toggle-row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
::ng-deep .toggle-info {
|
||||
@apply flex flex-col gap-0.5;
|
||||
}
|
||||
::ng-deep .toggle-info label {
|
||||
@apply text-[0.9rem] font-medium text-slate-200 cursor-pointer;
|
||||
}
|
||||
::ng-deep .toggle {
|
||||
@apply relative inline-block w-12 h-[26px] shrink-0 cursor-pointer;
|
||||
}
|
||||
::ng-deep .toggle input {
|
||||
@apply opacity-0 w-0 h-0;
|
||||
}
|
||||
::ng-deep .toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 26px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
::ng-deep .toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
::ng-deep .toggle input:checked + .toggle-slider {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
}
|
||||
|
||||
::ng-deep .toggle input:checked + .toggle-slider::before {
|
||||
transform: translateX(22px);
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Option Cards (e.g. Theme, Font Size)
|
||||
============================================================ */
|
||||
::ng-deep .option-cards {
|
||||
@apply flex gap-3 flex-wrap;
|
||||
}
|
||||
::ng-deep .option-card {
|
||||
@apply flex flex-col items-center gap-2 min-w-[100px] border text-slate-400 cursor-pointer transition-all duration-[0.25s] ease-[ease] text-[0.82rem] font-medium px-6 py-5 rounded-[14px] border-solid border-[rgba(255,255,255,0.08)] hover:-translate-y-0.5 hover:border-[rgba(255,255,255,0.12)];
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
font-family: inherit;
|
||||
}
|
||||
::ng-deep .option-card:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
::ng-deep .option-card.active {
|
||||
@apply text-indigo-200 shadow-[0_4px_16px_rgba(99,102,241,0.12)] border-[rgba(99,102,241,0.4)];
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12) 0%, rgba(139, 92, 246, 0.08) 100%);
|
||||
}
|
||||
::ng-deep .option-card-icon {
|
||||
@apply w-7 h-7;
|
||||
}
|
||||
::ng-deep .option-card-icon svg {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Avatar Section
|
||||
============================================================ */
|
||||
::ng-deep .avatar-section {
|
||||
@apply flex items-center gap-5 border p-5 rounded-[14px] border-solid border-[rgba(255,255,255,0.06)];
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
::ng-deep .avatar-preview {
|
||||
@apply w-[72px] h-[72px] overflow-hidden shrink-0 rounded-[50%] border-2 border-solid border-[rgba(99,102,241,0.3)];
|
||||
}
|
||||
::ng-deep .avatar-preview img {
|
||||
@apply w-full h-full object-cover;
|
||||
}
|
||||
::ng-deep .avatar-placeholder {
|
||||
@apply w-full h-full flex items-center justify-center text-indigo-400;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(139, 92, 246, 0.2));
|
||||
}
|
||||
::ng-deep .avatar-placeholder svg {
|
||||
@apply w-9 h-9;
|
||||
}
|
||||
::ng-deep .avatar-actions {
|
||||
@apply flex flex-col gap-[0.4rem];
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Danger Zone
|
||||
============================================================ */
|
||||
::ng-deep .danger-zone {
|
||||
@apply border mt-6 p-6 rounded-[14px] border-solid border-[rgba(239,68,68,0.15)];
|
||||
background: rgba(239, 68, 68, 0.04);
|
||||
}
|
||||
::ng-deep .danger-zone h4 {
|
||||
@apply text-red-300 text-base font-semibold m-0;
|
||||
}
|
||||
::ng-deep .danger-zone > p {
|
||||
@apply text-[0.8rem] text-red-400 opacity-70 mt-1 mb-4 mx-0;
|
||||
}
|
||||
::ng-deep .danger-actions {
|
||||
@apply flex gap-3 flex-wrap;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Buttons
|
||||
============================================================ */
|
||||
.btn,
|
||||
::ng-deep .btn {
|
||||
@apply inline-flex items-center justify-center gap-2 text-[0.85rem] font-medium cursor-pointer transition-all duration-[0.2s] ease-[ease] whitespace-nowrap px-5 py-[0.6rem] rounded-[10px] border-[none];
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply text-[white] shadow-[0_4px_12px_rgba(99,102,241,0.25)] hover:shadow-[0_6px_20px_rgba(99,102,241,0.35)] hover:-translate-y-px disabled:opacity-60 disabled:cursor-not-allowed disabled:shadow-none;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
transform: none;
|
||||
}
|
||||
.btn-outline,
|
||||
::ng-deep .btn-outline {
|
||||
@apply text-slate-400 border border-solid border-[rgba(255,255,255,0.1)];
|
||||
background: transparent;
|
||||
}
|
||||
.btn-outline:hover,
|
||||
::ng-deep .btn-outline:hover {
|
||||
@apply text-slate-200 border-[rgba(255,255,255,0.2)];
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
::ng-deep .btn-danger {
|
||||
@apply text-red-300 border border-solid border-[rgba(239,68,68,0.2)] hover:border-[rgba(239,68,68,0.35)];
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
::ng-deep .btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
::ng-deep .btn-danger-outline {
|
||||
@apply text-red-400 border border-solid border-[rgba(239,68,68,0.25)] hover:border-[rgba(239,68,68,0.4)];
|
||||
background: transparent;
|
||||
}
|
||||
::ng-deep .btn-danger-outline:hover {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Spinner
|
||||
============================================================ */
|
||||
.spinner {
|
||||
@apply inline-block w-4 h-4 animate-[spin_0.6s_linear_infinite] rounded-[50%] border-t-[white] border-2 border-solid border-[rgba(255,255,255,0.3)];
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Toasts
|
||||
============================================================ */
|
||||
.toast {
|
||||
@apply flex items-center gap-[0.6rem] text-[0.85rem] animate-[fadeSlideIn_0.3s_ease] mb-6 px-4 py-3 rounded-[10px];
|
||||
}
|
||||
.toast-icon {
|
||||
@apply w-5 h-5 shrink-0;
|
||||
}
|
||||
.toast-success {
|
||||
@apply border text-green-300 border-solid border-[rgba(34,197,94,0.2)];
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
}
|
||||
.toast-error {
|
||||
@apply border text-red-300 border-solid border-[rgba(239,68,68,0.2)];
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Responsive
|
||||
============================================================ */
|
||||
@media (max-width: 768px) {
|
||||
.settings-container {
|
||||
@apply flex-col;
|
||||
}
|
||||
.settings-sidebar {
|
||||
@apply w-full min-w-[unset] max-h-[40vh] border-r-[none] border-b-[rgba(255,255,255,0.06)] border-b border-solid;
|
||||
}
|
||||
.nav-description {
|
||||
@apply hidden;
|
||||
}
|
||||
.settings-content {
|
||||
@apply p-6;
|
||||
}
|
||||
.content-header {
|
||||
@apply flex-col gap-4;
|
||||
}
|
||||
::ng-deep .option-cards {
|
||||
@apply flex-col;
|
||||
}
|
||||
}
|
||||
71
frontend/src/app/settings/settings.html
Normal file
71
frontend/src/app/settings/settings.html
Normal file
@ -0,0 +1,71 @@
|
||||
<div class="settings-container">
|
||||
<!-- Left sidebar with tabs -->
|
||||
<aside class="settings-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<a routerLink="/" class="back-link">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="back-icon">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
</a>
|
||||
<h2>Settings</h2>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
@for (tab of tabs; track tab.id) {
|
||||
<button
|
||||
class="nav-tab"
|
||||
[class.active]="store.activeTabId() === tab.id"
|
||||
(click)="store.setActiveTab(tab.id)"
|
||||
type="button"
|
||||
[attr.id]="'settings-tab-' + tab.id"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="nav-icon">
|
||||
<path [attr.d]="tab.icon" />
|
||||
</svg>
|
||||
<div class="nav-tab-text">
|
||||
<span class="nav-label">{{ tab.label }}</span>
|
||||
<span class="nav-description">{{ tab.description }}</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Right content area -->
|
||||
<main class="settings-content">
|
||||
<div class="content-header">
|
||||
<div>
|
||||
<h2>{{ store.activeTab().label }}</h2>
|
||||
<p>{{ store.activeTab().description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success / Error toast -->
|
||||
@if (store.saveSuccess() === true) {
|
||||
<div class="toast toast-success">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="toast-icon">
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"
|
||||
/>
|
||||
</svg>
|
||||
Settings saved successfully!
|
||||
</div>
|
||||
} @if (store.error()) {
|
||||
<div class="toast toast-error">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="toast-icon">
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
|
||||
/>
|
||||
</svg>
|
||||
{{ store.error() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Tab panels -->
|
||||
<div class="tab-panel">
|
||||
@switch (store.activeTabId()) { @case ('privacy') {
|
||||
<app-privacy />
|
||||
}}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
22
frontend/src/app/settings/settings.spec.ts
Normal file
22
frontend/src/app/settings/settings.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Settings } from './settings';
|
||||
|
||||
describe('Settings', () => {
|
||||
let component: Settings;
|
||||
let fixture: ComponentFixture<Settings>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Settings],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Settings);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
46
frontend/src/app/settings/settings.store.ts
Normal file
46
frontend/src/app/settings/settings.store.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';
|
||||
import { computed } from '@angular/core';
|
||||
import { SettingsState, SETTINGS_TABS } from './settings.types';
|
||||
|
||||
const initialState: SettingsState = {
|
||||
activeTabId: 'privacy',
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
saveSuccess: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const SettingsStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState(initialState),
|
||||
withComputed((store) => ({
|
||||
activeTab: computed(() => {
|
||||
return SETTINGS_TABS.find((tab) => tab.id === store.activeTabId()) ?? SETTINGS_TABS[0];
|
||||
}),
|
||||
tabs: computed(() => SETTINGS_TABS),
|
||||
})),
|
||||
withMethods((store) => ({
|
||||
setActiveTab(tabId: string) {
|
||||
patchState(store, { activeTabId: tabId, saveSuccess: null, error: null });
|
||||
},
|
||||
async saveSettings() {
|
||||
patchState(store, { isSaving: true, saveSuccess: null, error: null });
|
||||
try {
|
||||
// Simulate API call — replace with actual SettingsService call
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
patchState(store, { isSaving: false, saveSuccess: true });
|
||||
|
||||
// Auto-dismiss success message
|
||||
setTimeout(() => {
|
||||
patchState(store, { saveSuccess: null });
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
patchState(store, {
|
||||
isSaving: false,
|
||||
saveSuccess: false,
|
||||
error: err?.message ?? 'Failed to save settings.',
|
||||
});
|
||||
}
|
||||
},
|
||||
})),
|
||||
);
|
||||
17
frontend/src/app/settings/settings.ts
Normal file
17
frontend/src/app/settings/settings.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { SettingsStore } from './settings.store';
|
||||
import { SETTINGS_TABS } from './settings.types';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Privacy } from "./privacy/privacy";
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
standalone: true,
|
||||
imports: [RouterLink, Privacy],
|
||||
templateUrl: './settings.html',
|
||||
styleUrl: './settings.css',
|
||||
})
|
||||
export class Settings {
|
||||
protected readonly store = inject(SettingsStore);
|
||||
protected readonly tabs = SETTINGS_TABS;
|
||||
}
|
||||
25
frontend/src/app/settings/settings.types.ts
Normal file
25
frontend/src/app/settings/settings.types.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export interface SettingsTab {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const SETTINGS_TABS: SettingsTab[] = [
|
||||
{
|
||||
id: 'privacy',
|
||||
label: 'Privacy & Security',
|
||||
icon: 'M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z',
|
||||
description: 'Manage security and data privacy',
|
||||
},
|
||||
];
|
||||
|
||||
// --- Store state ---
|
||||
|
||||
export interface SettingsState {
|
||||
activeTabId: string;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
saveSuccess: boolean | null;
|
||||
error: string | null;
|
||||
}
|
||||
@ -12,7 +12,7 @@ body {
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background: radial-gradient(circle at top right, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%);
|
||||
}
|
||||
button:active {
|
||||
transform: scale(0.98);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user