diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..83dc009 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,39 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +npm start # Dev server at http://localhost:4200/ (live reload) +npm run build # Production build +npm run watch # Build with watch mode (development config) +npm test # Run unit tests (Jasmine/Karma in Chrome) +``` + +No linter is configured in this project. + +## Architecture + +Angular 18 single-page app for managing calendar events for the "Nachklang" organization. Uses Angular Material for UI, RxJS for async data, and reactive forms. No NgRx — state lives in component local variables. + +**Environments:** +- Dev: `http://localhost:3000` (expects backend running locally) +- Prod: `https://api.nachklang.art` + +**Routing** (`app.routing.ts`): +- `/` → `LandingpageComponent` +- `/admin` → `AdminComponent` (main calendar management page) +- `**` → `NotfoundComponent` + +**Service layer** (`src/app/services/`): +- `api.service.ts` — all HTTP calls to the backend REST API; session credentials are passed as query params (`sessionId`, `sessionKey`) +- `utils.service.ts` — localStorage helpers for persisting session and user data + +**Data models** (`src/app/models/`): `Event`, `User`, `Session` + +**Admin page** (`src/app/pages/admin/`) is the core of the app. It owns events state and passes it down to `EventsTableComponent` via `@Input()`. Key features: multi-calendar support (public, members, choir, management, birthdays), event filtering (future/past/all), sorting, and an event-move dialog (`EventMovePopupComponent` via Angular Material Dialog). + +**Calendar-specific behavior:** Birthday calendar auto-sets recurrence to YEARLY. Events have a `status` field (`DRAFT` / `DELETED`). + +**Session lifecycle:** `checkSession()` is called on `AdminComponent` init; on failure it redirects to `/`. diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 5e230c9..6929dd6 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,4 +1,4 @@ -import {ComponentRef, NgModule} from '@angular/core'; +import {NgModule} from '@angular/core'; import {BrowserModule} from '@angular/platform-browser'; import {AppComponent} from './app.component'; diff --git a/src/app/components/event-move-popup/event-move-popup.component.ts b/src/app/components/event-move-popup/event-move-popup.component.ts index 1816989..4d2321e 100644 --- a/src/app/components/event-move-popup/event-move-popup.component.ts +++ b/src/app/components/event-move-popup/event-move-popup.component.ts @@ -1,7 +1,6 @@ import {Component, Inject} from '@angular/core'; import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog"; import {Event} from "../../models/event"; -import {log} from "@angular-devkit/build-angular/src/builders/ssr-dev-server"; @Component({ selector: 'app-event-move-popup', @@ -10,7 +9,7 @@ import {log} from "@angular-devkit/build-angular/src/builders/ssr-dev-server"; }) export class EventMovePopupComponent { selectedCalendar = 'public'; - calendars = ['', 'public', 'members', 'management', 'choir']; + calendars = ['', 'public', 'members', 'management', 'choir', 'birthdays']; constructor( public dialogRef: MatDialogRef, diff --git a/src/app/components/event/event.component.ts b/src/app/components/event/event.component.ts index 6e9a2e3..da13ed8 100644 --- a/src/app/components/event/event.component.ts +++ b/src/app/components/event/event.component.ts @@ -1,5 +1,7 @@ -import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; -import {MatDialog, MatDialogRef} from '@angular/material/dialog'; +import {Component, EventEmitter, Input, OnDestroy, OnInit, Output} from '@angular/core'; +import {Subject} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; +import {MatDialog} from '@angular/material/dialog'; import {Event} from '../../models/event'; import {ApiService} from '../../services/api.service'; import {EventMovePopupComponent} from "../event-move-popup/event-move-popup.component"; @@ -9,7 +11,9 @@ import {EventMovePopupComponent} from "../event-move-popup/event-move-popup.comp templateUrl: './event.component.html', styleUrls: ['./event.component.css'] }) -export class EventComponent implements OnInit { +export class EventComponent implements OnInit, OnDestroy { + + private destroy$ = new Subject(); @Input() event: Event | undefined; @Input() editActive: boolean = false; @@ -38,6 +42,11 @@ export class EventComponent implements OnInit { } } + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + toggleEdit() { if (this.editActive && this.event !== undefined) { // Prevent save if endDateTime is before startDateTime @@ -62,7 +71,7 @@ export class EventComponent implements OnInit { } if(this.event.eventId === undefined) { - this.api.createEvent(this.event).subscribe((res: any) => { + this.api.createEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { console.log(res); if(res.eventId) { @@ -74,7 +83,7 @@ export class EventComponent implements OnInit { }); } else { // Update existing event - this.api.updateEvent(this.event).subscribe((res: any) => { + this.api.updateEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { console.log(res); }); } @@ -171,7 +180,7 @@ export class EventComponent implements OnInit { let deleteConfirmed = window.confirm(`Are you sure you want to delete "${this.event!.name}"? This action cannot be undone.`); if(deleteConfirmed && this.event) { - this.api.deleteEvent(this.event).subscribe((res: any) => { + this.api.deleteEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { console.log(res); if(res.message) { this.deleteEvent.next(this.event!.eventId); @@ -192,10 +201,10 @@ export class EventComponent implements OnInit { } }); - movePopup.afterClosed().subscribe(result => { + movePopup.afterClosed().pipe(takeUntil(this.destroy$)).subscribe(result => { // If popup is dismissed, undefined will be returned if(result) { - this.api.moveEvent(result).subscribe((res: any) => { + this.api.moveEvent(result).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { console.log(res); // Uses the same interface as delete as from the calendar table perspective it is the same action // as a delete diff --git a/src/app/components/events-table/events-table.component.html b/src/app/components/events-table/events-table.component.html index 83f79e0..3fbb602 100644 --- a/src/app/components/events-table/events-table.component.html +++ b/src/app/components/events-table/events-table.component.html @@ -15,6 +15,6 @@ Move Delete - + diff --git a/src/app/components/events-table/events-table.component.ts b/src/app/components/events-table/events-table.component.ts index c93c2c6..b2ed78e 100644 --- a/src/app/components/events-table/events-table.component.ts +++ b/src/app/components/events-table/events-table.component.ts @@ -29,10 +29,10 @@ export class EventsTableComponent implements OnInit { } deleteEvent(id: number) { - this.events.forEach(event => { - if(event.eventId === id) { - this.events.splice(this.events.indexOf(event), 1); - } - }) + this.events = this.events.filter(event => event.eventId !== id); + } + + trackByEventId(index: number, event: Event): number { + return event.eventId ?? index; } } diff --git a/src/app/pages/admin/admin.component.ts b/src/app/pages/admin/admin.component.ts index bb6704d..6b50870 100644 --- a/src/app/pages/admin/admin.component.ts +++ b/src/app/pages/admin/admin.component.ts @@ -1,4 +1,6 @@ -import {Component, OnInit} from '@angular/core'; +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {Subject} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; import {ApiService} from '../../services/api.service'; import {UtilsService} from '../../services/utils.service'; import {Event} from '../../models/event'; @@ -10,7 +12,9 @@ import {User} from '../../models/user'; templateUrl: './admin.component.html', styleUrls: ['./admin.component.css'] }) -export class AdminComponent implements OnInit { +export class AdminComponent implements OnInit, OnDestroy { + + private destroy$ = new Subject(); isLoggedIn: boolean = false; events: Event[] = []; @@ -32,7 +36,7 @@ export class AdminComponent implements OnInit { ngOnInit(): void { if (UtilsService.getSessionInfoFromLocalStorage().sessionId !== -1) { - this.api.checkSession(UtilsService.getSessionInfoFromLocalStorage()).subscribe((user: User) => { + this.api.checkSession(UtilsService.getSessionInfoFromLocalStorage()).pipe(takeUntil(this.destroy$)).subscribe((user: User) => { if(user.userId != null && user.userId !== -1) { this.isLoggedIn = true; this.name = user.fullName; @@ -43,6 +47,11 @@ export class AdminComponent implements OnInit { } } + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + getEvents(): void { this.events = []; @@ -50,7 +59,7 @@ export class AdminComponent implements OnInit { return; } - this.api.getEvents(this.selectedCalendar).subscribe((events: Event[]): void => { + this.api.getEvents(this.selectedCalendar).pipe(takeUntil(this.destroy$)).subscribe((events: Event[]): void => { for (let event of events) { if(event.status !== 'DELETED') { this.events.push({ @@ -119,6 +128,7 @@ export class AdminComponent implements OnInit { dayOnlyDate.setFullYear(date.getFullYear()); dayOnlyDate.setMonth(date.getMonth()); dayOnlyDate.setDate(date.getDate()); + dayOnlyDate.setHours(0, 0, 0, 0); return dayOnlyDate; } @@ -147,39 +157,39 @@ export class AdminComponent implements OnInit { } login(): void { - this.api.login(this.email, this.password).subscribe((session: Session): void => { + this.api.login(this.email, this.password).pipe(takeUntil(this.destroy$)).subscribe((session: Session): void => { if(session.sessionId != null && session.sessionId !== -1) { UtilsService.saveSessionInfoToLocalStorage(session.sessionId, session.sessionKey); // Get user info - this.api.checkSession(UtilsService.getSessionInfoFromLocalStorage()).subscribe((user: User) => { + this.api.checkSession(UtilsService.getSessionInfoFromLocalStorage()).pipe(takeUntil(this.destroy$)).subscribe((user: User) => { if(user.userId != null && user.userId !== -1) { this.isLoggedIn = true; this.name = user.fullName; this.isActive = user.isActive; this.getEvents(); } else { - confirm('Login unsuccessful. Please check if you provided the correct username and password.'); + alert('Login unsuccessful. Please check if you provided the correct username and password.'); } }); } }, (error) => { - confirm('Login unsuccessful. Reported problem from server: ' + error.error.message); + alert('Login unsuccessful. Reported problem from server: ' + error?.error?.message); }); } register(): void { - this.api.register(this.registerEmail, this.name, this.registerPassword).subscribe((session: Session): void => { + this.api.register(this.registerEmail, this.name, this.registerPassword).pipe(takeUntil(this.destroy$)).subscribe((session: Session): void => { if(session.sessionId != null && session.sessionId !== -1) { UtilsService.saveSessionInfoToLocalStorage(session.sessionId, session.sessionKey); this.isLoggedIn = true; this.getEvents(); - confirm('An email was sent to your Nachklang address. Please click the link in the email to activate your account. You can\'t use this application before the activation.'); + alert('An email was sent to your Nachklang address. Please click the link in the email to activate your account. You can\'t use this application before the activation.'); } else { - confirm('Regisistration unsuccessful. Please contact Patrick.'); + alert('Registration unsuccessful. Please contact Patrick.'); } }, (error) => { - confirm('Login unsuccessful. Reported problem from server: ' + error.error.message); + alert('Registration unsuccessful. Reported problem from server: ' + error?.error?.message); }); } diff --git a/src/app/pages/landingpage/landingpage.component.css b/src/app/pages/landingpage/landingpage.component.css index e69de29..0732aa0 100644 --- a/src/app/pages/landingpage/landingpage.component.css +++ b/src/app/pages/landingpage/landingpage.component.css @@ -0,0 +1,272 @@ +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300&family=Jost:wght@300;400;500&display=swap'); + +/* ── Host fills the padded app-root container ───────────────────── */ +:host { + display: block; + height: calc(100vh - 40px); /* compensates for app-root padding: 20px */ +} + +/* ── Page shell ─────────────────────────────────────────────────── */ +.page { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + background-color: #1a1714; + background-image: + radial-gradient(ellipse 70% 60% at 14% 56%, rgba(200, 169, 110, 0.07) 0%, transparent 100%), + radial-gradient(ellipse 50% 50% at 87% 24%, rgba(200, 169, 110, 0.045) 0%, transparent 100%); +} + +/* Subtle horizontal staff-line texture */ +.page::before { + content: ''; + position: absolute; + inset: 0; + background-image: repeating-linear-gradient( + to bottom, + transparent 0px, + transparent 59px, + rgba(200, 169, 110, 0.033) 59px, + rgba(200, 169, 110, 0.033) 60px + ); + pointer-events: none; +} + +/* ── Admin link ─────────────────────────────────────────────────── */ +.admin-link { + position: absolute; + top: 0; + right: 0; + font-family: 'Jost', sans-serif; + font-size: 0.68rem; + font-weight: 300; + letter-spacing: 0.14em; + text-transform: uppercase; + color: rgba(240, 230, 211, 0.28); + text-decoration: none; + transition: color 0.25s; + z-index: 2; +} + +.admin-link:hover { + color: #c8a96e; +} + +/* ── Hero content ───────────────────────────────────────────────── */ +.hero { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 2.25rem; + width: 100%; + max-width: 800px; + padding: 0 1rem; +} + +/* ── Title block ────────────────────────────────────────────────── */ +.title-block { + text-align: center; + animation: rise 0.85s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.clef { + display: block; + font-size: 2.2rem; + line-height: 1.1; + color: #c8a96e; + opacity: 0.65; + margin-bottom: 0.2rem; +} + +h1 { + font-family: 'Cormorant Garamond', 'Palatino Linotype', Palatino, Georgia, serif; + font-size: clamp(2.8rem, 7.5vw, 5rem); + font-weight: 300; + letter-spacing: 0.22em; + color: #f0e6d3; + margin: 0 0 0.25em; + line-height: 1; +} + +.tagline { + font-family: 'Cormorant Garamond', Georgia, serif; + font-size: clamp(0.8rem, 1.8vw, 1rem); + font-weight: 300; + font-style: italic; + color: #c8a96e; + letter-spacing: 0.35em; + text-transform: uppercase; + margin: 0 0 1.1rem; +} + +.ornament-line { + width: 44px; + height: 1px; + background: linear-gradient(90deg, transparent, #c8a96e 35%, #c8a96e 65%, transparent); + margin: 0 auto 1.1rem; +} + +.description { + font-family: 'Jost', sans-serif; + font-size: 0.8rem; + font-weight: 300; + color: rgba(240, 230, 211, 0.38); + letter-spacing: 0.06em; + margin: 0; +} + +/* ── Cards grid ─────────────────────────────────────────────────── */ +.cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.875rem; + width: 100%; + animation: rise 0.85s cubic-bezier(0.16, 1, 0.3, 1) 0.14s both; +} + +/* ── Individual card ────────────────────────────────────────────── */ +.card { + background: rgba(255, 255, 255, 0.025); + border: 1px solid rgba(200, 169, 110, 0.13); + border-radius: 4px; + padding: 1.75rem 1rem 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.55rem; + text-decoration: none; + color: inherit; + cursor: pointer; + font-family: 'Jost', sans-serif; + font-size: inherit; + text-align: center; + transition: + border-color 0.3s ease, + background 0.3s ease, + transform 0.35s cubic-bezier(0.16, 1, 0.3, 1); + position: relative; +} + +.card::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 3px; + background: linear-gradient(145deg, rgba(200, 169, 110, 0.07) 0%, transparent 65%); + opacity: 0; + transition: opacity 0.3s; + pointer-events: none; +} + +.card:hover { + border-color: rgba(200, 169, 110, 0.38); + background: rgba(255, 255, 255, 0.04); + transform: translateY(-5px); +} + +.card:hover::before { + opacity: 1; +} + +.card:active { + transform: translateY(-2px); +} + +/* ── Icon container ─────────────────────────────────────────────── */ +.icon-wrap { + width: 48px; + height: 48px; + border-radius: 11px; + overflow: hidden; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.icon-wrap svg { + display: block; + width: 100%; + height: 100%; +} + +.icon-wrap.copy { + background: rgba(200, 169, 110, 0.1); + color: #c8a96e; + padding: 11px; + box-sizing: border-box; + transition: background 0.3s, color 0.3s; +} + +/* ── Card text ──────────────────────────────────────────────────── */ +.card-label { + font-size: 0.875rem; + font-weight: 500; + color: #ede0cc; + letter-spacing: 0.03em; + transition: color 0.25s; +} + +.card-sub { + font-size: 0.68rem; + font-weight: 300; + color: rgba(240, 230, 211, 0.32); + letter-spacing: 0.09em; +} + +/* ── Copy card: confirmed state ─────────────────────────────────── */ +.copy-card.copied .card-label { + color: #7cc48e; +} + +.copy-card.copied .icon-wrap.copy { + background: rgba(124, 196, 142, 0.1); + color: #7cc48e; +} + +/* ── Page-load animation ────────────────────────────────────────── */ +@keyframes rise { + from { + opacity: 0; + transform: translateY(18px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ── Responsive ─────────────────────────────────────────────────── */ +@media (max-width: 580px) { + :host { + height: auto; + } + + .page { + min-height: calc(100vh - 40px); + padding: 2.5rem 0; + justify-content: flex-start; + padding-top: 15%; + } + + .cards { + grid-template-columns: 1fr; + max-width: 280px; + } + + .card:hover { + transform: none; + } +} + +@media (max-width: 380px) { + h1 { + letter-spacing: 0.1em; + } +} diff --git a/src/app/pages/landingpage/landingpage.component.html b/src/app/pages/landingpage/landingpage.component.html index 32e166f..8b1b8a6 100644 --- a/src/app/pages/landingpage/landingpage.component.html +++ b/src/app/pages/landingpage/landingpage.component.html @@ -1,2 +1,71 @@ -

