Claude init file + div. improvements
This commit is contained in:
@@ -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 `/`.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComponentRef, NgModule} from '@angular/core';
|
import {NgModule} from '@angular/core';
|
||||||
import {BrowserModule} from '@angular/platform-browser';
|
import {BrowserModule} from '@angular/platform-browser';
|
||||||
|
|
||||||
import {AppComponent} from './app.component';
|
import {AppComponent} from './app.component';
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {Component, Inject} from '@angular/core';
|
import {Component, Inject} from '@angular/core';
|
||||||
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
|
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
|
||||||
import {Event} from "../../models/event";
|
import {Event} from "../../models/event";
|
||||||
import {log} from "@angular-devkit/build-angular/src/builders/ssr-dev-server";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-event-move-popup',
|
selector: 'app-event-move-popup',
|
||||||
@@ -10,7 +9,7 @@ import {log} from "@angular-devkit/build-angular/src/builders/ssr-dev-server";
|
|||||||
})
|
})
|
||||||
export class EventMovePopupComponent {
|
export class EventMovePopupComponent {
|
||||||
selectedCalendar = 'public';
|
selectedCalendar = 'public';
|
||||||
calendars = ['', 'public', 'members', 'management', 'choir'];
|
calendars = ['', 'public', 'members', 'management', 'choir', 'birthdays'];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public dialogRef: MatDialogRef<EventMovePopupComponent>,
|
public dialogRef: MatDialogRef<EventMovePopupComponent>,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
|
import {Component, EventEmitter, Input, OnDestroy, OnInit, Output} from '@angular/core';
|
||||||
import {MatDialog, MatDialogRef} from '@angular/material/dialog';
|
import {Subject} from 'rxjs';
|
||||||
|
import {takeUntil} from 'rxjs/operators';
|
||||||
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
import {Event} from '../../models/event';
|
import {Event} from '../../models/event';
|
||||||
import {ApiService} from '../../services/api.service';
|
import {ApiService} from '../../services/api.service';
|
||||||
import {EventMovePopupComponent} from "../event-move-popup/event-move-popup.component";
|
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',
|
templateUrl: './event.component.html',
|
||||||
styleUrls: ['./event.component.css']
|
styleUrls: ['./event.component.css']
|
||||||
})
|
})
|
||||||
export class EventComponent implements OnInit {
|
export class EventComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
@Input() event: Event | undefined;
|
@Input() event: Event | undefined;
|
||||||
@Input() editActive: boolean = false;
|
@Input() editActive: boolean = false;
|
||||||
@@ -38,6 +42,11 @@ export class EventComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
toggleEdit() {
|
toggleEdit() {
|
||||||
if (this.editActive && this.event !== undefined) {
|
if (this.editActive && this.event !== undefined) {
|
||||||
// Prevent save if endDateTime is before startDateTime
|
// Prevent save if endDateTime is before startDateTime
|
||||||
@@ -62,7 +71,7 @@ export class EventComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(this.event.eventId === undefined) {
|
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);
|
console.log(res);
|
||||||
|
|
||||||
if(res.eventId) {
|
if(res.eventId) {
|
||||||
@@ -74,7 +83,7 @@ export class EventComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Update existing event
|
// 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);
|
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.`);
|
let deleteConfirmed = window.confirm(`Are you sure you want to delete "${this.event!.name}"? This action cannot be undone.`);
|
||||||
|
|
||||||
if(deleteConfirmed && this.event) {
|
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);
|
console.log(res);
|
||||||
if(res.message) {
|
if(res.message) {
|
||||||
this.deleteEvent.next(this.event!.eventId);
|
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 popup is dismissed, undefined will be returned
|
||||||
if(result) {
|
if(result) {
|
||||||
this.api.moveEvent(result).subscribe((res: any) => {
|
this.api.moveEvent(result).pipe(takeUntil(this.destroy$)).subscribe((res: any) => {
|
||||||
console.log(res);
|
console.log(res);
|
||||||
// Uses the same interface as delete as from the calendar table perspective it is the same action
|
// Uses the same interface as delete as from the calendar table perspective it is the same action
|
||||||
// as a delete
|
// as a delete
|
||||||
|
|||||||
@@ -15,6 +15,6 @@
|
|||||||
<th>Move</th>
|
<th>Move</th>
|
||||||
<th>Delete</th>
|
<th>Delete</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr app-event *ngFor="let event of events" [event]="event" [editActive]="event.eventId === undefined" (deleteEvent)="deleteEvent($event)"></tr>
|
<tr app-event *ngFor="let event of events; trackBy: trackByEventId" [event]="event" [editActive]="event.eventId === undefined" (deleteEvent)="deleteEvent($event)"></tr>
|
||||||
</table>
|
</table>
|
||||||
<button *ngIf="selectedCalendar !== -1" (click)="addEvent()">Add Event</button>
|
<button *ngIf="selectedCalendar !== -1" (click)="addEvent()">Add Event</button>
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ export class EventsTableComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteEvent(id: number) {
|
deleteEvent(id: number) {
|
||||||
this.events.forEach(event => {
|
this.events = this.events.filter(event => event.eventId !== id);
|
||||||
if(event.eventId === id) {
|
}
|
||||||
this.events.splice(this.events.indexOf(event), 1);
|
|
||||||
}
|
trackByEventId(index: number, event: Event): number {
|
||||||
})
|
return event.eventId ?? index;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {ApiService} from '../../services/api.service';
|
||||||
import {UtilsService} from '../../services/utils.service';
|
import {UtilsService} from '../../services/utils.service';
|
||||||
import {Event} from '../../models/event';
|
import {Event} from '../../models/event';
|
||||||
@@ -10,7 +12,9 @@ import {User} from '../../models/user';
|
|||||||
templateUrl: './admin.component.html',
|
templateUrl: './admin.component.html',
|
||||||
styleUrls: ['./admin.component.css']
|
styleUrls: ['./admin.component.css']
|
||||||
})
|
})
|
||||||
export class AdminComponent implements OnInit {
|
export class AdminComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
isLoggedIn: boolean = false;
|
isLoggedIn: boolean = false;
|
||||||
events: Event[] = [];
|
events: Event[] = [];
|
||||||
@@ -32,7 +36,7 @@ export class AdminComponent implements OnInit {
|
|||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (UtilsService.getSessionInfoFromLocalStorage().sessionId !== -1) {
|
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) {
|
if(user.userId != null && user.userId !== -1) {
|
||||||
this.isLoggedIn = true;
|
this.isLoggedIn = true;
|
||||||
this.name = user.fullName;
|
this.name = user.fullName;
|
||||||
@@ -43,6 +47,11 @@ export class AdminComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
getEvents(): void {
|
getEvents(): void {
|
||||||
this.events = [];
|
this.events = [];
|
||||||
|
|
||||||
@@ -50,7 +59,7 @@ export class AdminComponent implements OnInit {
|
|||||||
return;
|
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) {
|
for (let event of events) {
|
||||||
if(event.status !== 'DELETED') {
|
if(event.status !== 'DELETED') {
|
||||||
this.events.push({
|
this.events.push({
|
||||||
@@ -119,6 +128,7 @@ export class AdminComponent implements OnInit {
|
|||||||
dayOnlyDate.setFullYear(date.getFullYear());
|
dayOnlyDate.setFullYear(date.getFullYear());
|
||||||
dayOnlyDate.setMonth(date.getMonth());
|
dayOnlyDate.setMonth(date.getMonth());
|
||||||
dayOnlyDate.setDate(date.getDate());
|
dayOnlyDate.setDate(date.getDate());
|
||||||
|
dayOnlyDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
return dayOnlyDate;
|
return dayOnlyDate;
|
||||||
}
|
}
|
||||||
@@ -147,39 +157,39 @@ export class AdminComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
login(): void {
|
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) {
|
if(session.sessionId != null && session.sessionId !== -1) {
|
||||||
UtilsService.saveSessionInfoToLocalStorage(session.sessionId, session.sessionKey);
|
UtilsService.saveSessionInfoToLocalStorage(session.sessionId, session.sessionKey);
|
||||||
|
|
||||||
// Get user info
|
// 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) {
|
if(user.userId != null && user.userId !== -1) {
|
||||||
this.isLoggedIn = true;
|
this.isLoggedIn = true;
|
||||||
this.name = user.fullName;
|
this.name = user.fullName;
|
||||||
this.isActive = user.isActive;
|
this.isActive = user.isActive;
|
||||||
this.getEvents();
|
this.getEvents();
|
||||||
} else {
|
} 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) => {
|
}, (error) => {
|
||||||
confirm('Login unsuccessful. Reported problem from server: ' + error.error.message);
|
alert('Login unsuccessful. Reported problem from server: ' + error?.error?.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
register(): void {
|
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) {
|
if(session.sessionId != null && session.sessionId !== -1) {
|
||||||
UtilsService.saveSessionInfoToLocalStorage(session.sessionId, session.sessionKey);
|
UtilsService.saveSessionInfoToLocalStorage(session.sessionId, session.sessionKey);
|
||||||
this.isLoggedIn = true;
|
this.isLoggedIn = true;
|
||||||
this.getEvents();
|
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 {
|
} else {
|
||||||
confirm('Regisistration unsuccessful. Please contact Patrick.');
|
alert('Registration unsuccessful. Please contact Patrick.');
|
||||||
}
|
}
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
confirm('Login unsuccessful. Reported problem from server: ' + error.error.message);
|
alert('Registration unsuccessful. Reported problem from server: ' + error?.error?.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,71 @@
|
|||||||
<h1>Nachklang Calendar</h1>
|
<div class="page">
|
||||||
<a href="webcal://api.nachklang.art/calendar/events/public/ical">Abonnieren</a>
|
<a routerLink="/admin" class="admin-link">Admin-Login ›</a>
|
||||||
|
|
||||||
|
<main class="hero">
|
||||||
|
|
||||||
|
<div class="title-block">
|
||||||
|
<span class="clef" aria-hidden="true">𝄞</span>
|
||||||
|
<h1>Nachklang</h1>
|
||||||
|
<p class="tagline">Veranstaltungskalender</p>
|
||||||
|
<div class="ornament-line"></div>
|
||||||
|
<p class="description">Abonniere unseren Kalender und verpasse keine Veranstaltung mehr.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
|
||||||
|
<!-- Apple Calendar -->
|
||||||
|
<a href="webcal://api.nachklang.art/calendar/events/public/ical" class="card">
|
||||||
|
<div class="icon-wrap apple">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="48" height="48" fill="white"/>
|
||||||
|
<rect width="48" height="14" fill="#FF3B30"/>
|
||||||
|
<rect x="13" y="3" width="5" height="9" rx="2.5" fill="#c7c7cc"/>
|
||||||
|
<rect x="30" y="3" width="5" height="9" rx="2.5" fill="#c7c7cc"/>
|
||||||
|
<text x="24" y="13" text-anchor="middle"
|
||||||
|
font-family="Helvetica Neue, Helvetica, Arial, sans-serif"
|
||||||
|
font-size="6.5" font-weight="600" fill="rgba(255,255,255,0.92)"
|
||||||
|
letter-spacing="1.5">DEC</text>
|
||||||
|
<text x="24" y="41" text-anchor="middle"
|
||||||
|
font-family="Helvetica Neue, Helvetica, Arial, sans-serif"
|
||||||
|
font-size="21" font-weight="200" fill="#1c1c1e">31</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="card-label">Apple Kalender</span>
|
||||||
|
<span class="card-sub">iPhone · iPad · Mac</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Google Calendar -->
|
||||||
|
<a href="https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2Fapi.nachklang.art%2Fcalendar%2Fevents%2Fpublic%2Fical"
|
||||||
|
target="_blank" rel="noopener noreferrer" class="card">
|
||||||
|
<div class="icon-wrap google">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="48" height="48" fill="white"/>
|
||||||
|
<rect width="48" height="14" fill="#1a73e8"/>
|
||||||
|
<rect x="13" y="3" width="5" height="9" rx="2.5" fill="#80868b"/>
|
||||||
|
<rect x="30" y="3" width="5" height="9" rx="2.5" fill="#80868b"/>
|
||||||
|
<rect x="8" y="18" width="14" height="12" rx="1.5" fill="#4285f4"/>
|
||||||
|
<rect x="26" y="18" width="14" height="12" rx="1.5" fill="#ea4335"/>
|
||||||
|
<rect x="8" y="34" width="14" height="11" rx="1.5" fill="#34a853"/>
|
||||||
|
<rect x="26" y="34" width="14" height="11" rx="1.5" fill="#fbbc05"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="card-label">Google Kalender</span>
|
||||||
|
<span class="card-sub">Android · Web</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Copy iCal link -->
|
||||||
|
<button type="button" (click)="copyUrl()" class="card copy-card" [class.copied]="copied">
|
||||||
|
<div class="icon-wrap copy">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="card-label">{{ copied ? '✓ Kopiert!' : 'iCal-Link kopieren' }}</span>
|
||||||
|
<span class="card-sub">Outlook · andere Apps</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ import {Component, OnInit} from '@angular/core';
|
|||||||
})
|
})
|
||||||
export class LandingpageComponent implements OnInit {
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,6 @@ export class UtilsService {
|
|||||||
constructor() {
|
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 {
|
static getNameFromLocalStorage(): string {
|
||||||
return localStorage.getItem('name') ?? '';
|
return localStorage.getItem('name') ?? '';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user