feat(frontend): add mobile-first Material redesign (#17)

This commit is contained in:
nessar committed 2026-08-17 14:18:14 +02:00
1 parent fd3fc0123a
commit 1a70bcd126
15 files changed
+1781 -721

No files matched your search

+842 -503
View File
File diff suppressed because it is too large. Load diff
+442 -180
View File
@@ -1,208 +1,470 @@
<div class="app-container">
<div class="background-decorations">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
</div>
<main class="main-card">
<header class="card-header">
<div class="logo-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="logo-icon">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</div>
<h1>LMNP Organizer</h1>
<p class="subtitle">Votre assistant de gestion et fiscalité immobilière</p>
</header>
<section class="card-body">
@if (!isLoggedIn) {
<div class="auth-state locked">
<div class="shield-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<h2>Connexion Sécurisée Requise</h2>
<p>Connectez-vous avec Keycloak / voidAuth pour accéder à votre espace de gestion locative.</p>
<button (click)="login()" class="btn btn-primary btn-glow">
<span>Se connecter avec OIDC</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="btn-arrow">
<path d="M5 12h14"/>
<path d="m12 5 7 7-7 7"/>
</svg>
</button>
@if (!isLoggedIn) {
<main class="sign-in-shell">
<section class="sign-in-panel">
<div class="brand-lockup">
<app-brand-mark class="brand-mark" />
<div>
<h1>LMNP Organizer</h1>
<p>Gestion locative & fiscalité</p>
</div>
} @else {
<div class="auth-state unlocked">
<!-- Multi-Tab Navigation Menu -->
<nav class="tab-nav">
<button (click)="activeTab.set('dashboard')" [class.active]="activeTab() === 'dashboard'" class="tab-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tab-icon">
<rect width="7" height="9" x="3" y="3" rx="1"/>
<rect width="7" height="5" x="14" y="3" rx="1"/>
<rect width="7" height="9" x="14" y="12" rx="1"/>
<rect width="7" height="5" x="3" y="16" rx="1"/>
</svg>
<span>Tableau de Bord</span>
</button>
<button (click)="activeTab.set('apartments')" [class.active]="activeTab() === 'apartments'" class="tab-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tab-icon">
<path d="M2 22h20"/>
<path d="M4 22V4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v18"/>
<path d="M12 18h.01"/>
<path d="M8 6h2"/>
<path d="M14 6h2"/>
<path d="M8 10h2"/>
<path d="M14 10h2"/>
<path d="M8 14h2"/>
<path d="M14 14h2"/>
</svg>
<span>Appartements</span>
</button>
</nav>
</div>
<!-- Tab Content: Dashboard -->
@if (activeTab() === 'dashboard') {
<div class="tab-pane">
<div class="user-profile">
<div class="avatar">
{{ username.charAt(0).toUpperCase() }}
<div class="sign-in-copy">
<span class="context-label">Espace bailleur</span>
<h2>Votre gestion locative, enfin lisible.</h2>
<p>
Pilotez vos appartements et gardez vos démarches LMNP au même endroit, sur mobile comme
sur ordinateur.
</p>
</div>
<button mat-flat-button class="primary-action sign-in-action" (click)="login()">
Se connecter avec OIDC
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</button>
<p class="security-note">
<span aria-hidden="true">●</span>
Connexion sécurisée via OpenID Connect
</p>
</section>
<aside class="sign-in-story" aria-label="Aperçu du produit">
<div class="story-card">
<p class="story-kicker">Pensé pour aller à l'essentiel</p>
<h2>Un espace calme pour vos décisions de bailleur.</h2>
<div class="story-list">
<div>
<span>01</span>
<p>Vos biens accessibles en quelques gestes.</p>
</div>
<div>
<span>02</span>
<p>Une interface adaptée à chaque écran.</p>
</div>
<div>
<span>03</span>
<p>Vos outils LMNP réunis progressivement.</p>
</div>
</div>
</div>
</aside>
</main>
} @else {
<div class="workspace-shell">
<aside class="app-navigation" data-testid="primary-navigation">
<div class="sidebar-brand">
<app-brand-mark class="brand-mark" />
<div>
<strong>LMNP</strong>
<span>Organizer</span>
</div>
</div>
<nav class="navigation-items" aria-label="Navigation principale">
<button
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'dashboard'"
[attr.aria-current]="activeTab() === 'dashboard' ? 'page' : null"
(click)="activeTab.set('dashboard')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<rect x="3" y="3" width="7" height="7" rx="2" />
<rect x="14" y="3" width="7" height="7" rx="2" />
<rect x="3" y="14" width="7" height="7" rx="2" />
<rect x="14" y="14" width="7" height="7" rx="2" />
</svg>
<span>Tableau de bord</span>
</button>
<button
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'apartments'"
[attr.aria-current]="activeTab() === 'apartments' ? 'page' : null"
(click)="activeTab.set('apartments')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="M4 21V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16" />
<path d="M2 21h20M8 7h2M14 7h2M8 11h2M14 11h2M8 15h2M14 15h2" />
</svg>
<span>Appartements</span>
</button>
<button
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'settings'"
[attr.aria-current]="activeTab() === 'settings' ? 'page' : null"
(click)="activeTab.set('settings')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21h-4v-.09A1.7 1.7 0 0 0 8.6 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H3v-4h.09A1.7 1.7 0 0 0 4.6 8.6a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.1V3h4v.09A1.7 1.7 0 0 0 15.4 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 9c.16.38.37.72.6 1 .3.3.7.45 1.1.45h.09v4h-.09c-.4 0-.8.15-1.1.45-.23.28-.44.62-.6 1Z"
/>
</svg>
<span>Réglages</span>
</button>
</nav>
<div class="sidebar-user">
<div class="compact-avatar">{{ userInitial() }}</div>
<div>
<strong>{{ username }}</strong>
<span>{{ email }}</span>
</div>
<button mat-button type="button" class="logout-action" (click)="logout()">
Déconnexion
</button>
</div>
</aside>
<main class="workspace-main">
<header class="mobile-header">
<div class="mobile-brand">
<app-brand-mark class="brand-mark" />
<strong>LMNP Organizer</strong>
</div>
<div class="compact-avatar">{{ userInitial() }}</div>
</header>
<div class="page-container">
@if (activeTab() === 'dashboard') {
<section class="page-section dashboard-page">
<header class="page-heading">
<div>
<p class="eyebrow">Bonjour {{ username }}</p>
<h1>Tableau de bord</h1>
<p>Retrouvez rapidement les actions utiles à votre gestion locative.</p>
</div>
<span class="status-pill"><span></span>Espace sécurisé</span>
</header>
<div class="dashboard-grid">
<article class="feature-card feature-card-primary">
<div class="feature-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M4 21V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16M2 21h20" />
<path d="M8 8h8M8 12h8M8 16h4" />
</svg>
</div>
<div>
<p class="eyebrow">Votre portefeuille</p>
<h2>
{{ apartments().length }} appartement{{ apartments().length > 1 ? 's' : '' }}
</h2>
<p>Centralisez les informations essentielles de vos biens.</p>
</div>
<button mat-button type="button" (click)="activeTab.set('apartments')">
Gérer mes appartements
</button>
</article>
<article class="feature-card user-profile">
<div class="large-avatar">{{ userInitial() }}</div>
<div class="user-details">
<p class="eyebrow">Compte actif</p>
<h3>{{ username }}</h3>
<p class="user-email">{{ email }}</p>
<span class="badge badge-success">Bailleur Connecté</span>
<span class="role-chip">Bailleur connecté</span>
</div>
</div>
</article>
<div class="api-demo-section">
<h4>Vérification API Sécurisée</h4>
<p>Testez la transmission et la validation du jeton JWT auprès du backend Java 25.</p>
<article class="feature-card api-demo-section">
<div>
<p class="eyebrow">État du service</p>
<h2>Connexion au serveur</h2>
<p>Vérifiez que votre espace communique correctement avec le service sécurisé.</p>
</div>
<div class="api-actions">
<button (click)="callHelloApi()" class="btn btn-secondary">
Appeler /api/hello
<button mat-stroked-button type="button" (click)="callHelloApi()">
Tester la connexion
</button>
</div>
@if (helloMessage()) {
<div class="api-response-wrapper">
<span class="response-label">Réponse du backend :</span>
<span>Réponse du serveur</span>
<code class="api-response-code">{{ helloMessage() }}</code>
</div>
}
</div>
</article>
</div>
}
</section>
}
<!-- Tab Content: Apartments -->
@if (activeTab() === 'apartments') {
<div class="tab-pane">
@if (!showForm()) {
<div class="section-header">
<h2>Mon Portefeuille de Biens</h2>
<button (click)="openAddForm()" class="btn btn-primary btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="btn-icon">
<path d="M5 12h14"/>
<path d="M12 5v14"/>
</svg>
<span>Ajouter un appartement</span>
</button>
@if (activeTab() === 'apartments') {
<section class="page-section apartments-page">
@if (!showForm()) {
<header class="page-heading">
<div>
<p class="eyebrow">Portefeuille</p>
<h1>Mes appartements</h1>
<p>Les biens rattachés à votre activité LMNP.</p>
</div>
<button
mat-flat-button
type="button"
class="primary-action"
(click)="openAddForm()"
>
<span class="button-plus" aria-hidden="true">+</span>
Ajouter un appartement
</button>
</header>
@if (apartments().length === 0) {
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="empty-icon">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
@if (apartments().length === 0) {
<article class="empty-state">
<div class="empty-illustration" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="m4 10 8-6 8 6v10H4z" />
<path d="M9 20v-6h6v6" />
</svg>
<h3>Aucun appartement enregistré</h3>
<p>Commencez par ajouter votre premier bien immobilier LMNP à votre portefeuille.</p>
</div>
} @else {
<div class="apartments-grid">
@for (apt of apartments(); track apt.id) {
<div class="apartment-card">
<div class="apt-info">
<h3>{{ apt.name }}</h3>
<p class="apt-address">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-icon">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
{{ apt.address }}
</p>
<p class="apt-price">{{ apt.purchasePrice.toLocaleString('fr-FR') }} &euro;</p>
@if (apt.description) {
<p class="apt-desc">{{ apt.description }}</p>
}
</div>
<div class="apt-actions">
<button (click)="openEditForm(apt)" class="btn btn-outline btn-xs btn-warning">
Modifier
</button>
@if (apt.id) {
<button (click)="deleteApartment(apt.id)" class="btn btn-outline btn-xs btn-danger">
Supprimer
</button>
}
</div>
</div>
}
</div>
}
<p class="eyebrow">Commencez ici</p>
<h2>Aucun appartement enregistré</h2>
<p>Ajoutez votre premier bien pour construire votre portefeuille.</p>
<button mat-stroked-button type="button" (click)="openAddForm()">
Ajouter un appartement
</button>
</article>
} @else {
<div class="form-container">
<h2>{{ editingApartment() ? 'Modifier l\'appartement' : 'Ajouter un appartement' }}</h2>
@if (formError) {
<div class="alert alert-error">
{{ formError }}
</div>
<div class="apartments-grid">
@for (apt of apartments(); track apt.id) {
<article class="apartment-card">
<div class="apartment-card-topline">
<span class="property-label">Bien immobilier</span>
<span class="property-value"
>{{ apt.purchasePrice.toLocaleString('fr-FR') }} €</span
>
</div>
<div class="apt-info">
<h2>{{ apt.name }}</h2>
<p class="apt-address">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="M20 10c0 5-8 11-8 11S4 15 4 10a8 8 0 1 1 16 0Z" />
<circle cx="12" cy="10" r="2.5" />
</svg>
{{ apt.address }}
</p>
@if (apt.description) {
<p class="apt-desc">{{ apt.description }}</p>
}
</div>
<div class="apt-actions">
<button mat-button type="button" (click)="openEditForm(apt)">
Modifier
</button>
@if (apt.id) {
<button
mat-button
type="button"
class="danger-action"
(click)="deleteApartment(apt.id)"
>
Supprimer
</button>
}
</div>
</article>
}
<form (submit)="saveApartment(); $event.preventDefault()" class="crud-form">
<div class="form-group">
<label for="aptName">Nom de l'appartement *</label>
<input type="text" id="aptName" name="name" [(ngModel)]="formName" placeholder="Ex. T2 Cosy Paris 15" required>
</div>
<div class="form-group">
<label for="aptAddress">Adresse complète *</label>
<input type="text" id="aptAddress" name="address" [(ngModel)]="formAddress" placeholder="Ex. 45 rue des Entrepreneurs, 75015 Paris" required>
</div>
<div class="form-group">
<label for="aptPrice">Prix d'acquisition (&euro;) *</label>
<input type="number" id="aptPrice" name="purchasePrice" [(ngModel)]="formPurchasePrice" min="1" placeholder="Ex. 180000" required>
</div>
<div class="form-group">
<label for="aptDesc">Description / Notes</label>
<textarea id="aptDesc" name="description" [(ngModel)]="formDescription" rows="3" placeholder="Ex. T2 en rez-de-chaussée, meublé, cuisine équipée."></textarea>
</div>
<div class="form-actions">
<button type="button" (click)="closeForm()" class="btn btn-outline">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer</button>
</div>
</form>
</div>
}
</div>
}
} @else {
<div class="form-page">
<header class="page-heading form-heading">
<div>
<button mat-button type="button" class="back-action" (click)="closeForm()">
← Retour
</button>
<p class="eyebrow">Portefeuille</p>
<h1>
{{ editingApartment() ? "Modifier l'appartement" : 'Ajouter un appartement' }}
</h1>
<p>Renseignez les informations principales du bien.</p>
</div>
</header>
<button (click)="logout()" class="btn btn-outline btn-logout">
Se déconnecter
</button>
</div>
}
</section>
<form (submit)="saveApartment(); $event.preventDefault()" class="apartment-form">
@if (formError) {
<div class="alert-error" role="alert">{{ formError }}</div>
}
<footer class="card-footer">
<p>LMNP Organizer &copy; 2026. Sécurisé avec OpenID Connect et Spring Boot 4.</p>
</footer>
</main>
</div>
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-label>Nom de l'appartement</mat-label>
<input
matInput
type="text"
name="name"
[(ngModel)]="formName"
placeholder="T2 Paris 15"
required
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Prix d'acquisition</mat-label>
<input
matInput
type="number"
name="purchasePrice"
[(ngModel)]="formPurchasePrice"
min="1"
required
/>
<span matTextSuffix>€</span>
</mat-form-field>
<mat-form-field appearance="outline" class="full-field">
<mat-label>Adresse complète</mat-label>
<input
matInput
type="text"
name="address"
[(ngModel)]="formAddress"
placeholder="45 rue des Entrepreneurs, 75015 Paris"
required
/>
</mat-form-field>
<mat-form-field appearance="outline" class="full-field">
<mat-label>Description ou notes</mat-label>
<textarea
matInput
name="description"
[(ngModel)]="formDescription"
rows="4"
placeholder="Étage, agencement, équipements…"
></textarea>
</mat-form-field>
</div>
<div class="form-actions">
<button mat-button type="button" (click)="closeForm()">Annuler</button>
<button mat-flat-button type="submit" class="primary-action">
Enregistrer
</button>
</div>
</form>
</div>
}
</section>
}
@if (activeTab() === 'settings') {
<section class="page-section settings-page">
<header class="page-heading">
<div>
<p class="eyebrow">Préférences</p>
<h1>Réglages</h1>
<p>Adaptez LMNP Organizer à votre façon de travailler.</p>
</div>
</header>
<article class="settings-card" aria-labelledby="appearance-heading">
<div class="settings-copy">
<span class="settings-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 3a9 9 0 1 0 9 9c0-.5-.04-1-.12-1.47A7 7 0 0 1 12 3Z" />
</svg>
</span>
<div>
<p class="eyebrow">Interface</p>
<h2 id="appearance-heading">Apparence</h2>
<p>Le réglage est mémorisé uniquement sur cet appareil.</p>
</div>
</div>
<mat-radio-group
class="theme-options"
aria-label="Thème de l'application"
[value]="themePreference()"
(change)="themeService.setPreference($event.value)"
>
<mat-radio-button value="light">
<span class="theme-option-copy"
><strong>Clair</strong><small>Une interface lumineuse et douce.</small></span
>
</mat-radio-button>
<mat-radio-button value="dark">
<span class="theme-option-copy"
><strong>Sombre</strong
><small>Un contraste réduit en faible lumière.</small></span
>
</mat-radio-button>
<mat-radio-button value="system">
<span class="theme-option-copy"
><strong>Système</strong
><small>Suit automatiquement votre appareil.</small></span
>
</mat-radio-button>
</mat-radio-group>
</article>
<article
class="settings-card session-settings"
data-testid="session-settings"
aria-labelledby="session-heading"
>
<div class="settings-copy">
<span class="settings-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</svg>
</span>
<div>
<p class="eyebrow">Compte</p>
<h2 id="session-heading">Session</h2>
<p>Gérez la session ouverte sur cet appareil.</p>
</div>
</div>
<div class="session-action">
<div>
<strong>{{ username }}</strong>
<span>{{ email }}</span>
</div>
<button mat-stroked-button type="button" (click)="logout()">Déconnexion</button>
</div>
</article>
</section>
}
</div>
</main>
</div>
}
+44 -26
View File
@@ -1,20 +1,33 @@
import { Component, signal, inject, OnInit, effect } from '@angular/core';
import { Component, computed, signal, inject, OnInit, effect } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ApartmentService, Apartment } from './apartment.service';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio';
import { ThemeService } from './theme.service';
import { BrandMark } from './brand-mark';
@Component({
selector: 'app-root',
imports: [FormsModule],
imports: [
BrandMark,
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatRadioModule,
],
templateUrl: './app.html',
styleUrl: './app.css'
styleUrl: './app.css',
})
export class App implements OnInit {
protected readonly title = signal('LMNP Organizer');
readonly helloMessage = signal<string>('');
// Tab & CRUD States
readonly activeTab = signal<'dashboard' | 'apartments'>('dashboard');
readonly activeTab = signal<'dashboard' | 'apartments' | 'settings'>('dashboard');
readonly apartments = signal<Apartment[]>([]);
readonly showForm = signal<boolean>(false);
readonly editingApartment = signal<Apartment | null>(null);
@@ -29,9 +42,12 @@ export class App implements OnInit {
private readonly _isLoggedIn = signal<boolean>(false);
private readonly _username = signal<string>('');
private readonly _email = signal<string>('');
readonly userInitial = computed(() => this._username().charAt(0).toUpperCase());
private readonly http = inject(HttpClient);
private readonly apartmentService = inject(ApartmentService);
protected readonly themeService = inject(ThemeService);
readonly themePreference = this.themeService.preference;
constructor() {
effect(() => {
@@ -42,25 +58,27 @@ export class App implements OnInit {
}
ngOnInit() {
this.http.get<{ authenticated: boolean, username?: string, email?: string }>('/api/user').subscribe({
next: (user) => {
if (user.authenticated) {
this._isLoggedIn.set(true);
this._username.set(user.username || 'User');
this._email.set(user.email || '');
} else {
this.http
.get<{ authenticated: boolean; username?: string; email?: string }>('/api/user')
.subscribe({
next: (user) => {
if (user.authenticated) {
this._isLoggedIn.set(true);
this._username.set(user.username || 'User');
this._email.set(user.email || '');
} else {
this._isLoggedIn.set(false);
this._username.set('');
this._email.set('');
}
},
error: (err) => {
console.error('Error fetching user authentication status', err);
this._isLoggedIn.set(false);
this._username.set('');
this._email.set('');
}
},
error: (err) => {
console.error('Error fetching user authentication status', err);
this._isLoggedIn.set(false);
this._username.set('');
this._email.set('');
}
});
},
});
}
get isLoggedIn(): boolean {
@@ -89,7 +107,7 @@ export class App implements OnInit {
error: (err) => {
console.error(err);
this.helloMessage.set('Failed to connect to backend: ' + err.statusText);
}
},
});
}
@@ -97,7 +115,7 @@ export class App implements OnInit {
loadApartments() {
this.apartmentService.getApartments().subscribe({
next: (data) => this.apartments.set(data),
error: (err) => console.error('Error loading apartments', err)
error: (err) => console.error('Error loading apartments', err),
});
}
@@ -136,7 +154,7 @@ export class App implements OnInit {
name: this.formName.trim(),
address: this.formAddress.trim(),
purchasePrice: this.formPurchasePrice,
description: this.formDescription.trim()
description: this.formDescription.trim(),
};
const editMode = this.editingApartment();
@@ -149,7 +167,7 @@ export class App implements OnInit {
error: (err) => {
console.error(err);
this.formError = 'Erreur lors de la modification.';
}
},
});
} else {
this.apartmentService.createApartment(apartmentData).subscribe({
@@ -160,7 +178,7 @@ export class App implements OnInit {
error: (err) => {
console.error(err);
this.formError = 'Erreur lors de la création.';
}
},
});
}
}
@@ -169,7 +187,7 @@ export class App implements OnInit {
if (confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?')) {
this.apartmentService.deleteApartment(id).subscribe({
next: () => this.loadApartments(),
error: (err) => console.error('Error deleting apartment', err)
error: (err) => console.error('Error deleting apartment', err),
});
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-brand-mark',
template: `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="m4 10 8-6 8 6v9a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z" />
<path d="M9 20v-6h6v6" />
</svg>
`,
styles: `
:host {
display: grid;
place-items: center;
}
svg {
width: 55%;
height: 55%;
}
`,
host: { 'aria-hidden': 'true' },
})
export class BrandMark {}
+45
View File
@@ -0,0 +1,45 @@
import { DOCUMENT } from '@angular/common';
import { effect, inject, Injectable, signal } from '@angular/core';
export type ThemePreference = 'light' | 'dark' | 'system';
const THEME_STORAGE_KEY = 'lmnp-theme';
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly document = inject(DOCUMENT);
private readonly colorSchemeQuery =
typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-color-scheme: dark)')
: null;
private readonly systemUsesDark = signal(this.colorSchemeQuery?.matches ?? false);
readonly preference = signal<ThemePreference>(this.readPreference());
constructor() {
this.colorSchemeQuery?.addEventListener('change', (event) =>
this.systemUsesDark.set(event.matches),
);
effect(() => {
const preference = this.preference();
const resolvedTheme =
preference === 'system' ? (this.systemUsesDark() ? 'dark' : 'light') : preference;
localStorage.setItem(THEME_STORAGE_KEY, preference);
this.document.documentElement.dataset['theme'] = resolvedTheme;
this.document.documentElement.dataset['themePreference'] = preference;
});
}
setPreference(preference: ThemePreference): void {
this.preference.set(preference);
}
private readPreference(): ThemePreference {
const storedPreference = localStorage.getItem(THEME_STORAGE_KEY);
return storedPreference === 'dark' || storedPreference === 'system'
? storedPreference
: 'light';
}
}
-4
View File
@@ -1,4 +0,0 @@
/* You can add global styles to this file, and also import other style files */
body {
margin: 0;
}
+95
View File
@@ -0,0 +1,95 @@
@use '@angular/material' as mat;
@include mat.core();
html {
@include mat.theme(
(
color: (
primary: mat.$cyan-palette,
tertiary: mat.$azure-palette,
theme-type: color-scheme,
),
typography: (
Inter,
ui-sans-serif,
system-ui,
sans-serif,
),
density: 0,
)
);
color-scheme: light;
--canvas: #f4f6f2;
--surface: #ffffff;
--surface-raised: #ffffff;
--surface-soft: #e9efeb;
--surface-accent: #dcefeb;
--text: #17211e;
--text-muted: #687670;
--text-subtle: #8c9994;
--accent: #0f766e;
--accent-strong: #0a5f59;
--accent-contrast: #ffffff;
--border: #dce5df;
--border-strong: #c7d3cd;
--danger: #b42318;
--danger-soft: #fee9e7;
--shadow: 0 24px 60px rgba(28, 49, 42, 0.09);
}
html[data-theme='dark'] {
color-scheme: dark;
--canvas: #101513;
--surface: #171d1b;
--surface-raised: #1b2320;
--surface-soft: #202a26;
--surface-accent: #173a36;
--text: #f0f5f2;
--text-muted: #a7b5af;
--text-subtle: #7f8e88;
--accent: #63c7bc;
--accent-strong: #86d7cf;
--accent-contrast: #08201d;
--border: #2c3733;
--border-strong: #3a4944;
--danger: #ffb4ab;
--danger-soft: #4a2421;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.3);
}
* {
box-sizing: border-box;
}
html,
body {
min-width: 320px;
min-height: 100%;
margin: 0;
overflow-x: hidden;
}
body {
background: var(--canvas);
color: var(--text);
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
text-rendering: optimizeLegibility;
transition:
background-color 180ms ease,
color 180ms ease;
}
button,
input,
textarea {
font: inherit;
}