Nachklang Calendar

-Abonnieren +
+ Admin-Login › + +
+ +
+ +

Nachklang

+

Veranstaltungskalender

+
+

Abonniere unseren Kalender und verpasse keine Veranstaltung mehr.

+
+ +
+ + + +
+ + + + + + DEC + 31 + +
+ Apple Kalender + iPhone · iPad · Mac +
+ + + +
+ + + + + + + + + + +
+ Google Kalender + Android · Web +
+ + + + +
+
+
diff --git a/src/app/pages/landingpage/landingpage.component.ts b/src/app/pages/landingpage/landingpage.component.ts index a9b36be..b77cd11 100644 --- a/src/app/pages/landingpage/landingpage.component.ts +++ b/src/app/pages/landingpage/landingpage.component.ts @@ -7,10 +7,16 @@ import {Component, OnInit} from '@angular/core'; }) export class LandingpageComponent implements OnInit { - constructor() { - } + copied = false; - ngOnInit(): void { - } + constructor() {} + ngOnInit(): void {} + + copyUrl(): void { + navigator.clipboard.writeText('https://api.nachklang.art/calendar/events/public/ical').then(() => { + this.copied = true; + setTimeout(() => { this.copied = false; }, 2500); + }); + } } diff --git a/src/app/services/utils.service.ts b/src/app/services/utils.service.ts index 51baf63..a6a01be 100644 --- a/src/app/services/utils.service.ts +++ b/src/app/services/utils.service.ts @@ -9,15 +9,6 @@ export class UtilsService { constructor() { } - static saveUserInfoToLocalStorage(password: string, name: string): void { - localStorage.setItem('password', password); - localStorage.setItem('name', name); - } - - static getPasswordFromLocalStorage(): string { - return localStorage.getItem('password') ?? ''; - } - static getNameFromLocalStorage(): string { return localStorage.getItem('name') ?? ''; }