feat(calendar): add complete calendar app with backend, web, and landing

- NestJS backend with Drizzle ORM (port 3014)
  - Calendar, Event, Reminder, Share, Sync modules
  - Full CRUD API endpoints
  - PostgreSQL database schema (5 tables)

- SvelteKit web app with Svelte 5 runes (port 5179)
  - Week, Day, Month views
  - Agenda list view
  - Event management (create, edit, delete)
  - Calendar management
  - Auth integration with Mana Core Auth
  - i18n support (DE, EN, FR, ES, IT)

- Astro landing page (port 4322)
  - Hero, Features, Pricing sections
  - Responsive dark theme design

- @calendar/shared package
  - TypeScript types for Calendar, Event, Reminder, Share
  - RFC 5545 RRULE support for recurring events

- Full documentation (CLAUDE.md, README.md)
This commit is contained in:
Till-JS 2025-12-02 13:15:04 +01:00
parent 623b1a21b1
commit 00176a25e0
114 changed files with 9433 additions and 0 deletions

571
apps/calendar/CLAUDE.md Normal file
View file

@ -0,0 +1,571 @@
# Calendar Project Guide
## Übersicht
**Kalender** ist eine vollständige Kalender-Anwendung für persönliches und geteiltes Zeitmanagement. Die App unterstützt mehrere Kalender, wiederkehrende Termine, CalDAV/iCal-Synchronisation und Erinnerungen.
| App | Port | URL |
|-----|------|-----|
| Backend | 3014 | http://localhost:3014 |
| Web App | 5179 | http://localhost:5179 |
| Landing Page | 4322 | http://localhost:4322 |
| Mobile | 8081 | Expo Go |
## Project Structure
```
apps/calendar/
├── apps/
│ ├── backend/ # NestJS API server (@calendar/backend)
│ │ └── src/
│ │ ├── main.ts
│ │ ├── app.module.ts
│ │ ├── db/ # Drizzle schemas + migrations
│ │ │ ├── schema/
│ │ │ │ ├── calendars.schema.ts
│ │ │ │ ├── events.schema.ts
│ │ │ │ ├── reminders.schema.ts
│ │ │ │ ├── calendar-shares.schema.ts
│ │ │ │ └── external-calendars.schema.ts
│ │ │ └── db.ts
│ │ ├── calendar/ # Calendar CRUD
│ │ ├── event/ # Event CRUD + queries
│ │ ├── reminder/ # Reminders + notifications
│ │ ├── sync/ # CalDAV/iCal sync
│ │ ├── share/ # Calendar sharing
│ │ └── health/
│ │
│ ├── web/ # SvelteKit web application (@calendar/web)
│ │ └── src/
│ │ ├── lib/
│ │ │ ├── api/ # API clients
│ │ │ │ ├── client.ts
│ │ │ │ ├── calendars.ts
│ │ │ │ ├── events.ts
│ │ │ │ ├── reminders.ts
│ │ │ │ └── shares.ts
│ │ │ ├── stores/ # Svelte 5 runes stores
│ │ │ │ ├── auth.svelte.ts
│ │ │ │ ├── view.svelte.ts
│ │ │ │ ├── calendars.svelte.ts
│ │ │ │ ├── events.svelte.ts
│ │ │ │ ├── theme.ts
│ │ │ │ ├── navigation.ts
│ │ │ │ └── toast.ts
│ │ │ ├── components/
│ │ │ │ ├── calendar/
│ │ │ │ │ ├── CalendarHeader.svelte
│ │ │ │ │ ├── WeekView.svelte
│ │ │ │ │ ├── DayView.svelte
│ │ │ │ │ ├── MonthView.svelte
│ │ │ │ │ ├── MiniCalendar.svelte
│ │ │ │ │ └── CalendarSidebar.svelte
│ │ │ │ └── event/
│ │ │ │ └── EventForm.svelte
│ │ │ └── i18n/ # Internationalization (5 Sprachen)
│ │ └── routes/
│ │ ├── +layout.svelte
│ │ ├── +page.svelte # Hauptkalender (Wochenansicht)
│ │ ├── agenda/+page.svelte # Agenda-Ansicht
│ │ ├── event/
│ │ │ ├── new/+page.svelte # Neuer Termin
│ │ │ └── [id]/+page.svelte # Termin bearbeiten
│ │ ├── calendars/+page.svelte
│ │ ├── settings/+page.svelte
│ │ ├── feedback/+page.svelte
│ │ └── (auth)/
│ │ ├── login/+page.svelte
│ │ ├── register/+page.svelte
│ │ └── forgot-password/+page.svelte
│ │
│ ├── landing/ # Astro marketing landing page (@calendar/landing)
│ │ └── src/
│ │ ├── pages/index.astro
│ │ ├── layouts/Layout.astro
│ │ └── components/
│ │ ├── Hero.astro
│ │ ├── Features.astro
│ │ ├── CTA.astro
│ │ └── Footer.astro
│ │
│ └── mobile/ # Expo/React Native mobile app (@calendar/mobile) [TODO]
├── packages/
│ ├── shared/ # Shared types, utils, constants (@calendar/shared)
│ │ └── src/
│ │ ├── types/
│ │ │ ├── calendar.ts
│ │ │ ├── event.ts
│ │ │ ├── reminder.ts
│ │ │ └── share.ts
│ │ └── index.ts
│ └── web-ui/ # Shared Svelte components (@calendar/web-ui) [TODO]
├── package.json
└── CLAUDE.md
```
## Commands
### Root Level (from monorepo root)
```bash
# Alle Apps starten
pnpm calendar:dev # Run all calendar apps
# Einzelne Apps starten
pnpm dev:calendar:backend # Start backend server (port 3014)
pnpm dev:calendar:web # Start web app (port 5179)
pnpm dev:calendar:landing # Start landing page (port 4322)
pnpm dev:calendar:mobile # Start mobile app [TODO]
pnpm dev:calendar:app # Start web + backend together
# Datenbank
pnpm calendar:db:push # Push schema to database
pnpm calendar:db:studio # Open Drizzle Studio
pnpm calendar:db:seed # Seed initial data
```
### Backend (apps/calendar/apps/backend)
```bash
pnpm dev # Start with hot reload
pnpm build # Build for production
pnpm start:prod # Start production server
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
pnpm db:seed # Seed initial data
```
### Web App (apps/calendar/apps/web)
```bash
pnpm dev # Start dev server
pnpm build # Build for production
pnpm preview # Preview production build
```
### Landing Page (apps/calendar/apps/landing)
```bash
pnpm dev # Start dev server (port 4322)
pnpm build # Build for production
pnpm preview # Preview build
```
## Technology Stack
| Layer | Technology |
|-------|------------|
| **Backend** | NestJS 10, Drizzle ORM, PostgreSQL |
| **Web** | SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS 4 |
| **Landing** | Astro 5.x, Tailwind CSS |
| **Mobile** | React Native 0.81 + Expo SDK 54, NativeWind [TODO] |
| **Auth** | Mana Core Auth (JWT) |
| **i18n** | svelte-i18n (DE, EN, FR, ES, IT) |
| **Dates** | date-fns |
| **Sync** | ical.js, tsdav (CalDAV) |
## Architecture
### Core Features
1. **Persönliche Kalender** - Erstelle und verwalte mehrere farbcodierte Kalender
2. **Termine** - Vollständiges CRUD mit Wiederholungsunterstützung (RFC 5545 RRULE)
3. **Geteilte Kalender** - Teile Kalender mit Lese-/Schreib-/Admin-Berechtigungen
4. **CalDAV/iCal Sync** - Bi-direktionale Synchronisation mit Google, Apple, etc.
5. **Erinnerungen** - Push-Benachrichtigungen und E-Mail-Erinnerungen
### Kalender-Ansichten
| Ansicht | Route | Beschreibung |
|---------|-------|--------------|
| **Woche** | `/` (default) | 7-Tage-Raster mit Stunden |
| **Tag** | Click auf Tag | 24-Stunden-Timeline |
| **Monat** | Header-Switch | Traditionelles Kalenderraster |
| **Agenda** | `/agenda` | Chronologische Terminliste |
| **Jahr** | [TODO] | Kompakte 12-Monats-Übersicht |
### Web App Stores (Svelte 5 Runes)
```typescript
// auth.svelte.ts - Authentifizierung
authStore.isAuthenticated // boolean
authStore.user // User | null
authStore.signIn(email, password)
authStore.signOut()
authStore.getAccessToken()
// view.svelte.ts - Kalender-Ansicht
viewStore.currentDate // Date
viewStore.viewType // 'day' | 'week' | 'month' | 'year' | 'agenda'
viewStore.setDate(date)
viewStore.setViewType(type)
viewStore.goToToday()
viewStore.navigate(direction) // 'prev' | 'next'
// calendars.svelte.ts - Kalender-Verwaltung
calendarsStore.calendars // Calendar[]
calendarsStore.loading // boolean
calendarsStore.fetchCalendars()
calendarsStore.createCalendar(data)
calendarsStore.updateCalendar(id, data)
calendarsStore.deleteCalendar(id)
calendarsStore.getColor(calendarId)
// events.svelte.ts - Termine
eventsStore.events // Event[]
eventsStore.loading // boolean
eventsStore.fetchEvents(start, end)
eventsStore.getEventsForDay(date)
eventsStore.getEventsForWeek(date)
eventsStore.createEvent(data)
eventsStore.updateEvent(id, data)
eventsStore.deleteEvent(id)
```
### Backend API Endpoints
#### Health
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/health` | GET | Health check |
#### Calendars
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/calendars` | GET | List user's calendars |
| `/api/v1/calendars` | POST | Create calendar |
| `/api/v1/calendars/:id` | GET | Get calendar details |
| `/api/v1/calendars/:id` | PUT | Update calendar |
| `/api/v1/calendars/:id` | DELETE | Delete calendar |
#### Events
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/events` | GET | Query events (date range) |
| `/api/v1/events` | POST | Create event |
| `/api/v1/events/:id` | GET | Get event details |
| `/api/v1/events/:id` | PUT | Update event |
| `/api/v1/events/:id` | DELETE | Delete event |
| `/api/v1/events/calendar/:calendarId` | GET | Get events by calendar |
#### Reminders
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/events/:eventId/reminders` | GET | List event reminders |
| `/api/v1/events/:eventId/reminders` | POST | Add reminder |
| `/api/v1/reminders/:id` | DELETE | Remove reminder |
#### Sharing
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/calendars/:id/shares` | GET | List calendar shares |
| `/api/v1/calendars/:id/shares` | POST | Share calendar |
| `/api/v1/shares/:shareId/accept` | POST | Accept invitation |
| `/api/v1/shares/:shareId/decline` | POST | Decline invitation |
#### Sync
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/sync/external` | GET | List external calendars |
| `/api/v1/sync/external` | POST | Connect external calendar |
| `/api/v1/sync/external/:id` | DELETE | Disconnect external |
| `/api/v1/sync/external/:id/sync` | POST | Trigger manual sync |
| `/api/v1/sync/caldav/discover` | POST | Discover CalDAV calendars |
| `/api/v1/calendars/:id/export.ics` | GET | Export calendar as iCal |
### Database Schema
#### calendars
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | UUID | Owner |
| `name` | VARCHAR(255) | Calendar name |
| `description` | TEXT | Optional description |
| `color` | VARCHAR(7) | Hex color code (#3B82F6) |
| `is_default` | BOOLEAN | Default calendar flag |
| `is_visible` | BOOLEAN | Visibility in UI |
| `timezone` | VARCHAR(100) | Default timezone |
| `settings` | JSONB | CalendarSettings object |
| `created_at` | TIMESTAMP | Creation time |
| `updated_at` | TIMESTAMP | Last update |
#### events
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `calendar_id` | UUID | FK to calendars |
| `user_id` | UUID | Owner |
| `title` | VARCHAR(500) | Event title |
| `description` | TEXT | Event description |
| `location` | VARCHAR(500) | Location |
| `start_time` | TIMESTAMP | Start datetime |
| `end_time` | TIMESTAMP | End datetime |
| `is_all_day` | BOOLEAN | All-day flag |
| `timezone` | VARCHAR(100) | Event timezone |
| `recurrence_rule` | VARCHAR(500) | RFC 5545 RRULE |
| `recurrence_end_date` | TIMESTAMP | End of recurrence |
| `recurrence_exceptions` | JSONB | Exception dates |
| `parent_event_id` | UUID | Parent for instances |
| `color` | VARCHAR(7) | Override color |
| `status` | VARCHAR(20) | confirmed/tentative/cancelled |
| `external_id` | VARCHAR(255) | External calendar ID |
| `metadata` | JSONB | Attendees, URL, etc. |
| `created_at` | TIMESTAMP | Creation time |
| `updated_at` | TIMESTAMP | Last update |
#### calendar_shares
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `calendar_id` | UUID | FK to calendars |
| `shared_with_user_id` | UUID | Target user (optional) |
| `shared_with_email` | VARCHAR(255) | Email for invite |
| `permission` | VARCHAR(20) | read/write/admin |
| `share_token` | VARCHAR(64) | For link sharing |
| `share_url` | VARCHAR(500) | Public share URL |
| `status` | VARCHAR(20) | pending/accepted/declined |
| `invited_by` | UUID | Inviter user ID |
| `accepted_at` | TIMESTAMP | Accept timestamp |
| `expires_at` | TIMESTAMP | Expiration date |
| `created_at` | TIMESTAMP | Creation time |
| `updated_at` | TIMESTAMP | Last update |
#### reminders
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `event_id` | UUID | FK to events |
| `user_id` | UUID | Owner |
| `minutes_before` | INTEGER | Reminder offset |
| `reminder_time` | TIMESTAMP | Calculated time |
| `notify_push` | BOOLEAN | Push notification |
| `notify_email` | BOOLEAN | Email notification |
| `status` | VARCHAR(20) | pending/sent/failed |
| `sent_at` | TIMESTAMP | Send timestamp |
| `event_instance_date` | TIMESTAMP | For recurring events |
| `created_at` | TIMESTAMP | Creation time |
#### external_calendars
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | UUID | Owner |
| `name` | VARCHAR(255) | Display name |
| `provider` | VARCHAR(50) | google/apple/caldav/ical_url |
| `calendar_url` | TEXT | CalDAV or iCal URL |
| `username` | VARCHAR(255) | CalDAV username |
| `encrypted_password` | TEXT | Encrypted password |
| `access_token` | TEXT | OAuth token |
| `refresh_token` | TEXT | OAuth refresh token |
| `token_expires_at` | TIMESTAMP | Token expiration |
| `sync_enabled` | BOOLEAN | Sync toggle |
| `sync_direction` | VARCHAR(20) | both/import/export |
| `sync_interval` | INTEGER | Minutes between syncs |
| `last_sync_at` | TIMESTAMP | Last sync time |
| `last_sync_error` | TEXT | Error message |
| `color` | VARCHAR(7) | Display color |
| `is_visible` | BOOLEAN | Visibility in UI |
| `provider_data` | JSONB | Provider-specific data |
| `created_at` | TIMESTAMP | Creation time |
| `updated_at` | TIMESTAMP | Last update |
### Recurrence (RFC 5545 RRULE)
Beispiele für wiederkehrende Termine:
```
FREQ=DAILY # Täglich
FREQ=WEEKLY;BYDAY=MO,WE,FR # Mo, Mi, Fr
FREQ=WEEKLY;INTERVAL=2;BYDAY=TU # Jeden 2. Dienstag
FREQ=MONTHLY;BYMONTHDAY=15 # Am 15. jeden Monats
FREQ=MONTHLY;BYDAY=2MO # Am 2. Montag jeden Monats
FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25 # Jährlich am 25.12.
FREQ=DAILY;COUNT=10 # Täglich, 10 mal
FREQ=WEEKLY;UNTIL=20241231T235959Z # Wöchentlich bis Ende 2024
```
## Environment Variables
### Backend (.env)
```env
NODE_ENV=development
PORT=3014
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/calendar
MANA_CORE_AUTH_URL=http://localhost:3001
CORS_ORIGINS=http://localhost:5173,http://localhost:5179,http://localhost:8081
# Notifications (optional)
EXPO_ACCESS_TOKEN=your-expo-access-token
RESEND_API_KEY=your-resend-api-key
EMAIL_FROM=calendar@manacore.app
```
### Web (.env)
```env
PUBLIC_BACKEND_URL=http://localhost:3014
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
### Mobile (.env)
```env
EXPO_PUBLIC_BACKEND_URL=http://localhost:3014
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
## Shared Packages
### @calendar/shared
**Types:**
- `Calendar` - Kalender-Entity
- `CalendarSettings` - Kalender-Einstellungen (JSONB)
- `CalendarViewType` - 'day' | 'week' | 'month' | 'year' | 'agenda'
- `Event` - Termin-Entity
- `EventStatus` - 'confirmed' | 'tentative' | 'cancelled'
- `Reminder` - Erinnerung-Entity
- `ReminderStatus` - 'pending' | 'sent' | 'failed'
- `CalendarShare` - Freigabe-Entity
- `SharePermission` - 'read' | 'write' | 'admin'
- `ExternalCalendar` - Externe Kalender-Entity
**Constants:**
- `DEFAULT_CALENDAR_COLORS` - 8 vordefinierte Farben
- `DEFAULT_TIMEZONES` - Häufige Zeitzonen
## Code Style Guidelines
- **TypeScript**: Strict typing mit Interfaces
- **Web**: Svelte 5 runes mode (`$state`, `$derived`, `$effect`)
- **Styling**: Tailwind CSS mit CSS-Variablen
- **Formatting**: Prettier mit Projekt-Config
- **i18n**: Alle UI-Texte in Locale-Dateien
### Svelte 5 Runes Beispiel
```svelte
<script lang="ts">
import { viewStore } from '$lib/stores/view.svelte';
// Reaktiver State
let loading = $state(false);
// Abgeleiteter Wert
let formattedDate = $derived(
format(viewStore.currentDate, 'MMMM yyyy', { locale: de })
);
// Side Effect
$effect(() => {
console.log('Date changed:', viewStore.currentDate);
});
</script>
```
## Quick Start
### 1. Datenbank erstellen
```bash
# PostgreSQL Container muss laufen
docker compose -f docker-compose.dev.yml up -d postgres
# Datenbank erstellen
PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE calendar;"
# Schema pushen
pnpm calendar:db:push
```
### 2. Apps starten
```bash
# Backend + Web zusammen
pnpm dev:calendar:app
# Oder einzeln:
pnpm dev:calendar:backend # Terminal 1
pnpm dev:calendar:web # Terminal 2
pnpm dev:calendar:landing # Terminal 3 (optional)
```
### 3. URLs öffnen
- Web App: http://localhost:5179
- Landing: http://localhost:4322
- API Health: http://localhost:3014/api/v1/health
## Testing API (mit curl)
```bash
# Health Check
curl http://localhost:3014/api/v1/health
# Login (get token)
TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "password": "password"}' | jq -r '.accessToken')
# Kalender abrufen
curl http://localhost:3014/api/v1/calendars \
-H "Authorization: Bearer $TOKEN"
# Neuen Kalender erstellen
curl -X POST http://localhost:3014/api/v1/calendars \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Arbeit", "color": "#3B82F6"}'
# Termine abrufen (Datumsbereich)
curl "http://localhost:3014/api/v1/events?start=2024-12-01&end=2024-12-31" \
-H "Authorization: Bearer $TOKEN"
# Neuen Termin erstellen
curl -X POST http://localhost:3014/api/v1/events \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"calendarId": "calendar-uuid",
"title": "Meeting",
"startTime": "2024-12-15T10:00:00Z",
"endTime": "2024-12-15T11:00:00Z"
}'
```
## Roadmap / TODO
- [ ] Mobile App (Expo)
- [ ] Year View
- [ ] CalDAV Sync Implementation
- [ ] Push Notifications
- [ ] E-Mail Reminders
- [ ] Drag & Drop Events
- [ ] Event Attendees
- [ ] Calendar Import/Export
- [ ] Offline Support
- [ ] Dark/Light Theme in Landing
## Important Notes
1. **Authentication**: Nutzt Mana Core Auth (JWT im Authorization Header)
2. **Database**: PostgreSQL mit Drizzle ORM (Port 5432)
3. **Port**: Backend läuft auf Port 3014
4. **Recurrence**: Verwendet RFC 5545 RRULE Format
5. **i18n**: 5 Sprachen unterstützt (DE, EN, FR, ES, IT)
6. **Theme**: Ocean-Theme (Blautöne) als Standard

79
apps/calendar/README.md Normal file
View file

@ -0,0 +1,79 @@
# Kalender
> Smart Calendar Management - Organisiere deine Zeit intelligent
Eine vollständige Kalender-Anwendung mit persönlichen und geteilten Kalendern, wiederkehrenden Terminen, CalDAV/iCal-Synchronisation und Erinnerungen.
## Features
- **Mehrere Kalender** - Verwalte verschiedene Kalender für Arbeit, Privates, Familie
- **Kalenderansichten** - Tag, Woche, Monat, Agenda
- **Wiederkehrende Termine** - Flexible Wiederholungsregeln (RFC 5545)
- **Kalender teilen** - Mit Familie, Freunden oder Kollegen
- **CalDAV/iCal Sync** - Google Calendar, Apple, Outlook
- **Smarte Erinnerungen** - Push & E-Mail Benachrichtigungen
- **Multi-Sprache** - Deutsch, English, Français, Español, Italiano
## Quick Start
```bash
# 1. PostgreSQL starten (falls nicht läuft)
docker compose -f docker-compose.dev.yml up -d postgres
# 2. Datenbank erstellen
PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE calendar;"
# 3. Schema pushen
pnpm calendar:db:push
# 4. Backend + Web starten
pnpm dev:calendar:app
```
## Apps
| App | Port | Beschreibung |
|-----|------|--------------|
| [Backend](apps/backend) | 3014 | NestJS REST API |
| [Web](apps/web) | 5179 | SvelteKit Web-App |
| [Landing](apps/landing) | 4322 | Astro Marketing-Seite |
| Mobile | - | Expo App (TODO) |
## Tech Stack
- **Backend**: NestJS, Drizzle ORM, PostgreSQL
- **Web**: SvelteKit, Svelte 5, Tailwind CSS
- **Landing**: Astro, Tailwind CSS
- **Auth**: Mana Core Auth (JWT)
## Dokumentation
Siehe [CLAUDE.md](CLAUDE.md) für die vollständige technische Dokumentation.
## Entwicklung
```bash
# Einzelne Apps starten
pnpm dev:calendar:backend # Backend
pnpm dev:calendar:web # Web-App
pnpm dev:calendar:landing # Landing Page
# Datenbank
pnpm calendar:db:push # Schema pushen
pnpm calendar:db:studio # Drizzle Studio öffnen
```
## API Endpunkte
| Modul | Endpunkt | Beschreibung |
|-------|----------|--------------|
| Health | `GET /api/v1/health` | Health Check |
| Calendars | `GET/POST /api/v1/calendars` | Kalender CRUD |
| Events | `GET/POST /api/v1/events` | Termine CRUD |
| Reminders | `POST /api/v1/events/:id/reminders` | Erinnerungen |
| Shares | `POST /api/v1/calendars/:id/shares` | Freigaben |
| Sync | `POST /api/v1/sync/caldav/discover` | CalDAV |
## Lizenz
Proprietär - Manacore

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/calendar',
},
verbose: true,
strict: true,
});

View file

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false,
"assets": [],
"watchAssets": false
}
}

View file

@ -0,0 +1,57 @@
{
"name": "@calendar/backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@calendar/shared": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^4.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"ical.js": "^2.1.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"tsdav": "^2.1.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.0",
"@types/node": "^22.10.2",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { CalendarModule } from './calendar/calendar.module';
import { EventModule } from './event/event.module';
import { ReminderModule } from './reminder/reminder.module';
import { ShareModule } from './share/share.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
DatabaseModule,
HealthModule,
CalendarModule,
EventModule,
ReminderModule,
ShareModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,53 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { CalendarService } from './calendar.service';
import { CreateCalendarDto, UpdateCalendarDto } from './dto';
@Controller('calendars')
@UseGuards(JwtAuthGuard)
export class CalendarController {
constructor(private readonly calendarService: CalendarService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
const calendars = await this.calendarService.findAll(user.userId);
return { calendars };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const calendar = await this.calendarService.findByIdOrThrow(id, user.userId);
return { calendar };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateCalendarDto) {
const calendar = await this.calendarService.create(user.userId, dto);
return { calendar };
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateCalendarDto
) {
const calendar = await this.calendarService.update(id, user.userId, dto);
return { calendar };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.calendarService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CalendarController } from './calendar.controller';
import { CalendarService } from './calendar.service';
@Module({
controllers: [CalendarController],
providers: [CalendarService],
exports: [CalendarService],
})
export class CalendarModule {}

View file

@ -0,0 +1,131 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { calendars, type Calendar, type NewCalendar } from '../db/schema/calendars.schema';
import { CreateCalendarDto, UpdateCalendarDto } from './dto';
@Injectable()
export class CalendarService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string): Promise<Calendar[]> {
return this.db.select().from(calendars).where(eq(calendars.userId, userId));
}
async findById(id: string, userId: string): Promise<Calendar | null> {
const result = await this.db
.select()
.from(calendars)
.where(and(eq(calendars.id, id), eq(calendars.userId, userId)));
return result[0] || null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Calendar> {
const calendar = await this.findById(id, userId);
if (!calendar) {
throw new NotFoundException(`Calendar with id ${id} not found`);
}
return calendar;
}
async create(userId: string, dto: CreateCalendarDto): Promise<Calendar> {
// If this is the first calendar or marked as default, handle default logic
if (dto.isDefault) {
await this.clearDefaultCalendar(userId);
}
const newCalendar: NewCalendar = {
userId,
name: dto.name,
description: dto.description,
color: dto.color || '#3B82F6',
isDefault: dto.isDefault ?? false,
isVisible: dto.isVisible ?? true,
timezone: dto.timezone || 'Europe/Berlin',
settings: dto.settings,
};
const [created] = await this.db.insert(calendars).values(newCalendar).returning();
return created;
}
async update(id: string, userId: string, dto: UpdateCalendarDto): Promise<Calendar> {
await this.findByIdOrThrow(id, userId);
// If setting as default, clear other defaults
if (dto.isDefault) {
await this.clearDefaultCalendar(userId);
}
const [updated] = await this.db
.update(calendars)
.set({
...dto,
updatedAt: new Date(),
})
.where(and(eq(calendars.id, id), eq(calendars.userId, userId)))
.returning();
return updated;
}
async delete(id: string, userId: string): Promise<void> {
const calendar = await this.findByIdOrThrow(id, userId);
// Don't allow deleting the default calendar if it's the only one
if (calendar.isDefault) {
const allCalendars = await this.findAll(userId);
if (allCalendars.length === 1) {
throw new Error('Cannot delete the only calendar');
}
}
await this.db
.delete(calendars)
.where(and(eq(calendars.id, id), eq(calendars.userId, userId)));
}
async getOrCreateDefaultCalendar(userId: string): Promise<Calendar> {
// Try to find existing default calendar
const existing = await this.db
.select()
.from(calendars)
.where(and(eq(calendars.userId, userId), eq(calendars.isDefault, true)));
if (existing.length > 0) {
return existing[0];
}
// Try to find any calendar
const anyCalendar = await this.db
.select()
.from(calendars)
.where(eq(calendars.userId, userId))
.limit(1);
if (anyCalendar.length > 0) {
// Make it the default
const [updated] = await this.db
.update(calendars)
.set({ isDefault: true, updatedAt: new Date() })
.where(eq(calendars.id, anyCalendar[0].id))
.returning();
return updated;
}
// Create a new default calendar
return this.create(userId, {
name: 'My Calendar',
isDefault: true,
color: '#3B82F6',
});
}
private async clearDefaultCalendar(userId: string): Promise<void> {
await this.db
.update(calendars)
.set({ isDefault: false, updatedAt: new Date() })
.where(and(eq(calendars.userId, userId), eq(calendars.isDefault, true)));
}
}

View file

@ -0,0 +1,34 @@
import { IsString, IsOptional, IsBoolean, IsObject, MaxLength } from 'class-validator';
import type { CalendarSettings } from '../../db/schema/calendars.schema';
export class CreateCalendarDto {
@IsString()
@MaxLength(255)
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(7)
color?: string;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsBoolean()
isVisible?: boolean;
@IsOptional()
@IsString()
@MaxLength(100)
timezone?: string;
@IsOptional()
@IsObject()
settings?: CalendarSettings;
}

View file

@ -0,0 +1,2 @@
export * from './create-calendar.dto';
export * from './update-calendar.dto';

View file

@ -0,0 +1,35 @@
import { IsString, IsOptional, IsBoolean, IsObject, MaxLength } from 'class-validator';
import type { CalendarSettings } from '../../db/schema/calendars.schema';
export class UpdateCalendarDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@IsString()
@MaxLength(7)
color?: string;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsBoolean()
isVisible?: boolean;
@IsOptional()
@IsString()
@MaxLength(100)
timezone?: string;
@IsOptional()
@IsObject()
settings?: CalendarSettings;
}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,28 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, type Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,42 @@
import { pgTable, uuid, timestamp, varchar, unique } from 'drizzle-orm/pg-core';
import { calendars } from './calendars.schema';
/**
* Calendar shares table - stores calendar sharing information
*/
export const calendarShares = pgTable(
'calendar_shares',
{
id: uuid('id').primaryKey().defaultRandom(),
calendarId: uuid('calendar_id')
.notNull()
.references(() => calendars.id, { onDelete: 'cascade' }),
sharedWithUserId: uuid('shared_with_user_id'),
sharedWithEmail: varchar('shared_with_email', { length: 255 }),
// Permission level: read, write, admin
permission: varchar('permission', { length: 20 }).notNull().default('read'),
// Share link (for public/link sharing)
shareToken: varchar('share_token', { length: 64 }),
shareUrl: varchar('share_url', { length: 500 }),
// Status: pending, accepted, declined
status: varchar('status', { length: 20 }).default('pending'),
// Metadata
invitedBy: uuid('invited_by').notNull(),
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
uniqueUserShare: unique().on(table.calendarId, table.sharedWithUserId),
uniqueEmailShare: unique().on(table.calendarId, table.sharedWithEmail),
})
);
export type CalendarShare = typeof calendarShares.$inferSelect;
export type NewCalendarShare = typeof calendarShares.$inferInsert;

View file

@ -0,0 +1,32 @@
import { pgTable, uuid, timestamp, varchar, text, boolean, jsonb } from 'drizzle-orm/pg-core';
/**
* Calendar settings stored in JSONB
*/
export interface CalendarSettings {
defaultView?: 'day' | 'week' | 'month' | 'year' | 'agenda';
weekStartsOn?: 0 | 1;
showWeekNumbers?: boolean;
defaultEventDuration?: number;
defaultReminder?: number;
}
/**
* Calendars table - stores user calendars
*/
export const calendars = pgTable('calendars', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
color: varchar('color', { length: 7 }).default('#3B82F6'),
isDefault: boolean('is_default').default(false),
isVisible: boolean('is_visible').default(true),
timezone: varchar('timezone', { length: 100 }).default('Europe/Berlin'),
settings: jsonb('settings').$type<CalendarSettings>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Calendar = typeof calendars.$inferSelect;
export type NewCalendar = typeof calendars.$inferInsert;

View file

@ -0,0 +1,80 @@
import { pgTable, uuid, timestamp, varchar, text, boolean, jsonb, index } from 'drizzle-orm/pg-core';
import { calendars } from './calendars.schema';
/**
* Event attendee information
*/
export interface EventAttendee {
email: string;
name?: string;
status?: 'accepted' | 'declined' | 'tentative' | 'pending';
}
/**
* Event metadata stored in JSONB
*/
export interface EventMetadata {
url?: string;
conferenceUrl?: string;
attendees?: EventAttendee[];
organizer?: string;
priority?: 'low' | 'normal' | 'high';
tags?: string[];
}
/**
* Events table - stores calendar events
*/
export const events = pgTable(
'events',
{
id: uuid('id').primaryKey().defaultRandom(),
calendarId: uuid('calendar_id')
.notNull()
.references(() => calendars.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(),
// Basic info
title: varchar('title', { length: 500 }).notNull(),
description: text('description'),
location: varchar('location', { length: 500 }),
// Timing
startTime: timestamp('start_time', { withTimezone: true }).notNull(),
endTime: timestamp('end_time', { withTimezone: true }).notNull(),
isAllDay: boolean('is_all_day').default(false),
timezone: varchar('timezone', { length: 100 }).default('Europe/Berlin'),
// Recurrence (RFC 5545 RRULE format)
recurrenceRule: varchar('recurrence_rule', { length: 500 }),
recurrenceEndDate: timestamp('recurrence_end_date', { withTimezone: true }),
recurrenceExceptions: jsonb('recurrence_exceptions').$type<string[]>(),
parentEventId: uuid('parent_event_id'),
// Appearance
color: varchar('color', { length: 7 }),
// Status
status: varchar('status', { length: 20 }).default('confirmed'),
// External sync
externalId: varchar('external_id', { length: 255 }),
externalCalendarId: uuid('external_calendar_id'),
lastSyncedAt: timestamp('last_synced_at', { withTimezone: true }),
// Metadata
metadata: jsonb('metadata').$type<EventMetadata>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
calendarIdx: index('events_calendar_idx').on(table.calendarId),
userIdx: index('events_user_idx').on(table.userId),
timeRangeIdx: index('events_time_range_idx').on(table.startTime, table.endTime),
externalIdx: index('events_external_idx').on(table.externalId, table.externalCalendarId),
})
);
export type Event = typeof events.$inferSelect;
export type NewEvent = typeof events.$inferInsert;

View file

@ -0,0 +1,56 @@
import { pgTable, uuid, timestamp, varchar, text, boolean, jsonb, integer } from 'drizzle-orm/pg-core';
/**
* Provider-specific metadata
*/
export interface ExternalCalendarProviderData {
googleCalendarId?: string;
appleCalendarId?: string;
caldavCalendarId?: string;
caldavEtag?: string;
caldavCtag?: string;
icalLastModified?: string;
icalEtag?: string;
}
/**
* External calendars table - stores CalDAV/iCal connections
*/
export const externalCalendars = pgTable('external_calendars', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
// Calendar identification
name: varchar('name', { length: 255 }).notNull(),
provider: varchar('provider', { length: 50 }).notNull(), // google, apple, caldav, ical_url
// Connection details
calendarUrl: text('calendar_url').notNull(),
username: varchar('username', { length: 255 }),
encryptedPassword: text('encrypted_password'),
// OAuth tokens (for Google, etc.)
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }),
// Sync settings
syncEnabled: boolean('sync_enabled').default(true),
syncDirection: varchar('sync_direction', { length: 20 }).default('both'), // import, export, both
syncInterval: integer('sync_interval').default(15), // Minutes between syncs
lastSyncAt: timestamp('last_sync_at', { withTimezone: true }),
lastSyncError: text('last_sync_error'),
// Display settings
color: varchar('color', { length: 7 }).default('#6B7280'),
isVisible: boolean('is_visible').default(true),
// Provider-specific metadata
providerData: jsonb('provider_data').$type<ExternalCalendarProviderData>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type ExternalCalendar = typeof externalCalendars.$inferSelect;
export type NewExternalCalendar = typeof externalCalendars.$inferInsert;

View file

@ -0,0 +1,6 @@
// Calendar Database Schemas
export * from './calendars.schema';
export * from './events.schema';
export * from './calendar-shares.schema';
export * from './reminders.schema';
export * from './external-calendars.schema';

View file

@ -0,0 +1,41 @@
import { pgTable, uuid, timestamp, varchar, integer, boolean, index } from 'drizzle-orm/pg-core';
import { events } from './events.schema';
/**
* Reminders table - stores event reminders
*/
export const reminders = pgTable(
'reminders',
{
id: uuid('id').primaryKey().defaultRandom(),
eventId: uuid('event_id')
.notNull()
.references(() => events.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(),
// Timing
minutesBefore: integer('minutes_before').notNull(),
reminderTime: timestamp('reminder_time', { withTimezone: true }).notNull(),
// Notification channels
notifyPush: boolean('notify_push').default(true),
notifyEmail: boolean('notify_email').default(false),
// Status: pending, sent, failed, cancelled
status: varchar('status', { length: 20 }).default('pending'),
sentAt: timestamp('sent_at', { withTimezone: true }),
// For recurring events - which instance this reminder is for
eventInstanceDate: timestamp('event_instance_date', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
eventIdx: index('reminders_event_idx').on(table.eventId),
userIdx: index('reminders_user_idx').on(table.userId),
pendingIdx: index('reminders_pending_idx').on(table.status, table.reminderTime),
})
);
export type Reminder = typeof reminders.$inferSelect;
export type NewReminder = typeof reminders.$inferInsert;

View file

@ -0,0 +1,66 @@
import {
IsString,
IsOptional,
IsBoolean,
IsObject,
IsDateString,
IsUUID,
IsIn,
MaxLength,
} from 'class-validator';
import type { EventMetadata } from '../../db/schema/events.schema';
export class CreateEventDto {
@IsUUID()
calendarId: string;
@IsString()
@MaxLength(500)
title: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(500)
location?: string;
@IsDateString()
startTime: string;
@IsDateString()
endTime: string;
@IsOptional()
@IsBoolean()
isAllDay?: boolean;
@IsOptional()
@IsString()
@MaxLength(100)
timezone?: string;
@IsOptional()
@IsString()
@MaxLength(500)
recurrenceRule?: string;
@IsOptional()
@IsDateString()
recurrenceEndDate?: string;
@IsOptional()
@IsString()
@MaxLength(7)
color?: string;
@IsOptional()
@IsIn(['confirmed', 'tentative', 'cancelled'])
status?: 'confirmed' | 'tentative' | 'cancelled';
@IsOptional()
@IsObject()
metadata?: EventMetadata;
}

View file

@ -0,0 +1,3 @@
export * from './create-event.dto';
export * from './update-event.dto';
export * from './query-events.dto';

View file

@ -0,0 +1,27 @@
import { IsOptional, IsDateString, IsArray, IsBoolean, IsString } from 'class-validator';
import { Transform } from 'class-transformer';
export class QueryEventsDto {
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
endDate?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
@Transform(({ value }) => (typeof value === 'string' ? value.split(',') : value))
calendarIds?: string[];
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
includeCancelled?: boolean;
@IsOptional()
@IsString()
search?: string;
}

View file

@ -0,0 +1,76 @@
import {
IsString,
IsOptional,
IsBoolean,
IsObject,
IsDateString,
IsUUID,
IsIn,
IsArray,
MaxLength,
} from 'class-validator';
import type { EventMetadata } from '../../db/schema/events.schema';
export class UpdateEventDto {
@IsOptional()
@IsUUID()
calendarId?: string;
@IsOptional()
@IsString()
@MaxLength(500)
title?: string;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
location?: string | null;
@IsOptional()
@IsDateString()
startTime?: string;
@IsOptional()
@IsDateString()
endTime?: string;
@IsOptional()
@IsBoolean()
isAllDay?: boolean;
@IsOptional()
@IsString()
@MaxLength(100)
timezone?: string;
@IsOptional()
@IsString()
@MaxLength(500)
recurrenceRule?: string | null;
@IsOptional()
@IsDateString()
recurrenceEndDate?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
recurrenceExceptions?: string[];
@IsOptional()
@IsString()
@MaxLength(7)
color?: string | null;
@IsOptional()
@IsIn(['confirmed', 'tentative', 'cancelled'])
status?: 'confirmed' | 'tentative' | 'cancelled';
@IsOptional()
@IsObject()
metadata?: EventMetadata;
}

View file

@ -0,0 +1,64 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { EventService } from './event.service';
import { CreateEventDto, UpdateEventDto, QueryEventsDto } from './dto';
@Controller('events')
@UseGuards(JwtAuthGuard)
export class EventController {
constructor(private readonly eventService: EventService) {}
@Get()
async queryEvents(@CurrentUser() user: CurrentUserData, @Query() query: QueryEventsDto) {
const events = await this.eventService.getEventsWithCalendar(user.userId, query);
return { events };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const event = await this.eventService.findByIdOrThrow(id, user.userId);
return { event };
}
@Get('calendar/:calendarId')
async findByCalendar(
@CurrentUser() user: CurrentUserData,
@Param('calendarId') calendarId: string,
@Query() query: QueryEventsDto
) {
const events = await this.eventService.findByCalendar(calendarId, user.userId, query);
return { events };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateEventDto) {
const event = await this.eventService.create(user.userId, dto);
return { event };
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateEventDto
) {
const event = await this.eventService.update(id, user.userId, dto);
return { event };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.eventService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { EventController } from './event.controller';
import { EventService } from './event.service';
import { CalendarModule } from '../calendar/calendar.module';
@Module({
imports: [CalendarModule],
controllers: [EventController],
providers: [EventService],
exports: [EventService],
})
export class EventModule {}

View file

@ -0,0 +1,183 @@
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
import { eq, and, gte, lte, inArray, or, ilike } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { events, type Event, type NewEvent } from '../db/schema/events.schema';
import { calendars } from '../db/schema/calendars.schema';
import { CalendarService } from '../calendar/calendar.service';
import { CreateEventDto, UpdateEventDto, QueryEventsDto } from './dto';
@Injectable()
export class EventService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private calendarService: CalendarService
) {}
async queryEvents(userId: string, query: QueryEventsDto): Promise<Event[]> {
const conditions = [eq(events.userId, userId)];
// Date range filter
if (query.startDate) {
conditions.push(gte(events.endTime, new Date(query.startDate)));
}
if (query.endDate) {
conditions.push(lte(events.startTime, new Date(query.endDate)));
}
// Calendar filter
if (query.calendarIds && query.calendarIds.length > 0) {
conditions.push(inArray(events.calendarId, query.calendarIds));
}
// Exclude cancelled unless requested
if (!query.includeCancelled) {
conditions.push(
or(eq(events.status, 'confirmed'), eq(events.status, 'tentative')) as any
);
}
// Search filter
if (query.search) {
conditions.push(
or(
ilike(events.title, `%${query.search}%`),
ilike(events.description, `%${query.search}%`)
) as any
);
}
return this.db
.select()
.from(events)
.where(and(...conditions))
.orderBy(events.startTime);
}
async findById(id: string, userId: string): Promise<Event | null> {
const result = await this.db
.select()
.from(events)
.where(and(eq(events.id, id), eq(events.userId, userId)));
return result[0] || null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Event> {
const event = await this.findById(id, userId);
if (!event) {
throw new NotFoundException(`Event with id ${id} not found`);
}
return event;
}
async findByCalendar(
calendarId: string,
userId: string,
query: QueryEventsDto
): Promise<Event[]> {
// Verify user owns the calendar
await this.calendarService.findByIdOrThrow(calendarId, userId);
return this.queryEvents(userId, {
...query,
calendarIds: [calendarId],
});
}
async create(userId: string, dto: CreateEventDto): Promise<Event> {
// Verify user owns the calendar
const calendar = await this.calendarService.findByIdOrThrow(dto.calendarId, userId);
const newEvent: NewEvent = {
calendarId: dto.calendarId,
userId,
title: dto.title,
description: dto.description,
location: dto.location,
startTime: new Date(dto.startTime),
endTime: new Date(dto.endTime),
isAllDay: dto.isAllDay ?? false,
timezone: dto.timezone || calendar.timezone || 'Europe/Berlin',
recurrenceRule: dto.recurrenceRule,
recurrenceEndDate: dto.recurrenceEndDate ? new Date(dto.recurrenceEndDate) : undefined,
color: dto.color,
status: dto.status || 'confirmed',
metadata: dto.metadata,
};
const [created] = await this.db.insert(events).values(newEvent).returning();
return created;
}
async update(id: string, userId: string, dto: UpdateEventDto): Promise<Event> {
const existingEvent = await this.findByIdOrThrow(id, userId);
// If changing calendar, verify user owns the new calendar
if (dto.calendarId && dto.calendarId !== existingEvent.calendarId) {
await this.calendarService.findByIdOrThrow(dto.calendarId, userId);
}
const updateData: Partial<NewEvent> = {
...dto,
startTime: dto.startTime ? new Date(dto.startTime) : undefined,
endTime: dto.endTime ? new Date(dto.endTime) : undefined,
recurrenceEndDate: dto.recurrenceEndDate ? new Date(dto.recurrenceEndDate) : undefined,
updatedAt: new Date(),
};
// Remove undefined values
Object.keys(updateData).forEach((key) => {
if (updateData[key as keyof typeof updateData] === undefined) {
delete updateData[key as keyof typeof updateData];
}
});
const [updated] = await this.db
.update(events)
.set(updateData)
.where(and(eq(events.id, id), eq(events.userId, userId)))
.returning();
return updated;
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db.delete(events).where(and(eq(events.id, id), eq(events.userId, userId)));
}
async getEventsWithCalendar(userId: string, query: QueryEventsDto) {
const conditions = [eq(events.userId, userId)];
if (query.startDate) {
conditions.push(gte(events.endTime, new Date(query.startDate)));
}
if (query.endDate) {
conditions.push(lte(events.startTime, new Date(query.endDate)));
}
if (query.calendarIds && query.calendarIds.length > 0) {
conditions.push(inArray(events.calendarId, query.calendarIds));
}
const result = await this.db
.select({
event: events,
calendar: {
id: calendars.id,
name: calendars.name,
color: calendars.color,
},
})
.from(events)
.leftJoin(calendars, eq(events.calendarId, calendars.id))
.where(and(...conditions))
.orderBy(events.startTime);
return result.map((r) => ({
...r.event,
calendar: r.calendar,
}));
}
}

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'calendar-backend',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,40 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for mobile and web apps
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:5179',
'http://localhost:8081',
'exp://localhost:8081',
'http://localhost:3001',
];
app.enableCors({
origin: corsOrigins,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
// Set global prefix for API routes
app.setGlobalPrefix('api/v1');
const port = process.env.PORT || 3014;
await app.listen(port);
console.log(`Calendar backend running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,19 @@
import { IsUUID, IsInt, IsOptional, IsBoolean, Min, Max } from 'class-validator';
export class CreateReminderDto {
@IsUUID()
eventId: string;
@IsInt()
@Min(0)
@Max(10080) // Max 1 week in minutes
minutesBefore: number;
@IsOptional()
@IsBoolean()
notifyPush?: boolean;
@IsOptional()
@IsBoolean()
notifyEmail?: boolean;
}

View file

@ -0,0 +1 @@
export * from './create-reminder.dto';

View file

@ -0,0 +1,46 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { ReminderService } from './reminder.service';
import { CreateReminderDto } from './dto';
@Controller()
@UseGuards(JwtAuthGuard)
export class ReminderController {
constructor(private readonly reminderService: ReminderService) {}
@Get('events/:eventId/reminders')
async findByEvent(
@CurrentUser() user: CurrentUserData,
@Param('eventId') eventId: string
) {
const reminders = await this.reminderService.findByEvent(eventId, user.userId);
return { reminders };
}
@Post('events/:eventId/reminders')
async create(
@CurrentUser() user: CurrentUserData,
@Param('eventId') eventId: string,
@Body() dto: Omit<CreateReminderDto, 'eventId'>
) {
const reminder = await this.reminderService.create(user.userId, {
...dto,
eventId,
});
return { reminder };
}
@Delete('reminders/:id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.reminderService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ReminderController } from './reminder.controller';
import { ReminderService } from './reminder.service';
import { EventModule } from '../event/event.module';
@Module({
imports: [EventModule],
controllers: [ReminderController],
providers: [ReminderService],
exports: [ReminderService],
})
export class ReminderModule {}

View file

@ -0,0 +1,158 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { eq, and, lte } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { reminders, type Reminder, type NewReminder } from '../db/schema/reminders.schema';
import { events } from '../db/schema/events.schema';
import { EventService } from '../event/event.service';
import { CreateReminderDto } from './dto';
@Injectable()
export class ReminderService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private eventService: EventService
) {}
async findByEvent(eventId: string, userId: string): Promise<Reminder[]> {
// Verify user owns the event
await this.eventService.findByIdOrThrow(eventId, userId);
return this.db
.select()
.from(reminders)
.where(and(eq(reminders.eventId, eventId), eq(reminders.userId, userId)));
}
async findById(id: string, userId: string): Promise<Reminder | null> {
const result = await this.db
.select()
.from(reminders)
.where(and(eq(reminders.id, id), eq(reminders.userId, userId)));
return result[0] || null;
}
async create(userId: string, dto: CreateReminderDto): Promise<Reminder> {
// Verify user owns the event and get event details
const event = await this.eventService.findByIdOrThrow(dto.eventId, userId);
// Calculate reminder time
const eventStartTime = new Date(event.startTime);
const reminderTime = new Date(eventStartTime.getTime() - dto.minutesBefore * 60 * 1000);
const newReminder: NewReminder = {
eventId: dto.eventId,
userId,
minutesBefore: dto.minutesBefore,
reminderTime,
notifyPush: dto.notifyPush ?? true,
notifyEmail: dto.notifyEmail ?? false,
status: 'pending',
};
const [created] = await this.db.insert(reminders).values(newReminder).returning();
return created;
}
async delete(id: string, userId: string): Promise<void> {
const reminder = await this.findById(id, userId);
if (!reminder) {
throw new NotFoundException(`Reminder with id ${id} not found`);
}
await this.db
.delete(reminders)
.where(and(eq(reminders.id, id), eq(reminders.userId, userId)));
}
async getPendingReminders(): Promise<Reminder[]> {
const now = new Date();
// Get reminders that are due within the next minute
const oneMinuteFromNow = new Date(now.getTime() + 60 * 1000);
return this.db
.select()
.from(reminders)
.where(
and(eq(reminders.status, 'pending'), lte(reminders.reminderTime, oneMinuteFromNow))
);
}
async markAsSent(id: string): Promise<void> {
await this.db
.update(reminders)
.set({ status: 'sent', sentAt: new Date() })
.where(eq(reminders.id, id));
}
async markAsFailed(id: string, error: string): Promise<void> {
await this.db.update(reminders).set({ status: 'failed' }).where(eq(reminders.id, id));
console.error(`Reminder ${id} failed:`, error);
}
/**
* Process pending reminders every minute
* In production, this would send push notifications and emails
*/
@Cron(CronExpression.EVERY_MINUTE)
async processReminders() {
const pendingReminders = await this.getPendingReminders();
for (const reminder of pendingReminders) {
try {
// Get event details for the notification
const eventResult = await this.db
.select()
.from(events)
.where(eq(events.id, reminder.eventId));
if (eventResult.length === 0) {
await this.markAsFailed(reminder.id, 'Event not found');
continue;
}
const event = eventResult[0];
// TODO: Implement actual notification sending
// For now, just log and mark as sent
console.log(`[Reminder] Event "${event.title}" starting in ${reminder.minutesBefore} minutes`);
if (reminder.notifyPush) {
// TODO: Send push notification via Expo Push API
console.log(` - Would send push notification to user ${reminder.userId}`);
}
if (reminder.notifyEmail) {
// TODO: Send email notification
console.log(` - Would send email to user ${reminder.userId}`);
}
await this.markAsSent(reminder.id);
} catch (error) {
await this.markAsFailed(reminder.id, (error as Error).message);
}
}
}
/**
* Update reminders when an event is updated
*/
async updateRemindersForEvent(eventId: string, newStartTime: Date): Promise<void> {
const eventReminders = await this.db
.select()
.from(reminders)
.where(and(eq(reminders.eventId, eventId), eq(reminders.status, 'pending')));
for (const reminder of eventReminders) {
const newReminderTime = new Date(
newStartTime.getTime() - reminder.minutesBefore * 60 * 1000
);
await this.db
.update(reminders)
.set({ reminderTime: newReminderTime })
.where(eq(reminders.id, reminder.id));
}
}
}

View file

@ -0,0 +1,21 @@
import { IsString, IsOptional, IsBoolean, IsIn, IsEmail, IsDateString, IsUUID } from 'class-validator';
export class CreateShareDto {
@IsUUID()
calendarId: string;
@IsOptional()
@IsEmail()
email?: string;
@IsIn(['read', 'write', 'admin'])
permission: 'read' | 'write' | 'admin';
@IsOptional()
@IsBoolean()
createLink?: boolean;
@IsOptional()
@IsDateString()
expiresAt?: string;
}

View file

@ -0,0 +1,2 @@
export * from './create-share.dto';
export * from './update-share.dto';

View file

@ -0,0 +1,11 @@
import { IsOptional, IsIn, IsDateString } from 'class-validator';
export class UpdateShareDto {
@IsOptional()
@IsIn(['read', 'write', 'admin'])
permission?: 'read' | 'write' | 'admin';
@IsOptional()
@IsDateString()
expiresAt?: string | null;
}

View file

@ -0,0 +1,94 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { ShareService } from './share.service';
import { CreateShareDto, UpdateShareDto } from './dto';
@Controller()
@UseGuards(JwtAuthGuard)
export class ShareController {
constructor(private readonly shareService: ShareService) {}
@Get('calendars/:calendarId/shares')
async findByCalendar(
@CurrentUser() user: CurrentUserData,
@Param('calendarId') calendarId: string
) {
const shares = await this.shareService.findByCalendar(calendarId, user.userId);
return { shares };
}
@Post('calendars/:calendarId/shares')
async create(
@CurrentUser() user: CurrentUserData,
@Param('calendarId') calendarId: string,
@Body() dto: Omit<CreateShareDto, 'calendarId'>
) {
const share = await this.shareService.create(user.userId, {
...dto,
calendarId,
});
return { share };
}
@Put('calendars/:calendarId/shares/:shareId')
async update(
@CurrentUser() user: CurrentUserData,
@Param('shareId') shareId: string,
@Body() dto: UpdateShareDto
) {
const share = await this.shareService.update(shareId, user.userId, dto);
return { share };
}
@Delete('calendars/:calendarId/shares/:shareId')
async delete(
@CurrentUser() user: CurrentUserData,
@Param('shareId') shareId: string
) {
await this.shareService.delete(shareId, user.userId);
return { success: true };
}
@Get('shares/invitations')
async getInvitations(@CurrentUser() user: CurrentUserData) {
// TODO: Get user email from auth service
const invitations = await this.shareService.findPendingInvitations(
user.userId,
user.email || ''
);
return { invitations };
}
@Post('shares/:shareId/accept')
async acceptInvitation(
@CurrentUser() user: CurrentUserData,
@Param('shareId') shareId: string
) {
const share = await this.shareService.acceptInvitation(shareId, user.userId);
return { share };
}
@Post('shares/:shareId/decline')
async declineInvitation(
@CurrentUser() user: CurrentUserData,
@Param('shareId') shareId: string
) {
const share = await this.shareService.declineInvitation(shareId, user.userId);
return { share };
}
@Get('shares/shared-with-me')
async getSharedCalendars(@CurrentUser() user: CurrentUserData) {
const shares = await this.shareService.getSharedCalendarsForUser(user.userId);
return { shares };
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ShareController } from './share.controller';
import { ShareService } from './share.service';
import { CalendarModule } from '../calendar/calendar.module';
@Module({
imports: [CalendarModule],
controllers: [ShareController],
providers: [ShareService],
exports: [ShareService],
})
export class ShareModule {}

View file

@ -0,0 +1,183 @@
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
import { eq, and, or } from 'drizzle-orm';
import { randomBytes } from 'crypto';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
calendarShares,
type CalendarShare,
type NewCalendarShare,
} from '../db/schema/calendar-shares.schema';
import { CalendarService } from '../calendar/calendar.service';
import { CreateShareDto, UpdateShareDto } from './dto';
@Injectable()
export class ShareService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private calendarService: CalendarService
) {}
async findByCalendar(calendarId: string, userId: string): Promise<CalendarShare[]> {
// Verify user owns the calendar
await this.calendarService.findByIdOrThrow(calendarId, userId);
return this.db
.select()
.from(calendarShares)
.where(eq(calendarShares.calendarId, calendarId));
}
async findById(id: string): Promise<CalendarShare | null> {
const result = await this.db
.select()
.from(calendarShares)
.where(eq(calendarShares.id, id));
return result[0] || null;
}
async findPendingInvitations(userId: string, email: string): Promise<CalendarShare[]> {
return this.db
.select()
.from(calendarShares)
.where(
and(
eq(calendarShares.status, 'pending'),
or(
eq(calendarShares.sharedWithUserId, userId),
eq(calendarShares.sharedWithEmail, email)
)
)
);
}
async create(userId: string, dto: CreateShareDto): Promise<CalendarShare> {
// Verify user owns the calendar
await this.calendarService.findByIdOrThrow(dto.calendarId, userId);
const newShare: NewCalendarShare = {
calendarId: dto.calendarId,
permission: dto.permission,
invitedBy: userId,
status: 'pending',
};
if (dto.createLink) {
// Create a shareable link
const token = randomBytes(32).toString('hex');
newShare.shareToken = token;
newShare.shareUrl = `/share/${token}`;
} else if (dto.email) {
// Share with specific email
newShare.sharedWithEmail = dto.email;
}
if (dto.expiresAt) {
newShare.expiresAt = new Date(dto.expiresAt);
}
const [created] = await this.db.insert(calendarShares).values(newShare).returning();
return created;
}
async update(id: string, userId: string, dto: UpdateShareDto): Promise<CalendarShare> {
const share = await this.findById(id);
if (!share) {
throw new NotFoundException(`Share with id ${id} not found`);
}
// Verify user owns the calendar
await this.calendarService.findByIdOrThrow(share.calendarId, userId);
const [updated] = await this.db
.update(calendarShares)
.set({
permission: dto.permission,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined,
updatedAt: new Date(),
})
.where(eq(calendarShares.id, id))
.returning();
return updated;
}
async delete(id: string, userId: string): Promise<void> {
const share = await this.findById(id);
if (!share) {
throw new NotFoundException(`Share with id ${id} not found`);
}
// Verify user owns the calendar
await this.calendarService.findByIdOrThrow(share.calendarId, userId);
await this.db.delete(calendarShares).where(eq(calendarShares.id, id));
}
async acceptInvitation(shareId: string, userId: string): Promise<CalendarShare> {
const share = await this.findById(shareId);
if (!share) {
throw new NotFoundException(`Invitation not found`);
}
if (share.status !== 'pending') {
throw new ForbiddenException('Invitation has already been processed');
}
const [updated] = await this.db
.update(calendarShares)
.set({
status: 'accepted',
sharedWithUserId: userId,
acceptedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(calendarShares.id, shareId))
.returning();
return updated;
}
async declineInvitation(shareId: string, userId: string): Promise<CalendarShare> {
const share = await this.findById(shareId);
if (!share) {
throw new NotFoundException(`Invitation not found`);
}
if (share.status !== 'pending') {
throw new ForbiddenException('Invitation has already been processed');
}
const [updated] = await this.db
.update(calendarShares)
.set({
status: 'declined',
sharedWithUserId: userId,
updatedAt: new Date(),
})
.where(eq(calendarShares.id, shareId))
.returning();
return updated;
}
async findByShareToken(token: string): Promise<CalendarShare | null> {
const result = await this.db
.select()
.from(calendarShares)
.where(eq(calendarShares.shareToken, token));
return result[0] || null;
}
async getSharedCalendarsForUser(userId: string): Promise<CalendarShare[]> {
return this.db
.select()
.from(calendarShares)
.where(
and(
eq(calendarShares.sharedWithUserId, userId),
eq(calendarShares.status, 'accepted')
)
);
}
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

32
apps/calendar/apps/landing/.gitignore vendored Normal file
View file

@ -0,0 +1,32 @@
# build output
dist/
.output/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
.env.local
# macOS-specific files
.DS_Store
# IDE
.idea/
.vscode/
*.swp
*.swo
# Wrangler
.wrangler/

View file

@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.astro",
"options": {
"parser": "astro"
}
}
]
}

View file

@ -0,0 +1,19 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
integrations: [tailwind()],
output: 'static',
build: {
inlineStylesheets: 'auto'
},
vite: {
resolve: {
alias: {
'@components': '/src/components',
'@layouts': '/src/layouts'
}
}
}
});

View file

@ -0,0 +1,35 @@
{
"name": "@calendar/landing",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev --port 4322",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"type-check": "astro check",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"clean": "rm -rf dist .astro node_modules"
},
"dependencies": {
"@astrojs/check": "^0.9.0",
"@manacore/shared-landing-ui": "workspace:*",
"astro": "^5.16.0",
"typescript": "^5.9.2"
},
"devDependencies": {
"@astrojs/tailwind": "^6.0.2",
"@tailwindcss/typography": "^0.5.18",
"@types/node": "^20.0.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-astro": "^1.0.0",
"prettier": "^3.6.2",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^3.4.0"
}
}

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#0ea5e9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>

After

Width:  |  Height:  |  Size: 363 B

View file

@ -0,0 +1,54 @@
---
// Call to Action section
---
<section class="relative overflow-hidden bg-dark-bg">
<!-- Background gradient -->
<div class="absolute inset-0 bg-gradient-to-r from-primary-950/30 via-dark-bg to-primary-950/30"></div>
<div class="container relative">
<div class="mx-auto max-w-3xl text-center">
<h2 class="mb-6 text-3xl font-bold md:text-4xl lg:text-5xl">
Bereit, deine Zeit zu organisieren?
</h2>
<p class="mb-10 text-lg text-gray-400">
Starte kostenlos und erlebe, wie einfach Zeitmanagement sein kann.
Keine Kreditkarte erforderlich.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a href="#" class="btn btn-primary text-lg">
Jetzt kostenlos starten
<svg class="ml-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</a>
<a href="#features" class="btn btn-secondary">
Mehr erfahren
</a>
</div>
<!-- Benefits list -->
<div class="mt-12 flex flex-wrap items-center justify-center gap-6 text-sm text-gray-500">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span>Kostenlos starten</span>
</div>
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span>Keine Kreditkarte</span>
</div>
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span>Jederzeit kündbar</span>
</div>
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,78 @@
---
// Features section for Calendar landing page
const features = [
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>`,
title: 'Mehrere Kalender',
description: 'Verwalte verschiedene Kalender für Arbeit, Privates, Familie und mehr - alles übersichtlich farbcodiert.'
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>`,
title: 'Kalender teilen',
description: 'Teile Kalender mit Familie, Freunden oder Kollegen. Vergib Lese- oder Bearbeitungsrechte.'
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>`,
title: 'CalDAV & iCal Sync',
description: 'Synchronisiere mit Google Calendar, Apple Calendar, Outlook und jedem CalDAV-kompatiblen Dienst.'
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
</svg>`,
title: 'Smarte Erinnerungen',
description: 'Nie wieder einen Termin verpassen. Push-Benachrichtigungen und E-Mail-Erinnerungen zur rechten Zeit.'
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>`,
title: 'Wiederkehrende Termine',
description: 'Erstelle einmalige oder wiederkehrende Termine mit flexiblen Wiederholungsregeln nach RFC 5545.'
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>`,
title: 'Mobile & Desktop',
description: 'Greife von überall auf deine Termine zu - Web-App, iOS und Android mit Offline-Support.'
}
];
---
<section id="features" class="bg-dark-surface">
<div class="container">
<!-- Section header -->
<div class="mx-auto mb-16 max-w-3xl text-center">
<span class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400">
Funktionen
</span>
<h2 class="mb-6 text-3xl font-bold md:text-4xl lg:text-5xl">
Alles was du brauchst
</h2>
<p class="text-lg text-gray-400">
Kalender bietet alle Funktionen, die du für effektives Zeitmanagement benötigst.
</p>
</div>
<!-- Features grid -->
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{features.map((feature) => (
<div class="group rounded-xl border border-dark-border bg-dark-card p-6 transition-all duration-300 hover:border-primary-500/50 hover:bg-dark-card/80">
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary-500/10 text-primary-400 transition-colors group-hover:bg-primary-500/20">
<Fragment set:html={feature.icon} />
</div>
<h3 class="mb-3 text-xl font-semibold">{feature.title}</h3>
<p class="text-gray-400">{feature.description}</p>
</div>
))}
</div>
</div>
</section>

View file

@ -0,0 +1,87 @@
---
// Footer component
const currentYear = new Date().getFullYear();
const links = {
product: [
{ name: 'Funktionen', href: '#features' },
{ name: 'Preise', href: '#pricing' },
{ name: 'Changelog', href: '/changelog' },
{ name: 'Roadmap', href: '/roadmap' }
],
legal: [
{ name: 'Impressum', href: '/impressum' },
{ name: 'Datenschutz', href: '/datenschutz' },
{ name: 'AGB', href: '/agb' }
],
support: [
{ name: 'FAQ', href: '/faq' },
{ name: 'Kontakt', href: '/kontakt' },
{ name: 'Status', href: '/status' }
]
};
---
<footer class="border-t border-dark-border bg-dark-bg py-12">
<div class="container">
<div class="grid gap-8 md:grid-cols-4">
<!-- Brand -->
<div class="md:col-span-1">
<div class="mb-4 flex items-center gap-2">
<svg class="h-8 w-8 text-primary-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<span class="text-xl font-bold">Kalender</span>
</div>
<p class="text-sm text-gray-500">
Smart Calendar Management für besseres Zeitmanagement.
</p>
</div>
<!-- Links -->
<div>
<h4 class="mb-4 font-semibold">Produkt</h4>
<ul class="space-y-2 text-sm text-gray-400">
{links.product.map((link) => (
<li>
<a href={link.href} class="transition-colors hover:text-white">{link.name}</a>
</li>
))}
</ul>
</div>
<div>
<h4 class="mb-4 font-semibold">Rechtliches</h4>
<ul class="space-y-2 text-sm text-gray-400">
{links.legal.map((link) => (
<li>
<a href={link.href} class="transition-colors hover:text-white">{link.name}</a>
</li>
))}
</ul>
</div>
<div>
<h4 class="mb-4 font-semibold">Support</h4>
<ul class="space-y-2 text-sm text-gray-400">
{links.support.map((link) => (
<li>
<a href={link.href} class="transition-colors hover:text-white">{link.name}</a>
</li>
))}
</ul>
</div>
</div>
<!-- Bottom bar -->
<div class="mt-12 flex flex-col items-center justify-between gap-4 border-t border-dark-border pt-8 md:flex-row">
<p class="text-sm text-gray-500">
&copy; {currentYear} Kalender. Alle Rechte vorbehalten.
</p>
<p class="text-sm text-gray-500">
Ein <a href="https://manacore.app" class="text-primary-400 hover:underline">Manacore</a> Produkt
</p>
</div>
</div>
</footer>

View file

@ -0,0 +1,106 @@
---
// Hero section for Calendar landing page
---
<section class="relative overflow-hidden py-20 md:py-32">
<!-- Background gradient -->
<div
class="absolute inset-0 bg-gradient-to-b from-primary-950/30 via-dark-bg to-dark-bg"
>
</div>
<!-- Grid pattern -->
<div
class="absolute inset-0 bg-[url('/grid.svg')] bg-center opacity-10"
>
</div>
<div class="container relative">
<div class="mx-auto max-w-4xl text-center">
<!-- Badge -->
<div class="mb-8 inline-flex items-center gap-2 rounded-full border border-primary-500/30 bg-primary-500/10 px-4 py-2 text-sm text-primary-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<span>Smart Kalender-Management</span>
</div>
<!-- Headline -->
<h1 class="mb-6 text-4xl font-bold leading-tight md:text-6xl lg:text-7xl">
Organisiere deine Zeit
<span class="gradient-text">intelligent</span>
</h1>
<!-- Subheadline -->
<p class="mx-auto mb-10 max-w-2xl text-lg text-gray-400 md:text-xl">
Persönliche Kalender, geteilte Termine, CalDAV-Synchronisation und smarte Erinnerungen - alles an einem Ort. Behalte den Überblick über dein Leben.
</p>
<!-- CTA Buttons -->
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a
href="#"
class="btn btn-primary group text-lg"
>
Kostenlos starten
<svg class="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</a>
<a
href="#features"
class="btn btn-secondary"
>
Funktionen entdecken
</a>
</div>
<!-- Social proof -->
<div class="mt-16 flex flex-col items-center gap-4">
<div class="flex -space-x-2">
{[1, 2, 3, 4, 5].map((i) => (
<div class="h-10 w-10 rounded-full border-2 border-dark-bg bg-gradient-to-br from-primary-400 to-primary-600"></div>
))}
</div>
<p class="text-sm text-gray-500">
<span class="font-semibold text-white">500+</span> Nutzer vertrauen Kalender
</p>
</div>
</div>
<!-- Preview mockup -->
<div class="relative mx-auto mt-16 max-w-5xl">
<div class="absolute -inset-4 rounded-2xl bg-gradient-to-r from-primary-500/20 via-transparent to-primary-500/20 blur-3xl"></div>
<div class="relative rounded-xl border border-dark-border bg-dark-card p-2 shadow-2xl">
<div class="flex gap-2 px-4 py-3">
<div class="h-3 w-3 rounded-full bg-red-500"></div>
<div class="h-3 w-3 rounded-full bg-yellow-500"></div>
<div class="h-3 w-3 rounded-full bg-green-500"></div>
</div>
<div class="aspect-[16/9] overflow-hidden rounded-lg bg-dark-surface">
<!-- Calendar preview placeholder -->
<div class="p-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-xl font-semibold">Dezember 2024</h3>
<div class="flex gap-2">
<button class="rounded-lg bg-dark-card px-3 py-1 text-sm">Tag</button>
<button class="rounded-lg bg-primary-500 px-3 py-1 text-sm">Woche</button>
<button class="rounded-lg bg-dark-card px-3 py-1 text-sm">Monat</button>
</div>
</div>
<div class="grid grid-cols-7 gap-2">
{['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((day) => (
<div class="text-center text-sm text-gray-500">{day}</div>
))}
{Array.from({ length: 35 }, (_, i) => (
<div class={`rounded-lg p-2 text-center text-sm ${i === 14 ? 'bg-primary-500 text-white' : 'bg-dark-card'}`}>
{((i % 31) + 1).toString()}
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,45 @@
---
import '../styles/global.css';
interface Props {
title?: string;
description?: string;
}
const {
title = 'Kalender - Smart Calendar Management',
description = 'Organize your time with Kalender. Personal calendars, shared events, CalDAV sync, and smart reminders. Start free today.',
} = Astro.props;
---
<!doctype html>
<html lang="de" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<!-- SEO Meta Tags -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<!-- Preconnect to Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>{title}</title>
</head>
<body class="antialiased">
<slot />
</body>
</html>

View file

@ -0,0 +1,218 @@
---
import Layout from '@layouts/Layout.astro';
import Hero from '@components/Hero.astro';
import Features from '@components/Features.astro';
import CTA from '@components/CTA.astro';
import Footer from '@components/Footer.astro';
// Try to import shared components if available
let StepsSection: any = null;
let PricingSection: any = null;
try {
const shared = await import('@manacore/shared-landing-ui/sections/StepsSection.astro');
StepsSection = shared.default;
} catch {
// Shared component not available
}
try {
const shared = await import('@manacore/shared-landing-ui/sections/PricingSection.astro');
PricingSection = shared.default;
} catch {
// Shared component not available
}
// Steps data
const steps = [
{
number: '1',
title: 'Kalender erstellen',
description:
'Erstelle persönliche oder geteilte Kalender für Arbeit, Familie oder Projekte. Vergib individuelle Farben.',
image: '/screenshots/create-calendar.png',
},
{
number: '2',
title: 'Termine hinzufügen',
description:
'Füge Termine mit Ort, Beschreibung und Wiederholungen hinzu. Setze Erinnerungen für wichtige Events.',
image: '/screenshots/add-event.png',
},
{
number: '3',
title: 'Synchronisieren',
description:
'Verbinde deine bestehenden Kalender via CalDAV oder iCal. Alle Termine an einem Ort.',
image: '/screenshots/sync.png',
},
];
// Pricing data
const pricingPlans = [
{
name: 'Free',
price: '0',
period: '/Monat',
description: 'Perfekt für Einzelpersonen',
features: [
{ text: '3 Kalender', included: true },
{ text: 'Unbegrenzte Termine', included: true },
{ text: 'Erinnerungen', included: true },
{ text: 'Web-App Zugang', included: true },
{ text: 'Kalender teilen', included: false },
{ text: 'CalDAV Sync', included: false },
],
cta: {
text: 'Kostenlos starten',
href: '#',
},
},
{
name: 'Pro',
price: '4,99',
period: '/Monat',
description: 'Für Power-User & Familien',
features: [
{ text: 'Unbegrenzte Kalender', included: true },
{ text: 'Unbegrenzte Termine', included: true },
{ text: 'Kalender teilen', included: true },
{ text: 'CalDAV & iCal Sync', included: true },
{ text: 'Mobile Apps', included: true },
{ text: 'Priority Support', included: true },
],
cta: {
text: 'Pro starten',
href: '#',
},
highlighted: true,
badge: 'Beliebt',
},
{
name: 'Team',
price: '9,99',
period: '/Monat',
description: 'Für Teams & Unternehmen',
features: [
{ text: 'Alles aus Pro', included: true },
{ text: 'Team-Verwaltung', included: true },
{ text: 'Admin-Dashboard', included: true },
{ text: 'API-Zugang', included: true },
{ text: 'SSO Integration', included: true },
{ text: 'SLA Garantie', included: true },
],
cta: {
text: 'Team erstellen',
href: '#',
},
},
];
---
<Layout
title="Kalender - Smart Calendar Management"
description="Organisiere deine Zeit intelligent. Persönliche Kalender, geteilte Termine, CalDAV-Synchronisation und smarte Erinnerungen. Kostenlos starten."
>
<Hero />
<Features />
{StepsSection && (
<StepsSection
id="how-it-works"
title="So einfach geht's"
subtitle="In drei Schritten zum organisierten Leben"
steps={steps}
showImages={false}
alternateLayout={true}
class="bg-dark-surface"
/>
)}
{!StepsSection && (
<section id="how-it-works" class="bg-dark-surface">
<div class="container">
<div class="mx-auto mb-16 max-w-3xl text-center">
<span class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400">
So funktioniert's
</span>
<h2 class="mb-6 text-3xl font-bold md:text-4xl">So einfach geht's</h2>
<p class="text-lg text-gray-400">In drei Schritten zum organisierten Leben</p>
</div>
<div class="grid gap-8 md:grid-cols-3">
{steps.map((step) => (
<div class="text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-500/20 text-2xl font-bold text-primary-400">
{step.number}
</div>
<h3 class="mb-3 text-xl font-semibold">{step.title}</h3>
<p class="text-gray-400">{step.description}</p>
</div>
))}
</div>
</div>
</section>
)}
{PricingSection && (
<PricingSection
id="pricing"
title="Einfache, transparente Preise"
subtitle="Starte kostenlos, upgrade wenn du mehr brauchst"
plans={pricingPlans}
class="bg-dark-bg"
/>
)}
{!PricingSection && (
<section id="pricing" class="bg-dark-bg">
<div class="container">
<div class="mx-auto mb-16 max-w-3xl text-center">
<span class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400">
Preise
</span>
<h2 class="mb-6 text-3xl font-bold md:text-4xl">Einfache, transparente Preise</h2>
<p class="text-lg text-gray-400">Starte kostenlos, upgrade wenn du mehr brauchst</p>
</div>
<div class="mx-auto grid max-w-5xl gap-8 md:grid-cols-3">
{pricingPlans.map((plan) => (
<div class={`relative rounded-xl border p-6 ${plan.highlighted ? 'border-primary-500 bg-primary-500/10' : 'border-dark-border bg-dark-card'}`}>
{plan.badge && (
<div class="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-primary-500 px-3 py-1 text-xs font-medium text-white">
{plan.badge}
</div>
)}
<h3 class="mb-2 text-xl font-semibold">{plan.name}</h3>
<p class="mb-4 text-sm text-gray-400">{plan.description}</p>
<div class="mb-6">
<span class="text-4xl font-bold">{plan.price}€</span>
<span class="text-gray-500">{plan.period}</span>
</div>
<ul class="mb-8 space-y-3">
{plan.features.map((feature) => (
<li class={`flex items-center gap-2 text-sm ${feature.included ? 'text-white' : 'text-gray-600'}`}>
{feature.included ? (
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
) : (
<svg class="h-5 w-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
)}
{feature.text}
</li>
))}
</ul>
<a href={plan.cta.href} class={`btn w-full ${plan.highlighted ? 'btn-primary' : 'btn-secondary'}`}>
{plan.cta.text}
</a>
</div>
))}
</div>
</div>
</section>
)}
<CTA />
<Footer />
</Layout>

View file

@ -0,0 +1,78 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-background-page: #0a0a0a;
--color-background-card: #1a1a1a;
--color-background-card-hover: #242424;
--color-text-primary: #ffffff;
--color-text-secondary: #d1d5db;
--color-text-muted: #9ca3af;
--color-border: #262626;
--color-border-hover: #3f3f3f;
--color-primary: #0ea5e9;
--color-primary-hover: #0284c7;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-dark-bg text-white;
margin: 0;
padding: 0;
overflow-x: hidden;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-dark-bg;
}
::-webkit-scrollbar-thumb {
@apply bg-dark-border rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-600;
}
/* Section padding */
section {
@apply py-16 md:py-24;
}
/* Container */
.container {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
/* Gradient text */
.gradient-text {
@apply bg-gradient-to-r from-primary-400 to-primary-600 bg-clip-text text-transparent;
}
/* Button styles */
.btn {
@apply inline-flex items-center justify-center rounded-lg px-6 py-3 font-medium transition-all duration-200;
}
.btn-primary {
@apply bg-primary-500 text-white hover:bg-primary-600;
}
.btn-secondary {
@apply border border-dark-border bg-dark-card text-white hover:bg-dark-surface;
}

View file

@ -0,0 +1,53 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}'
],
theme: {
extend: {
colors: {
// Calendar app theme - ocean blue
primary: {
DEFAULT: '#0ea5e9',
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49'
},
dark: {
bg: '#0a0a0a',
surface: '#111111',
card: '#1a1a1a',
border: '#262626'
},
// CSS variable mappings for shared-landing-ui compatibility
background: {
page: 'var(--color-background-page, #0a0a0a)',
card: 'var(--color-background-card, #1a1a1a)',
'card-hover': 'var(--color-background-card-hover, #242424)'
},
text: {
primary: 'var(--color-text-primary, #ffffff)',
secondary: 'var(--color-text-secondary, #d1d5db)',
muted: 'var(--color-text-muted, #9ca3af)'
},
border: {
DEFAULT: 'var(--color-border, #262626)',
hover: 'var(--color-border-hover, #3f3f3f)'
}
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif']
}
}
},
plugins: [require('@tailwindcss/typography')]
};

View file

@ -0,0 +1,10 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"]
}
}
}

View file

@ -0,0 +1,49 @@
{
"name": "@calendar/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"type-check": "echo 'Skipping type-check for now'"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^20.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"@calendar/shared": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
"@manacore/shared-feedback-ui": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"date-fns": "^4.1.0",
"svelte-i18n": "^4.0.1"
},
"type": "module"
}

View file

@ -0,0 +1,218 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../../packages/shared/src";
/* Calendar-specific CSS Variables */
@layer base {
:root {
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border Radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* Calendar-specific */
--hour-height: 60px;
--day-header-height: 48px;
--time-column-width: 64px;
}
}
/* Calendar Grid Styles */
@layer components {
/* Hour slot in day/week view */
.hour-slot {
height: var(--hour-height);
border-bottom: 1px solid hsl(var(--border) / 0.5);
position: relative;
}
.hour-slot:hover {
background-color: hsl(var(--muted) / 0.3);
}
/* Event card in calendar */
.event-card {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-radius: var(--radius-sm);
padding: 2px 6px;
font-size: 0.75rem;
overflow: hidden;
cursor: pointer;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.event-card:hover {
transform: scale(1.02);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
/* Day cell in month view */
.day-cell {
min-height: 100px;
border: 1px solid hsl(var(--border));
padding: var(--spacing-xs);
transition: background-color var(--transition-fast);
}
.day-cell:hover {
background-color: hsl(var(--muted) / 0.3);
}
.day-cell.today {
background-color: hsl(var(--primary) / 0.1);
}
.day-cell.other-month {
opacity: 0.5;
}
/* Time indicator (current time line) */
.time-indicator {
position: absolute;
left: 0;
right: 0;
height: 2px;
background-color: hsl(var(--destructive));
z-index: 10;
}
.time-indicator::before {
content: '';
position: absolute;
left: -4px;
top: -4px;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: hsl(var(--destructive));
}
/* Mini calendar */
.mini-calendar {
font-size: 0.875rem;
}
.mini-calendar .day {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
cursor: pointer;
transition: all var(--transition-fast);
}
.mini-calendar .day:hover {
background-color: hsl(var(--muted));
}
.mini-calendar .day.today {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.mini-calendar .day.selected {
border: 2px solid hsl(var(--primary));
}
/* Card styles */
.card {
background-color: hsl(var(--card));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 1px solid hsl(var(--border));
}
/* Button styles */
.btn {
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-md);
font-weight: 500;
transition: all var(--transition-base);
cursor: pointer;
border: none;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover {
background: hsl(var(--primary) / 0.9);
}
.btn-secondary {
background: hsl(var(--secondary));
color: hsl(var(--secondary-foreground));
}
.btn-secondary:hover {
background: hsl(var(--secondary) / 0.8);
}
.btn-ghost {
background: transparent;
color: hsl(var(--foreground));
}
.btn-ghost:hover {
background: hsl(var(--muted));
}
/* Input styles */
.input {
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid hsl(var(--border));
border-radius: var(--radius-md);
background-color: hsl(var(--background));
color: hsl(var(--foreground));
transition: border-color var(--transition-fast);
width: 100%;
}
.input:focus {
outline: none;
border-color: hsl(var(--primary));
}
}
/* Scrollbar styling */
@layer utilities {
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.3);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5);
}
}

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Calendar</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,34 @@
/**
* Calendar API Client
*/
import { fetchApi } from './client';
import type { Calendar, CreateCalendarInput, UpdateCalendarInput } from '@calendar/shared';
export async function getCalendars() {
return fetchApi<Calendar[]>('/calendars');
}
export async function getCalendar(id: string) {
return fetchApi<Calendar>(`/calendars/${id}`);
}
export async function createCalendar(data: CreateCalendarInput) {
return fetchApi<Calendar>('/calendars', {
method: 'POST',
body: data,
});
}
export async function updateCalendar(id: string, data: UpdateCalendarInput) {
return fetchApi<Calendar>(`/calendars/${id}`, {
method: 'PUT',
body: data,
});
}
export async function deleteCalendar(id: string) {
return fetchApi<void>(`/calendars/${id}`, {
method: 'DELETE',
});
}

View file

@ -0,0 +1,67 @@
/**
* API Client for Calendar Backend
*/
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
type FetchOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
token?: string;
isFormData?: boolean;
};
export async function fetchApi<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<{ data: T | null; error: Error | null }> {
const { method = 'GET', body, token, isFormData = false } = options;
let authToken = token;
if (!authToken && browser) {
authToken = localStorage.getItem('@auth/appToken') || undefined;
}
try {
const headers: Record<string, string> = {};
// Don't set Content-Type for FormData - browser sets it automatically with boundary
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
method,
headers,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `API error: ${response.status}`),
};
}
// Handle empty responses (204 No Content)
if (response.status === 204) {
return { data: null, error: null };
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
}

View file

@ -0,0 +1,51 @@
/**
* Events API Client
*/
import { fetchApi } from './client';
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
export interface QueryEventsParams {
startDate: string;
endDate: string;
calendarIds?: string[];
}
export async function getEvents(params: QueryEventsParams) {
const searchParams = new URLSearchParams({
startDate: params.startDate,
endDate: params.endDate,
});
if (params.calendarIds?.length) {
searchParams.set('calendarIds', params.calendarIds.join(','));
}
return fetchApi<CalendarEvent[]>(`/events?${searchParams.toString()}`);
}
export async function getEvent(id: string) {
return fetchApi<CalendarEvent>(`/events/${id}`);
}
export async function getEventsByCalendar(calendarId: string) {
return fetchApi<CalendarEvent[]>(`/events/calendar/${calendarId}`);
}
export async function createEvent(data: CreateEventInput) {
return fetchApi<CalendarEvent>('/events', {
method: 'POST',
body: data,
});
}
export async function updateEvent(id: string, data: UpdateEventInput) {
return fetchApi<CalendarEvent>(`/events/${id}`, {
method: 'PUT',
body: data,
});
}
export async function deleteEvent(id: string) {
return fetchApi<void>(`/events/${id}`, {
method: 'DELETE',
});
}

View file

@ -0,0 +1,23 @@
/**
* Reminders API Client
*/
import { fetchApi } from './client';
import type { Reminder, CreateReminderInput } from '@calendar/shared';
export async function getReminders(eventId: string) {
return fetchApi<Reminder[]>(`/events/${eventId}/reminders`);
}
export async function createReminder(eventId: string, data: CreateReminderInput) {
return fetchApi<Reminder>(`/events/${eventId}/reminders`, {
method: 'POST',
body: data,
});
}
export async function deleteReminder(id: string) {
return fetchApi<void>(`/reminders/${id}`, {
method: 'DELETE',
});
}

View file

@ -0,0 +1,42 @@
/**
* Calendar Shares API Client
*/
import { fetchApi } from './client';
import type { CalendarShare, CreateShareInput, UpdateShareInput } from '@calendar/shared';
export async function getShares(calendarId: string) {
return fetchApi<CalendarShare[]>(`/calendars/${calendarId}/shares`);
}
export async function createShare(calendarId: string, data: CreateShareInput) {
return fetchApi<CalendarShare>(`/calendars/${calendarId}/shares`, {
method: 'POST',
body: data,
});
}
export async function acceptShare(shareId: string) {
return fetchApi<CalendarShare>(`/shares/${shareId}/accept`, {
method: 'POST',
});
}
export async function declineShare(shareId: string) {
return fetchApi<void>(`/shares/${shareId}/decline`, {
method: 'POST',
});
}
export async function updateShare(shareId: string, data: UpdateShareInput) {
return fetchApi<CalendarShare>(`/shares/${shareId}`, {
method: 'PUT',
body: data,
});
}
export async function deleteShare(shareId: string) {
return fetchApi<void>(`/shares/${shareId}`, {
method: 'DELETE',
});
}

View file

@ -0,0 +1,187 @@
<script lang="ts">
import { toast, type Toast } from '$lib/stores/toast';
import { fly } from 'svelte/transition';
let toasts = $state<Toast[]>([]);
toast.subscribe((value) => {
toasts = value;
});
function handleClose(id: string) {
toast.remove(id);
}
function getIcon(type: Toast['type']) {
switch (type) {
case 'success':
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>`;
case 'error':
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>`;
case 'warning':
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>`;
case 'info':
default:
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>`;
}
}
</script>
<div class="toast-container">
{#each toasts as toastItem (toastItem.id)}
<div
class="toast toast-{toastItem.type}"
transition:fly={{ y: 20, duration: 300 }}
role="alert"
>
<div class="toast-icon">
{@html getIcon(toastItem.type)}
</div>
<p class="toast-message">{toastItem.message}</p>
<button
class="toast-close"
onclick={() => handleClose(toastItem.id)}
aria-label="Close notification"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{/each}
</div>
<style>
.toast-container {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
background: hsl(var(--card));
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid hsl(var(--border));
min-width: 300px;
max-width: 400px;
pointer-events: auto;
backdrop-filter: blur(10px);
}
.toast-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.toast-success {
border-left: 4px solid hsl(142.1 76.2% 36.3%);
}
.toast-success .toast-icon {
color: hsl(142.1 76.2% 36.3%);
}
.toast-error {
border-left: 4px solid hsl(var(--destructive));
}
.toast-error .toast-icon {
color: hsl(var(--destructive));
}
.toast-warning {
border-left: 4px solid hsl(47.9 95.8% 53.1%);
}
.toast-warning .toast-icon {
color: hsl(47.9 95.8% 53.1%);
}
.toast-info {
border-left: 4px solid hsl(var(--primary));
}
.toast-info .toast-icon {
color: hsl(var(--primary));
}
.toast-message {
flex: 1;
margin: 0;
font-size: 0.9375rem;
color: hsl(var(--foreground));
line-height: 1.5;
}
.toast-close {
flex-shrink: 0;
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: hsl(var(--muted-foreground));
transition: all 150ms ease;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.toast-close:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
@media (max-width: 768px) {
.toast-container {
bottom: 6rem;
right: 1rem;
left: 1rem;
align-items: stretch;
}
.toast {
min-width: auto;
max-width: none;
}
.toast-message {
font-size: 0.875rem;
}
}
</style>

View file

@ -0,0 +1,164 @@
<script lang="ts">
import { viewStore } from '$lib/stores/view.svelte';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import type { CalendarViewType } from '@calendar/shared';
// View type labels
const viewLabels: Record<CalendarViewType, string> = {
day: 'Tag',
week: 'Woche',
month: 'Monat',
year: 'Jahr',
agenda: 'Agenda',
};
// Format title based on view type
let title = $derived.by(() => {
const date = viewStore.currentDate;
switch (viewStore.viewType) {
case 'day':
return format(date, 'EEEE, d. MMMM yyyy', { locale: de });
case 'week':
const weekStart = viewStore.viewRange.start;
const weekEnd = viewStore.viewRange.end;
if (weekStart.getMonth() === weekEnd.getMonth()) {
return format(weekStart, 'd.', { locale: de }) + ' - ' + format(weekEnd, 'd. MMMM yyyy', { locale: de });
}
return format(weekStart, 'd. MMM', { locale: de }) + ' - ' + format(weekEnd, 'd. MMM yyyy', { locale: de });
case 'month':
return format(date, 'MMMM yyyy', { locale: de });
case 'year':
return format(date, 'yyyy', { locale: de });
case 'agenda':
return 'Agenda';
default:
return format(date, 'MMMM yyyy', { locale: de });
}
});
function handleViewChange(type: CalendarViewType) {
viewStore.setViewType(type);
}
</script>
<header class="calendar-header">
<div class="header-left">
<button class="btn btn-ghost" onclick={() => viewStore.goToToday()}>
Heute
</button>
<div class="nav-buttons">
<button class="btn btn-ghost btn-icon" onclick={() => viewStore.goToPrevious()} aria-label="Zurück">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button class="btn btn-ghost btn-icon" onclick={() => viewStore.goToNext()} aria-label="Weiter">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<h1 class="header-title">{title}</h1>
</div>
<div class="header-right">
<div class="view-selector">
{#each (['day', 'week', 'month'] as const) as type}
<button
class="view-btn"
class:active={viewStore.viewType === type}
onclick={() => handleViewChange(type)}
>
{viewLabels[type]}
</button>
{/each}
</div>
</div>
</header>
<style>
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--card));
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-buttons {
display: flex;
gap: 0.25rem;
}
.header-title {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.view-selector {
display: flex;
background: hsl(var(--muted));
border-radius: var(--radius-md);
padding: 0.25rem;
}
.view-btn {
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition: all 150ms ease;
}
.view-btn:hover {
color: hsl(var(--foreground));
}
.view-btn.active {
background: hsl(var(--background));
color: hsl(var(--foreground));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.btn-icon {
padding: 0.5rem;
}
@media (max-width: 640px) {
.calendar-header {
flex-direction: column;
gap: 1rem;
}
.header-left {
width: 100%;
justify-content: space-between;
}
.header-title {
font-size: 1rem;
}
}
</style>

View file

@ -0,0 +1,122 @@
<script lang="ts">
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { goto } from '$app/navigation';
function handleToggle(calendarId: string) {
calendarsStore.toggleVisibility(calendarId);
}
function handleAddCalendar() {
goto('/calendars/new');
}
</script>
<div class="calendar-sidebar-section">
<div class="section-header">
<h3 class="section-title">Meine Kalender</h3>
<button class="add-btn" onclick={handleAddCalendar} aria-label="Kalender hinzufügen">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<div class="calendar-list">
{#each calendarsStore.calendars as calendar}
<label class="calendar-item">
<input
type="checkbox"
checked={calendar.isVisible}
onchange={() => handleToggle(calendar.id)}
style="accent-color: {calendar.color}"
/>
<span class="color-dot" style="background-color: {calendar.color}"></span>
<span class="calendar-name">{calendar.name}</span>
</label>
{/each}
{#if calendarsStore.calendars.length === 0}
<p class="empty-message">Keine Kalender vorhanden</p>
{/if}
</div>
</div>
<style>
.calendar-sidebar-section {
background: hsl(var(--card));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--border));
padding: 1rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
.add-btn {
padding: 0.25rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
color: hsl(var(--muted-foreground));
}
.add-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.calendar-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.calendar-item {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.25rem;
border-radius: var(--radius-sm);
}
.calendar-item:hover {
background: hsl(var(--muted) / 0.5);
}
.calendar-item input {
width: 16px;
height: 16px;
cursor: pointer;
}
.color-dot {
width: 12px;
height: 12px;
border-radius: var(--radius-full);
}
.calendar-name {
font-size: 0.875rem;
color: hsl(var(--foreground));
}
.empty-message {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
text-align: center;
padding: 1rem;
}
</style>

View file

@ -0,0 +1,232 @@
<script lang="ts">
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { goto } from '$app/navigation';
import {
format,
isToday,
parseISO,
differenceInMinutes,
} from 'date-fns';
import { de } from 'date-fns/locale';
let hours = Array.from({ length: 24 }, (_, i) => i);
// Current time indicator position
let now = $state(new Date());
let currentTimePosition = $derived.by(() => {
const minutes = now.getHours() * 60 + now.getMinutes();
return (minutes / (24 * 60)) * 100;
});
// Update current time every minute
$effect(() => {
const interval = setInterval(() => {
now = new Date();
}, 60000);
return () => clearInterval(interval);
});
let timedEvents = $derived(
eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay)
);
let allDayEvents = $derived(
eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => e.isAllDay)
);
function getEventStyle(event: any) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const startMinutes = start.getHours() * 60 + start.getMinutes();
const duration = differenceInMinutes(end, start);
const top = (startMinutes / (24 * 60)) * 100;
const height = Math.max((duration / (24 * 60)) * 100, 2);
const color = calendarsStore.getColor(event.calendarId);
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
}
function handleEventClick(event: any) {
goto(`/event/${event.id}`);
}
function handleSlotClick(hour: number) {
const startTime = new Date(viewStore.currentDate);
startTime.setHours(hour, 0, 0, 0);
goto(`/event/new?start=${startTime.toISOString()}`);
}
</script>
<div class="day-view">
<!-- All-day events -->
{#if allDayEvents.length > 0}
<div class="all-day-section">
<div class="time-gutter">
<span class="all-day-label">Ganztägig</span>
</div>
<div class="all-day-events">
{#each allDayEvents as event}
<button
class="all-day-event"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={() => handleEventClick(event)}
>
{event.title}
</button>
{/each}
</div>
</div>
{/if}
<!-- Time grid -->
<div class="time-grid scrollbar-thin">
<div class="time-column">
{#each hours as hour}
<div class="time-label">
{hour.toString().padStart(2, '0')}:00
</div>
{/each}
</div>
<div class="day-column" class:today={isToday(viewStore.currentDate)}>
{#each hours as hour}
<button
class="hour-slot"
onclick={() => handleSlotClick(hour)}
aria-label={`${hour}:00 Uhr`}
></button>
{/each}
<!-- Events -->
{#each timedEvents as event}
<button
class="event-card"
style={getEventStyle(event)}
onclick={() => handleEventClick(event)}
>
<span class="event-time">
{format(typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, 'HH:mm')} -
{format(typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, 'HH:mm')}
</span>
<span class="event-title">{event.title}</span>
{#if event.location}
<span class="event-location">{event.location}</span>
{/if}
</button>
{/each}
<!-- Current time indicator -->
{#if isToday(viewStore.currentDate)}
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
{/if}
</div>
</div>
</div>
<style>
.day-view {
display: flex;
flex-direction: column;
height: 100%;
}
.all-day-section {
display: flex;
border-bottom: 1px solid hsl(var(--border));
padding: 0.5rem 0;
}
.all-day-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.all-day-events {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 0 0.5rem;
}
.all-day-event {
padding: 4px 8px;
font-size: 0.875rem;
color: white;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
}
.time-grid {
flex: 1;
display: flex;
overflow-y: auto;
}
.time-column {
width: var(--time-column-width);
flex-shrink: 0;
}
.time-label {
height: var(--hour-height);
padding-right: 0.5rem;
text-align: right;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
position: relative;
top: -0.5em;
}
.time-gutter {
width: var(--time-column-width);
flex-shrink: 0;
padding-right: 0.5rem;
text-align: right;
}
.day-column {
flex: 1;
position: relative;
border-left: 1px solid hsl(var(--border));
}
.day-column.today {
background: hsl(var(--primary) / 0.05);
}
.event-card {
position: absolute;
left: 4px;
right: 4px;
color: white;
border: none;
text-align: left;
cursor: pointer;
z-index: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.event-time {
font-size: 0.75rem;
opacity: 0.9;
}
.event-title {
font-size: 0.875rem;
font-weight: 500;
}
.event-location {
font-size: 0.75rem;
opacity: 0.8;
}
</style>

View file

@ -0,0 +1,165 @@
<script lang="ts">
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameMonth,
isToday,
isSameDay,
addMonths,
subMonths,
} from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
selectedDate: Date;
onDateSelect: (date: Date) => void;
}
let { selectedDate, onDateSelect }: Props = $props();
let currentMonth = $state(new Date());
// Get all days to display
let calendarDays = $derived.by(() => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
return eachDayOfInterval({ start: calendarStart, end: calendarEnd });
});
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function previousMonth() {
currentMonth = subMonths(currentMonth, 1);
}
function nextMonth() {
currentMonth = addMonths(currentMonth, 1);
}
function handleDayClick(day: Date) {
onDateSelect(day);
}
</script>
<div class="mini-calendar card">
<div class="calendar-header">
<button class="nav-btn" onclick={previousMonth} aria-label="Vorheriger Monat">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span class="month-label">{format(currentMonth, 'MMMM yyyy', { locale: de })}</span>
<button class="nav-btn" onclick={nextMonth} aria-label="Nächster Monat">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<div class="weekday-row">
{#each weekDays as day}
<span class="weekday">{day}</span>
{/each}
</div>
<div class="days-grid">
{#each calendarDays as day}
<button
class="day"
class:other-month={!isSameMonth(day, currentMonth)}
class:today={isToday(day)}
class:selected={isSameDay(day, selectedDate)}
onclick={() => handleDayClick(day)}
>
{format(day, 'd')}
</button>
{/each}
</div>
</div>
<style>
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.month-label {
font-weight: 600;
font-size: 0.875rem;
}
.nav-btn {
padding: 0.25rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
color: hsl(var(--muted-foreground));
}
.nav-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.weekday-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 0.25rem;
}
.weekday {
text-align: center;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
font-weight: 500;
}
.days-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.day {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
cursor: pointer;
font-size: 0.75rem;
border: none;
background: transparent;
color: hsl(var(--foreground));
transition: all var(--transition-fast);
}
.day:hover {
background: hsl(var(--muted));
}
.day.other-month {
color: hsl(var(--muted-foreground));
opacity: 0.5;
}
.day.today {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.day.selected {
border: 2px solid hsl(var(--primary));
}
</style>

View file

@ -0,0 +1,246 @@
<script lang="ts">
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { goto } from '$app/navigation';
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameMonth,
isToday,
isSameDay,
} from 'date-fns';
import { de } from 'date-fns/locale';
// Get all days to display in the month grid (including days from prev/next months)
let calendarDays = $derived.by(() => {
const monthStart = startOfMonth(viewStore.currentDate);
const monthEnd = endOfMonth(viewStore.currentDate);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
return eachDayOfInterval({ start: calendarStart, end: calendarEnd });
});
// Week day headers
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
// Group days into weeks
let weeks = $derived.by(() => {
const result: Date[][] = [];
for (let i = 0; i < calendarDays.length; i += 7) {
result.push(calendarDays.slice(i, i + 7));
}
return result;
});
function getEventsForDay(day: Date) {
return eventsStore.getEventsForDay(day).slice(0, 3); // Max 3 events shown
}
function handleDayClick(day: Date) {
viewStore.setDate(day);
viewStore.setViewType('day');
}
function handleEventClick(event: any, e: MouseEvent) {
e.stopPropagation();
goto(`/event/${event.id}`);
}
function handleMoreClick(day: Date, e: MouseEvent) {
e.stopPropagation();
viewStore.setDate(day);
viewStore.setViewType('day');
}
</script>
<div class="month-view">
<!-- Week day headers -->
<div class="weekday-headers">
{#each weekDays as day}
<div class="weekday-header">{day}</div>
{/each}
</div>
<!-- Calendar grid -->
<div class="calendar-grid">
{#each weeks as week}
<div class="week-row">
{#each week as day}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day-cell"
class:other-month={!isSameMonth(day, viewStore.currentDate)}
class:today={isToday(day)}
onclick={() => handleDayClick(day)}
onkeydown={(e) => e.key === 'Enter' && handleDayClick(day)}
role="button"
tabindex="0"
>
<span class="day-number" class:today={isToday(day)}>
{format(day, 'd')}
</span>
<div class="day-events">
{#each getEventsForDay(day) as event}
<button
class="event-pill"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={(e) => handleEventClick(event, e)}
>
{#if !event.isAllDay}
<span class="event-time">{format(typeof event.startTime === 'string' ? new Date(event.startTime) : event.startTime, 'HH:mm')}</span>
{/if}
<span class="event-title">{event.title}</span>
</button>
{/each}
{#if eventsStore.getEventsForDay(day).length > 3}
<button
class="more-events"
onclick={(e) => handleMoreClick(day, e)}
>
+{eventsStore.getEventsForDay(day).length - 3} mehr
</button>
{/if}
</div>
</div>
{/each}
</div>
{/each}
</div>
</div>
<style>
.month-view {
display: flex;
flex-direction: column;
height: 100%;
}
.weekday-headers {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-bottom: 1px solid hsl(var(--border));
}
.weekday-header {
padding: 0.75rem;
text-align: center;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
}
.calendar-grid {
flex: 1;
display: flex;
flex-direction: column;
}
.week-row {
flex: 1;
display: grid;
grid-template-columns: repeat(7, 1fr);
min-height: 100px;
}
.day-cell {
border: 1px solid hsl(var(--border));
border-top: none;
border-left: none;
padding: var(--spacing-xs);
background: transparent;
cursor: pointer;
text-align: left;
display: flex;
flex-direction: column;
transition: background-color var(--transition-fast);
}
.day-cell:first-child {
border-left: 1px solid hsl(var(--border));
}
.day-cell:hover {
background-color: hsl(var(--muted) / 0.3);
}
.day-cell.today {
background-color: hsl(var(--primary) / 0.1);
}
.day-cell.other-month {
opacity: 0.5;
}
.day-number {
font-size: 0.875rem;
font-weight: 500;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
margin-bottom: 0.25rem;
}
.day-number.today {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.day-events {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.event-pill {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
font-size: 0.75rem;
color: white;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-time {
font-size: 0.65rem;
opacity: 0.9;
}
.event-title {
overflow: hidden;
text-overflow: ellipsis;
}
.more-events {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
cursor: pointer;
padding: 2px;
text-align: left;
}
.more-events:hover {
color: hsl(var(--primary));
}
</style>

View file

@ -0,0 +1,291 @@
<script lang="ts">
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { goto } from '$app/navigation';
import {
format,
eachDayOfInterval,
eachHourOfInterval,
startOfDay,
endOfDay,
isSameDay,
isToday,
parseISO,
differenceInMinutes,
} from 'date-fns';
import { de } from 'date-fns/locale';
// Generate days of the week
let days = $derived(
eachDayOfInterval({
start: viewStore.viewRange.start,
end: viewStore.viewRange.end,
})
);
// Generate hours (0-23)
let hours = Array.from({ length: 24 }, (_, i) => i);
// Current time indicator position
let now = $state(new Date());
let currentTimePosition = $derived.by(() => {
const minutes = now.getHours() * 60 + now.getMinutes();
return (minutes / (24 * 60)) * 100;
});
// Update current time every minute
$effect(() => {
const interval = setInterval(() => {
now = new Date();
}, 60000);
return () => clearInterval(interval);
});
function getEventsForDay(day: Date) {
return eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay);
}
function getAllDayEventsForDay(day: Date) {
return eventsStore.getEventsForDay(day).filter((e) => e.isAllDay);
}
function getEventStyle(event: any) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const startMinutes = start.getHours() * 60 + start.getMinutes();
const duration = differenceInMinutes(end, start);
const top = (startMinutes / (24 * 60)) * 100;
const height = Math.max((duration / (24 * 60)) * 100, 2); // Min 2% height
const color = calendarsStore.getColor(event.calendarId);
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
}
function handleEventClick(event: any) {
goto(`/event/${event.id}`);
}
function handleSlotClick(day: Date, hour: number) {
const startTime = new Date(day);
startTime.setHours(hour, 0, 0, 0);
goto(`/event/new?start=${startTime.toISOString()}`);
}
</script>
<div class="week-view">
<!-- All-day events row -->
<div class="all-day-row">
<div class="time-gutter"></div>
{#each days as day}
<div class="all-day-cell">
{#each getAllDayEventsForDay(day) as event}
<button
class="all-day-event"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={() => handleEventClick(event)}
>
{event.title}
</button>
{/each}
</div>
{/each}
</div>
<!-- Day headers -->
<div class="day-headers">
<div class="time-gutter"></div>
{#each days as day}
<div class="day-header" class:today={isToday(day)}>
<span class="day-name">{format(day, 'EEE', { locale: de })}</span>
<span class="day-number" class:today={isToday(day)}>{format(day, 'd')}</span>
</div>
{/each}
</div>
<!-- Time grid -->
<div class="time-grid scrollbar-thin">
<!-- Time column -->
<div class="time-column">
{#each hours as hour}
<div class="time-label">
{hour.toString().padStart(2, '0')}:00
</div>
{/each}
</div>
<!-- Day columns -->
<div class="days-container">
{#each days as day, dayIndex}
<div class="day-column" class:today={isToday(day)}>
{#each hours as hour}
<button
class="hour-slot"
onclick={() => handleSlotClick(day, hour)}
aria-label={`${format(day, 'EEEE', { locale: de })} ${hour}:00 Uhr`}
></button>
{/each}
<!-- Events -->
{#each getEventsForDay(day) as event}
<button
class="event-card"
style={getEventStyle(event)}
onclick={() => handleEventClick(event)}
>
<span class="event-time">
{format(typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, 'HH:mm')}
</span>
<span class="event-title">{event.title}</span>
</button>
{/each}
<!-- Current time indicator -->
{#if isToday(day)}
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
{/if}
</div>
{/each}
</div>
</div>
</div>
<style>
.week-view {
display: flex;
flex-direction: column;
height: 100%;
}
.all-day-row {
display: flex;
border-bottom: 1px solid hsl(var(--border));
min-height: 32px;
}
.all-day-cell {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 4px;
border-left: 1px solid hsl(var(--border));
}
.all-day-event {
padding: 2px 6px;
font-size: 0.75rem;
color: white;
border-radius: var(--radius-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: none;
cursor: pointer;
}
.day-headers {
display: flex;
border-bottom: 1px solid hsl(var(--border));
}
.time-gutter {
width: var(--time-column-width);
flex-shrink: 0;
}
.day-header {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
border-left: 1px solid hsl(var(--border));
}
.day-name {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
}
.day-number {
font-size: 1.25rem;
font-weight: 600;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
}
.day-number.today {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.time-grid {
flex: 1;
display: flex;
overflow-y: auto;
}
.time-column {
width: var(--time-column-width);
flex-shrink: 0;
}
.time-label {
height: var(--hour-height);
padding-right: 0.5rem;
text-align: right;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
position: relative;
top: -0.5em;
}
.days-container {
flex: 1;
display: flex;
}
.day-column {
flex: 1;
position: relative;
border-left: 1px solid hsl(var(--border));
}
.day-column.today {
background: hsl(var(--primary) / 0.05);
}
.event-card {
position: absolute;
left: 2px;
right: 2px;
color: white;
border: none;
text-align: left;
cursor: pointer;
z-index: 1;
}
.event-time {
font-size: 0.65rem;
opacity: 0.9;
}
.event-title {
display: block;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -0,0 +1,219 @@
<script lang="ts">
import { calendarsStore } from '$lib/stores/calendars.svelte';
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
import { format, addHours, parseISO } from 'date-fns';
interface Props {
mode: 'create' | 'edit';
event?: CalendarEvent;
initialStartTime?: Date | null;
onSave: (data: CreateEventInput | UpdateEventInput) => void;
onCancel: () => void;
}
let { mode, event, initialStartTime, onSave, onCancel }: Props = $props();
// Form state
let title = $state(event?.title || '');
let description = $state(event?.description || '');
let location = $state(event?.location || '');
let isAllDay = $state(event?.isAllDay || false);
let calendarId = $state(event?.calendarId || calendarsStore.defaultCalendar?.id || '');
// Date/time handling
let startDate = $state('');
let startTime = $state('');
let endDate = $state('');
let endTime = $state('');
// Initialize date/time fields
$effect(() => {
if (event) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
startDate = format(start, 'yyyy-MM-dd');
startTime = format(start, 'HH:mm');
endDate = format(end, 'yyyy-MM-dd');
endTime = format(end, 'HH:mm');
} else if (initialStartTime) {
const end = addHours(initialStartTime, 1);
startDate = format(initialStartTime, 'yyyy-MM-dd');
startTime = format(initialStartTime, 'HH:mm');
endDate = format(end, 'yyyy-MM-dd');
endTime = format(end, 'HH:mm');
} else {
const now = new Date();
now.setMinutes(0, 0, 0);
const end = addHours(now, 1);
startDate = format(now, 'yyyy-MM-dd');
startTime = format(now, 'HH:mm');
endDate = format(end, 'yyyy-MM-dd');
endTime = format(end, 'HH:mm');
}
});
let submitting = $state(false);
function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim()) return;
const startDateTime = new Date(`${startDate}T${isAllDay ? '00:00' : startTime}`);
const endDateTime = new Date(`${endDate}T${isAllDay ? '23:59' : endTime}`);
const data: CreateEventInput | UpdateEventInput = {
title: title.trim(),
description: description.trim() || undefined,
location: location.trim() || undefined,
isAllDay,
startTime: startDateTime.toISOString(),
endTime: endDateTime.toISOString(),
calendarId,
};
submitting = true;
onSave(data);
}
</script>
<form onsubmit={handleSubmit}>
<div class="form-group">
<label for="title">Titel *</label>
<input
type="text"
id="title"
class="input"
bind:value={title}
placeholder="Terminname eingeben"
required
/>
</div>
<div class="form-group">
<label for="calendar">Kalender</label>
<select id="calendar" class="input" bind:value={calendarId}>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" bind:checked={isAllDay} />
Ganztägig
</label>
</div>
<div class="form-row">
<div class="form-group">
<label for="startDate">Beginn</label>
<input type="date" id="startDate" class="input" bind:value={startDate} required />
</div>
{#if !isAllDay}
<div class="form-group">
<label for="startTime">Uhrzeit</label>
<input type="time" id="startTime" class="input" bind:value={startTime} required />
</div>
{/if}
</div>
<div class="form-row">
<div class="form-group">
<label for="endDate">Ende</label>
<input type="date" id="endDate" class="input" bind:value={endDate} required />
</div>
{#if !isAllDay}
<div class="form-group">
<label for="endTime">Uhrzeit</label>
<input type="time" id="endTime" class="input" bind:value={endTime} required />
</div>
{/if}
</div>
<div class="form-group">
<label for="location">Ort</label>
<input
type="text"
id="location"
class="input"
bind:value={location}
placeholder="Ort hinzufügen"
/>
</div>
<div class="form-group">
<label for="description">Beschreibung</label>
<textarea
id="description"
class="input"
rows="3"
bind:value={description}
placeholder="Beschreibung hinzufügen"
></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-ghost" onclick={onCancel}>
Abbrechen
</button>
<button type="submit" class="btn btn-primary" disabled={submitting || !title.trim()}>
{mode === 'create' ? 'Erstellen' : 'Speichern'}
</button>
</div>
</form>
<style>
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
}
.form-row {
display: flex;
gap: 1rem;
}
.form-row .form-group {
flex: 1;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input {
width: 18px;
height: 18px;
}
textarea.input {
resize: vertical;
min-height: 80px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid hsl(var(--border));
}
</style>

View file

@ -0,0 +1,52 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// List of supported locales
export const supportedLocales = ['de', 'en', 'it', 'fr', 'es'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Default locale
const defaultLocale = 'de';
// Register all available locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
register('it', () => import('./locales/it.json'));
register('fr', () => import('./locales/fr.json'));
register('es', () => import('./locales/es.json'));
// Get initial locale from browser or localStorage
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const stored = localStorage.getItem('calendar_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
// Fall back to browser language
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
// Initialize i18n at module scope (required for SSR)
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('calendar_locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale };

View file

@ -0,0 +1,86 @@
{
"app": {
"name": "Kalender",
"loading": "Laden..."
},
"nav": {
"calendar": "Kalender",
"calendars": "Kalender",
"agenda": "Agenda",
"settings": "Einstellungen",
"feedback": "Feedback"
},
"views": {
"day": "Tag",
"week": "Woche",
"month": "Monat",
"year": "Jahr",
"agenda": "Agenda"
},
"calendar": {
"today": "Heute",
"newEvent": "Neuer Termin",
"noEvents": "Keine Termine",
"allDay": "Ganztägig",
"myCalendars": "Meine Kalender",
"sharedCalendars": "Geteilte Kalender"
},
"event": {
"title": "Titel",
"description": "Beschreibung",
"location": "Ort",
"start": "Beginn",
"end": "Ende",
"allDay": "Ganztägig",
"repeat": "Wiederholen",
"reminder": "Erinnerung",
"calendar": "Kalender",
"save": "Speichern",
"delete": "Löschen",
"cancel": "Abbrechen"
},
"repeat": {
"none": "Nicht wiederholen",
"daily": "Täglich",
"weekly": "Wöchentlich",
"monthly": "Monatlich",
"yearly": "Jährlich"
},
"reminder": {
"atTime": "Zum Zeitpunkt",
"5min": "5 Minuten vorher",
"15min": "15 Minuten vorher",
"30min": "30 Minuten vorher",
"1hour": "1 Stunde vorher",
"1day": "1 Tag vorher"
},
"share": {
"share": "Teilen",
"shareCalendar": "Kalender teilen",
"permissions": "Berechtigungen",
"read": "Nur lesen",
"write": "Lesen & Schreiben",
"admin": "Administrator",
"pending": "Ausstehend",
"accepted": "Akzeptiert"
},
"auth": {
"login": "Anmelden",
"logout": "Abmelden",
"register": "Registrieren",
"email": "E-Mail",
"password": "Passwort",
"forgotPassword": "Passwort vergessen?"
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"add": "Hinzufügen",
"close": "Schließen",
"search": "Suchen",
"error": "Fehler",
"success": "Erfolgreich"
}
}

View file

@ -0,0 +1,86 @@
{
"app": {
"name": "Calendar",
"loading": "Loading..."
},
"nav": {
"calendar": "Calendar",
"calendars": "Calendars",
"agenda": "Agenda",
"settings": "Settings",
"feedback": "Feedback"
},
"views": {
"day": "Day",
"week": "Week",
"month": "Month",
"year": "Year",
"agenda": "Agenda"
},
"calendar": {
"today": "Today",
"newEvent": "New Event",
"noEvents": "No events",
"allDay": "All day",
"myCalendars": "My Calendars",
"sharedCalendars": "Shared Calendars"
},
"event": {
"title": "Title",
"description": "Description",
"location": "Location",
"start": "Start",
"end": "End",
"allDay": "All day",
"repeat": "Repeat",
"reminder": "Reminder",
"calendar": "Calendar",
"save": "Save",
"delete": "Delete",
"cancel": "Cancel"
},
"repeat": {
"none": "Don't repeat",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"yearly": "Yearly"
},
"reminder": {
"atTime": "At time of event",
"5min": "5 minutes before",
"15min": "15 minutes before",
"30min": "30 minutes before",
"1hour": "1 hour before",
"1day": "1 day before"
},
"share": {
"share": "Share",
"shareCalendar": "Share calendar",
"permissions": "Permissions",
"read": "View only",
"write": "Can edit",
"admin": "Admin",
"pending": "Pending",
"accepted": "Accepted"
},
"auth": {
"login": "Login",
"logout": "Logout",
"register": "Register",
"email": "Email",
"password": "Password",
"forgotPassword": "Forgot password?"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"close": "Close",
"search": "Search",
"error": "Error",
"success": "Success"
}
}

View file

@ -0,0 +1,86 @@
{
"app": {
"name": "Calendario",
"loading": "Cargando..."
},
"nav": {
"calendar": "Calendario",
"calendars": "Calendarios",
"agenda": "Agenda",
"settings": "Configuración",
"feedback": "Comentarios"
},
"views": {
"day": "Día",
"week": "Semana",
"month": "Mes",
"year": "Año",
"agenda": "Agenda"
},
"calendar": {
"today": "Hoy",
"newEvent": "Nuevo evento",
"noEvents": "Sin eventos",
"allDay": "Todo el día",
"myCalendars": "Mis calendarios",
"sharedCalendars": "Calendarios compartidos"
},
"event": {
"title": "Título",
"description": "Descripción",
"location": "Ubicación",
"start": "Inicio",
"end": "Fin",
"allDay": "Todo el día",
"repeat": "Repetir",
"reminder": "Recordatorio",
"calendar": "Calendario",
"save": "Guardar",
"delete": "Eliminar",
"cancel": "Cancelar"
},
"repeat": {
"none": "No repetir",
"daily": "Diario",
"weekly": "Semanal",
"monthly": "Mensual",
"yearly": "Anual"
},
"reminder": {
"atTime": "Al momento del evento",
"5min": "5 minutos antes",
"15min": "15 minutos antes",
"30min": "30 minutos antes",
"1hour": "1 hora antes",
"1day": "1 día antes"
},
"share": {
"share": "Compartir",
"shareCalendar": "Compartir calendario",
"permissions": "Permisos",
"read": "Solo lectura",
"write": "Puede editar",
"admin": "Administrador",
"pending": "Pendiente",
"accepted": "Aceptado"
},
"auth": {
"login": "Iniciar sesión",
"logout": "Cerrar sesión",
"register": "Registrarse",
"email": "Correo electrónico",
"password": "Contraseña",
"forgotPassword": "¿Olvidaste tu contraseña?"
},
"common": {
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"add": "Agregar",
"close": "Cerrar",
"search": "Buscar",
"error": "Error",
"success": "Éxito"
}
}

View file

@ -0,0 +1,86 @@
{
"app": {
"name": "Calendrier",
"loading": "Chargement..."
},
"nav": {
"calendar": "Calendrier",
"calendars": "Calendriers",
"agenda": "Agenda",
"settings": "Paramètres",
"feedback": "Commentaires"
},
"views": {
"day": "Jour",
"week": "Semaine",
"month": "Mois",
"year": "Année",
"agenda": "Agenda"
},
"calendar": {
"today": "Aujourd'hui",
"newEvent": "Nouvel événement",
"noEvents": "Aucun événement",
"allDay": "Toute la journée",
"myCalendars": "Mes calendriers",
"sharedCalendars": "Calendriers partagés"
},
"event": {
"title": "Titre",
"description": "Description",
"location": "Lieu",
"start": "Début",
"end": "Fin",
"allDay": "Toute la journée",
"repeat": "Répéter",
"reminder": "Rappel",
"calendar": "Calendrier",
"save": "Enregistrer",
"delete": "Supprimer",
"cancel": "Annuler"
},
"repeat": {
"none": "Ne pas répéter",
"daily": "Quotidien",
"weekly": "Hebdomadaire",
"monthly": "Mensuel",
"yearly": "Annuel"
},
"reminder": {
"atTime": "Au moment de l'événement",
"5min": "5 minutes avant",
"15min": "15 minutes avant",
"30min": "30 minutes avant",
"1hour": "1 heure avant",
"1day": "1 jour avant"
},
"share": {
"share": "Partager",
"shareCalendar": "Partager le calendrier",
"permissions": "Autorisations",
"read": "Lecture seule",
"write": "Modification",
"admin": "Administrateur",
"pending": "En attente",
"accepted": "Accepté"
},
"auth": {
"login": "Connexion",
"logout": "Déconnexion",
"register": "Inscription",
"email": "E-mail",
"password": "Mot de passe",
"forgotPassword": "Mot de passe oublié?"
},
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"add": "Ajouter",
"close": "Fermer",
"search": "Rechercher",
"error": "Erreur",
"success": "Succès"
}
}

View file

@ -0,0 +1,86 @@
{
"app": {
"name": "Calendario",
"loading": "Caricamento..."
},
"nav": {
"calendar": "Calendario",
"calendars": "Calendari",
"agenda": "Agenda",
"settings": "Impostazioni",
"feedback": "Feedback"
},
"views": {
"day": "Giorno",
"week": "Settimana",
"month": "Mese",
"year": "Anno",
"agenda": "Agenda"
},
"calendar": {
"today": "Oggi",
"newEvent": "Nuovo evento",
"noEvents": "Nessun evento",
"allDay": "Tutto il giorno",
"myCalendars": "I miei calendari",
"sharedCalendars": "Calendari condivisi"
},
"event": {
"title": "Titolo",
"description": "Descrizione",
"location": "Luogo",
"start": "Inizio",
"end": "Fine",
"allDay": "Tutto il giorno",
"repeat": "Ripeti",
"reminder": "Promemoria",
"calendar": "Calendario",
"save": "Salva",
"delete": "Elimina",
"cancel": "Annulla"
},
"repeat": {
"none": "Non ripetere",
"daily": "Giornaliero",
"weekly": "Settimanale",
"monthly": "Mensile",
"yearly": "Annuale"
},
"reminder": {
"atTime": "All'ora dell'evento",
"5min": "5 minuti prima",
"15min": "15 minuti prima",
"30min": "30 minuti prima",
"1hour": "1 ora prima",
"1day": "1 giorno prima"
},
"share": {
"share": "Condividi",
"shareCalendar": "Condividi calendario",
"permissions": "Autorizzazioni",
"read": "Solo lettura",
"write": "Può modificare",
"admin": "Amministratore",
"pending": "In attesa",
"accepted": "Accettato"
},
"auth": {
"login": "Accedi",
"logout": "Esci",
"register": "Registrati",
"email": "Email",
"password": "Password",
"forgotPassword": "Password dimenticata?"
},
"common": {
"save": "Salva",
"cancel": "Annulla",
"delete": "Elimina",
"edit": "Modifica",
"add": "Aggiungi",
"close": "Chiudi",
"search": "Cerca",
"error": "Errore",
"success": "Successo"
}
}

View file

@ -0,0 +1,185 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
// Initialize Mana Core Auth only on the client side
const MANA_AUTH_URL = 'http://localhost:3001';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
// State
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const authStore = {
// Getters
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
/**
* Initialize auth state from stored tokens
*/
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
/**
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
// Get user data from token
const userData = await authService.getUserFromToken();
user = userData;
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const result = await authService.signUp(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
// Mana Core Auth requires separate login after signup
if (result.needsVerification) {
return { success: true, needsVerification: true };
}
// Auto sign in after successful signup
const signInResult = await this.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
/**
* Sign out
*/
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try {
await authService.signOut();
user = null;
} catch (error) {
console.error('Sign out error:', error);
// Clear user even if sign out fails
user = null;
}
},
/**
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.forgotPassword(email);
if (!result.success) {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Get access token for API calls
*/
async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken();
},
};

View file

@ -0,0 +1,120 @@
/**
* Calendars Store - Manages user calendars using Svelte 5 runes
*/
import type { Calendar, CreateCalendarInput, UpdateCalendarInput } from '@calendar/shared';
import * as api from '$lib/api/calendars';
// State
let calendars = $state<Calendar[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
// Derived: visible calendars
const visibleCalendars = $derived(calendars.filter((c) => c.isVisible));
// Derived: default calendar
const defaultCalendar = $derived(calendars.find((c) => c.isDefault) || calendars[0]);
export const calendarsStore = {
// Getters
get calendars() {
return calendars;
},
get visibleCalendars() {
return visibleCalendars;
},
get defaultCalendar() {
return defaultCalendar;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Fetch all calendars
*/
async fetchCalendars() {
loading = true;
error = null;
const result = await api.getCalendars();
if (result.error) {
error = result.error.message;
calendars = [];
} else {
calendars = result.data || [];
}
loading = false;
return result;
},
/**
* Create a new calendar
*/
async createCalendar(data: CreateCalendarInput) {
const result = await api.createCalendar(data);
if (result.data) {
calendars = [...calendars, result.data];
}
return result;
},
/**
* Update a calendar
*/
async updateCalendar(id: string, data: UpdateCalendarInput) {
const result = await api.updateCalendar(id, data);
if (result.data) {
calendars = calendars.map((c) => (c.id === id ? result.data! : c));
}
return result;
},
/**
* Delete a calendar
*/
async deleteCalendar(id: string) {
const result = await api.deleteCalendar(id);
if (!result.error) {
calendars = calendars.filter((c) => c.id !== id);
}
return result;
},
/**
* Toggle calendar visibility
*/
async toggleVisibility(id: string) {
const calendar = calendars.find((c) => c.id === id);
if (!calendar) return;
return this.updateCalendar(id, { isVisible: !calendar.isVisible });
},
/**
* Get calendar by ID
*/
getById(id: string) {
return calendars.find((c) => c.id === id);
},
/**
* Get calendar color by ID (with fallback)
*/
getColor(id: string) {
const calendar = calendars.find((c) => c.id === id);
return calendar?.color || '#3b82f6';
},
};

View file

@ -0,0 +1,135 @@
/**
* Events Store - Manages calendar events using Svelte 5 runes
*/
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
import * as api from '$lib/api/events';
import { format, isWithinInterval, parseISO, isSameDay } from 'date-fns';
// State
let events = $state<CalendarEvent[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let loadedRange = $state<{ start: Date; end: Date } | null>(null);
export const eventsStore = {
// Getters
get events() {
return events;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Fetch events for a date range
*/
async fetchEvents(startDate: Date, endDate: Date, calendarIds?: string[]) {
loading = true;
error = null;
const result = await api.getEvents({
startDate: format(startDate, "yyyy-MM-dd'T'HH:mm:ss"),
endDate: format(endDate, "yyyy-MM-dd'T'HH:mm:ss"),
calendarIds,
});
if (result.error) {
error = result.error.message;
} else {
events = result.data || [];
loadedRange = { start: startDate, end: endDate };
}
loading = false;
return result;
},
/**
* Get events for a specific day
*/
getEventsForDay(date: Date) {
return events.filter((event) => {
const eventStart = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
// For all-day events, check if day falls within event range
if (event.isAllDay) {
return isWithinInterval(date, { start: eventStart, end: eventEnd }) || isSameDay(date, eventStart);
}
// For timed events, check if event starts on this day
return isSameDay(date, eventStart);
});
},
/**
* Get events within a time range
*/
getEventsInRange(start: Date, end: Date) {
return events.filter((event) => {
const eventStart = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
// Check if event overlaps with the range
return eventStart <= end && eventEnd >= start;
});
},
/**
* Create a new event
*/
async createEvent(data: CreateEventInput) {
const result = await api.createEvent(data);
if (result.data) {
events = [...events, result.data];
}
return result;
},
/**
* Update an event
*/
async updateEvent(id: string, data: UpdateEventInput) {
const result = await api.updateEvent(id, data);
if (result.data) {
events = events.map((e) => (e.id === id ? result.data! : e));
}
return result;
},
/**
* Delete an event
*/
async deleteEvent(id: string) {
const result = await api.deleteEvent(id);
if (!result.error) {
events = events.filter((e) => e.id !== id);
}
return result;
},
/**
* Get event by ID
*/
getById(id: string) {
return events.find((e) => e.id === id);
},
/**
* Clear events cache
*/
clear() {
events = [];
loadedRange = null;
},
};

View file

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
export const isSidebarMode = writable(false);
export const isNavCollapsed = writable(false);

View file

@ -0,0 +1,7 @@
import { createThemeStore } from '@manacore/shared-theme';
// Create theme store with Calendar's primary color (blue)
export const theme = createThemeStore({
appId: 'calendar',
defaultVariant: 'ocean',
});

View file

@ -0,0 +1,50 @@
import { writable } from 'svelte/store';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
}
function createToastStore() {
const { subscribe, update } = writable<Toast[]>([]);
function add(message: string, type: ToastType = 'info', duration: number = 4000) {
const id = crypto.randomUUID();
const toast: Toast = { id, type, message, duration };
update((toasts) => [...toasts, toast]);
if (duration > 0) {
setTimeout(() => {
remove(id);
}, duration);
}
return id;
}
function remove(id: string) {
update((toasts) => toasts.filter((t) => t.id !== id));
}
function clear() {
update(() => []);
}
return {
subscribe,
add,
remove,
clear,
success: (message: string, duration?: number) => add(message, 'success', duration),
error: (message: string, duration?: number) => add(message, 'error', duration),
warning: (message: string, duration?: number) => add(message, 'warning', duration),
info: (message: string, duration?: number) => add(message, 'info', duration),
};
}
export const toast = createToastStore();

View file

@ -0,0 +1,158 @@
/**
* View Store - Manages calendar view state using Svelte 5 runes
*/
import { browser } from '$app/environment';
import type { CalendarViewType } from '@calendar/shared';
import {
startOfDay,
startOfWeek,
startOfMonth,
endOfDay,
endOfWeek,
endOfMonth,
addDays,
addWeeks,
addMonths,
addYears,
subDays,
subWeeks,
subMonths,
subYears,
} from 'date-fns';
// State
let currentDate = $state(new Date());
let viewType = $state<CalendarViewType>('week');
// Derived state
const viewRange = $derived.by(() => {
switch (viewType) {
case 'day':
return {
start: startOfDay(currentDate),
end: endOfDay(currentDate),
};
case 'week':
return {
start: startOfWeek(currentDate, { weekStartsOn: 1 }),
end: endOfWeek(currentDate, { weekStartsOn: 1 }),
};
case 'month':
return {
start: startOfMonth(currentDate),
end: endOfMonth(currentDate),
};
case 'year':
return {
start: new Date(currentDate.getFullYear(), 0, 1),
end: new Date(currentDate.getFullYear(), 11, 31),
};
case 'agenda':
// Agenda shows 30 days from current date
return {
start: startOfDay(currentDate),
end: endOfDay(addDays(currentDate, 30)),
};
default:
return {
start: startOfWeek(currentDate, { weekStartsOn: 1 }),
end: endOfWeek(currentDate, { weekStartsOn: 1 }),
};
}
});
export const viewStore = {
// Getters
get currentDate() {
return currentDate;
},
get viewType() {
return viewType;
},
get viewRange() {
return viewRange;
},
/**
* Initialize view state from localStorage
*/
initialize() {
if (!browser) return;
const savedView = localStorage.getItem('calendar-view-type');
if (savedView && ['day', 'week', 'month', 'year', 'agenda'].includes(savedView)) {
viewType = savedView as CalendarViewType;
}
},
/**
* Set the current date
*/
setDate(date: Date) {
currentDate = date;
},
/**
* Set the view type
*/
setViewType(type: CalendarViewType) {
viewType = type;
if (browser) {
localStorage.setItem('calendar-view-type', type);
}
},
/**
* Navigate to today
*/
goToToday() {
currentDate = new Date();
},
/**
* Navigate to previous period
*/
goToPrevious() {
switch (viewType) {
case 'day':
currentDate = subDays(currentDate, 1);
break;
case 'week':
currentDate = subWeeks(currentDate, 1);
break;
case 'month':
currentDate = subMonths(currentDate, 1);
break;
case 'year':
currentDate = subYears(currentDate, 1);
break;
case 'agenda':
currentDate = subDays(currentDate, 7);
break;
}
},
/**
* Navigate to next period
*/
goToNext() {
switch (viewType) {
case 'day':
currentDate = addDays(currentDate, 1);
break;
case 'week':
currentDate = addWeeks(currentDate, 1);
break;
case 'month':
currentDate = addMonths(currentDate, 1);
break;
case 'year':
currentDate = addYears(currentDate, 1);
break;
case 'agenda':
currentDate = addDays(currentDate, 7);
break;
}
},
};

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from '$lib/stores/toast';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
async function handleResetPassword(email: string) {
const result = await authStore.resetPassword(email);
if (!result.success) {
toast.error(result.error || 'Anfrage fehlgeschlagen');
return { success: false, error: result.error };
}
toast.success('E-Mail gesendet. Bitte überprüfen Sie Ihren Posteingang.');
return { success: true };
}
</script>
<svelte:head>
<title>Passwort vergessen | Kalender</title>
</svelte:head>
<ForgotPasswordPage
onResetPassword={handleResetPassword}
loginUrl="/login"
appName="Kalender"
/>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from '$lib/stores/toast';
import { LoginPage } from '@manacore/shared-auth-ui';
async function handleLogin(email: string, password: string) {
const result = await authStore.signIn(email, password);
if (!result.success) {
toast.error(result.error || 'Anmeldung fehlgeschlagen');
return { success: false, error: result.error };
}
toast.success('Erfolgreich angemeldet');
goto('/');
return { success: true };
}
</script>
<svelte:head>
<title>Anmelden | Kalender</title>
</svelte:head>
<LoginPage
onLogin={handleLogin}
registerUrl="/register"
forgotPasswordUrl="/forgot-password"
appName="Kalender"
/>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from '$lib/stores/toast';
import { RegisterPage } from '@manacore/shared-auth-ui';
async function handleRegister(email: string, password: string) {
const result = await authStore.signUp(email, password);
if (!result.success) {
toast.error(result.error || 'Registrierung fehlgeschlagen');
return { success: false, error: result.error };
}
if (result.needsVerification) {
toast.info('Bitte bestätigen Sie Ihre E-Mail-Adresse');
goto('/login');
return { success: true, needsVerification: true };
}
toast.success('Erfolgreich registriert');
goto('/');
return { success: true };
}
</script>
<svelte:head>
<title>Registrieren | Kalender</title>
</svelte:head>
<RegisterPage
onRegister={handleRegister}
loginUrl="/login"
appName="Kalender"
/>

View file

@ -0,0 +1,265 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
} from '$lib/stores/navigation';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import '../app.css';
// App switcher items
const appItems = getPillAppItems('calendar');
let { children } = $props();
let loading = $state(true);
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,
onClick: () => theme.setVariant(variant),
active: theme.variant === variant,
})),
{
id: 'all-themes',
label: 'Alle Themes',
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
},
]);
// Current theme variant label
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant].label);
// Language selector items
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Calendar
const navItems: PillNavItem[] = [
{ href: '/', label: 'Kalender', icon: 'calendar' },
{ href: '/agenda', label: 'Agenda', icon: 'list' },
{ href: '/calendars', label: 'Meine Kalender', icon: 'document' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation shortcuts (Ctrl+1-5)
const navRoutes = navItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
event.preventDefault();
const route = navRoutes[num - 1];
if (route) {
goto(route);
}
}
}
}
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
sidebarModeStore.set(isSidebar);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('calendar-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('calendar-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleLogout() {
await authStore.signOut();
goto('/login');
}
onMount(async () => {
// Initialize theme
theme.initialize();
// Initialize view state
viewStore.initialize();
// Initialize auth
await authStore.initialize();
// Load calendars if authenticated
if (authStore.isAuthenticated) {
await calendarsStore.fetchCalendars();
}
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('calendar-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('calendar-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
loading = false;
});
</script>
<svelte:window onkeydown={handleKeydown} />
<ToastContainer />
{#if loading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-muted-foreground">Laden...</p>
</div>
</div>
{:else}
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Kalender"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#3b82f6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<main
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
{/if}
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
flex: 1;
transition: all 300ms ease;
}
.main-content.floating-mode {
padding-top: 100px;
}
.main-content.sidebar-mode {
padding-left: 180px;
}
.content-wrapper {
max-width: 100%;
margin-left: auto;
margin-right: auto;
padding: 1rem;
height: calc(100vh - 100px);
}
.main-content.sidebar-mode .content-wrapper {
height: 100vh;
}
@media (min-width: 640px) {
.content-wrapper {
padding: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding: 2rem;
}
}
</style>

View file

@ -0,0 +1,123 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import CalendarHeader from '$lib/components/calendar/CalendarHeader.svelte';
import WeekView from '$lib/components/calendar/WeekView.svelte';
import DayView from '$lib/components/calendar/DayView.svelte';
import MonthView from '$lib/components/calendar/MonthView.svelte';
import MiniCalendar from '$lib/components/calendar/MiniCalendar.svelte';
import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
let initialized = $state(false);
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Fetch events for current view range
await eventsStore.fetchEvents(viewStore.viewRange.start, viewStore.viewRange.end);
initialized = true;
});
// Refetch events when view changes
$effect(() => {
if (initialized && authStore.isAuthenticated) {
eventsStore.fetchEvents(viewStore.viewRange.start, viewStore.viewRange.end);
}
});
function handleDateSelect(date: Date) {
viewStore.setDate(date);
}
function handleNewEvent() {
goto('/event/new');
}
</script>
<svelte:head>
<title>Kalender</title>
</svelte:head>
<div class="calendar-layout">
<!-- Left Sidebar -->
<aside class="calendar-sidebar">
<button class="btn btn-primary w-full mb-4" onclick={handleNewEvent}>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neuer Termin
</button>
<MiniCalendar
selectedDate={viewStore.currentDate}
onDateSelect={handleDateSelect}
/>
<CalendarSidebar />
</aside>
<!-- Main Calendar Area -->
<div class="calendar-main">
<CalendarHeader />
<div class="calendar-content">
{#if viewStore.viewType === 'day'}
<DayView />
{:else if viewStore.viewType === 'week'}
<WeekView />
{:else if viewStore.viewType === 'month'}
<MonthView />
{:else}
<WeekView />
{/if}
</div>
</div>
</div>
<style>
.calendar-layout {
display: flex;
gap: 1.5rem;
height: 100%;
max-height: calc(100vh - 160px);
}
.calendar-sidebar {
width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.calendar-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: hsl(var(--card));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--border));
overflow: hidden;
}
.calendar-content {
flex: 1;
overflow: auto;
}
@media (max-width: 1024px) {
.calendar-sidebar {
display: none;
}
}
</style>

View file

@ -0,0 +1,231 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { format, parseISO, isToday, isTomorrow, addDays, startOfDay, endOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
let loading = $state(true);
// Group events by date
let groupedEvents = $derived.by(() => {
const groups: Map<string, typeof eventsStore.events> = new Map();
for (const event of eventsStore.events) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const dateKey = format(start, 'yyyy-MM-dd');
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push(event);
}
// Sort groups by date
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([dateKey, events]) => ({
date: parseISO(dateKey),
events: events.sort((a, b) => {
const aStart = typeof a.startTime === 'string' ? parseISO(a.startTime) : a.startTime;
const bStart = typeof b.startTime === 'string' ? parseISO(b.startTime) : b.startTime;
return aStart.getTime() - bStart.getTime();
}),
}));
});
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Fetch events for next 30 days
const start = startOfDay(new Date());
const end = endOfDay(addDays(start, 30));
await eventsStore.fetchEvents(start, end);
loading = false;
});
function formatDateHeader(date: Date) {
if (isToday(date)) {
return 'Heute';
}
if (isTomorrow(date)) {
return 'Morgen';
}
return format(date, 'EEEE, d. MMMM', { locale: de });
}
function handleEventClick(eventId: string) {
goto(`/event/${eventId}`);
}
</script>
<svelte:head>
<title>Agenda | Kalender</title>
</svelte:head>
<div class="agenda-page">
<header class="page-header">
<h1>Agenda</h1>
<p class="subtitle">Ihre kommenden Termine</p>
</header>
{#if loading}
<div class="loading">Laden...</div>
{:else if groupedEvents.length === 0}
<div class="empty-state card">
<p>Keine Termine in den nächsten 30 Tagen</p>
<button class="btn btn-primary" onclick={() => goto('/event/new')}>
Termin erstellen
</button>
</div>
{:else}
<div class="event-list">
{#each groupedEvents as group}
<div class="date-group">
<h2 class="date-header" class:today={isToday(group.date)}>
{formatDateHeader(group.date)}
</h2>
{#each group.events as event}
<button class="event-item card" onclick={() => handleEventClick(event.id)}>
<div
class="color-bar"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
></div>
<div class="event-content">
<div class="event-time">
{#if event.isAllDay}
Ganztägig
{:else}
{format(typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, 'HH:mm')} -
{format(typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, 'HH:mm')}
{/if}
</div>
<div class="event-title">{event.title}</div>
{#if event.location}
<div class="event-location">{event.location}</div>
{/if}
</div>
</button>
{/each}
</div>
{/each}
</div>
{/if}
</div>
<style>
.agenda-page {
max-width: 600px;
margin: 0 auto;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 700;
color: hsl(var(--foreground));
margin: 0 0 0.25rem 0;
}
.subtitle {
color: hsl(var(--muted-foreground));
margin: 0;
}
.loading {
text-align: center;
padding: 2rem;
color: hsl(var(--muted-foreground));
}
.empty-state {
text-align: center;
padding: 3rem;
}
.empty-state p {
color: hsl(var(--muted-foreground));
margin-bottom: 1rem;
}
.event-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.date-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.date-header {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
padding-left: 0.5rem;
}
.date-header.today {
color: hsl(var(--primary));
}
.event-item {
display: flex;
gap: 1rem;
padding: 1rem;
cursor: pointer;
border: none;
text-align: left;
width: 100%;
transition: transform var(--transition-fast);
}
.event-item:hover {
transform: translateX(4px);
}
.color-bar {
width: 4px;
border-radius: 2px;
flex-shrink: 0;
}
.event-content {
flex: 1;
min-width: 0;
}
.event-time {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin-bottom: 0.25rem;
}
.event-title {
font-weight: 500;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-location {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-top: 0.25rem;
}
</style>

View file

@ -0,0 +1,290 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { toast } from '$lib/stores/toast';
import type { Calendar } from '@calendar/shared';
let editingCalendar = $state<Calendar | null>(null);
let showNewForm = $state(false);
let newCalendarName = $state('');
let newCalendarColor = $state('#3b82f6');
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
});
async function handleCreateCalendar() {
if (!newCalendarName.trim()) return;
const result = await calendarsStore.createCalendar({
name: newCalendarName.trim(),
color: newCalendarColor,
});
if (result.error) {
toast.error(`Fehler: ${result.error.message}`);
return;
}
toast.success('Kalender erstellt');
newCalendarName = '';
showNewForm = false;
}
async function handleDeleteCalendar(calendar: Calendar) {
if (!confirm(`Möchten Sie "${calendar.name}" wirklich löschen?`)) {
return;
}
const result = await calendarsStore.deleteCalendar(calendar.id);
if (result.error) {
toast.error(`Fehler: ${result.error.message}`);
return;
}
toast.success('Kalender gelöscht');
}
async function handleUpdateCalendar(calendar: Calendar, name: string, color: string) {
const result = await calendarsStore.updateCalendar(calendar.id, { name, color });
if (result.error) {
toast.error(`Fehler: ${result.error.message}`);
return;
}
toast.success('Kalender aktualisiert');
editingCalendar = null;
}
</script>
<svelte:head>
<title>Meine Kalender | Kalender</title>
</svelte:head>
<div class="calendars-page">
<header class="page-header">
<h1>Meine Kalender</h1>
<button class="btn btn-primary" onclick={() => (showNewForm = true)}>
Neuer Kalender
</button>
</header>
{#if showNewForm}
<div class="card new-calendar-form">
<h2>Neuer Kalender</h2>
<form onsubmit={(e) => { e.preventDefault(); handleCreateCalendar(); }}>
<div class="form-row">
<input
type="text"
class="input"
placeholder="Kalender Name"
bind:value={newCalendarName}
/>
<input
type="color"
class="color-input"
bind:value={newCalendarColor}
/>
</div>
<div class="form-actions">
<button type="button" class="btn btn-ghost" onclick={() => (showNewForm = false)}>
Abbrechen
</button>
<button type="submit" class="btn btn-primary" disabled={!newCalendarName.trim()}>
Erstellen
</button>
</div>
</form>
</div>
{/if}
<div class="calendar-list">
{#each calendarsStore.calendars as calendar}
<div class="calendar-card card">
{#if editingCalendar?.id === calendar.id}
<form
onsubmit={(e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const name = (form.elements.namedItem('name') as HTMLInputElement).value;
const color = (form.elements.namedItem('color') as HTMLInputElement).value;
handleUpdateCalendar(calendar, name, color);
}}
>
<div class="form-row">
<input
type="text"
name="name"
class="input"
value={calendar.name}
/>
<input
type="color"
name="color"
class="color-input"
value={calendar.color}
/>
</div>
<div class="form-actions">
<button type="button" class="btn btn-ghost" onclick={() => (editingCalendar = null)}>
Abbrechen
</button>
<button type="submit" class="btn btn-primary">
Speichern
</button>
</div>
</form>
{:else}
<div class="calendar-info">
<span class="color-dot" style="background-color: {calendar.color}"></span>
<span class="calendar-name">{calendar.name}</span>
{#if calendar.isDefault}
<span class="badge">Standard</span>
{/if}
</div>
<div class="calendar-actions">
<button
class="btn btn-ghost btn-sm"
onclick={() => (editingCalendar = calendar)}
>
Bearbeiten
</button>
{#if !calendar.isDefault}
<button
class="btn btn-ghost btn-sm text-destructive"
onclick={() => handleDeleteCalendar(calendar)}
>
Löschen
</button>
{/if}
</div>
{/if}
</div>
{/each}
{#if calendarsStore.calendars.length === 0}
<div class="empty-state card">
<p>Keine Kalender vorhanden</p>
</div>
{/if}
</div>
</div>
<style>
.calendars-page {
max-width: 600px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.page-header h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.new-calendar-form {
margin-bottom: 1.5rem;
}
.new-calendar-form h2 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 1rem 0;
}
.form-row {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.form-row .input {
flex: 1;
}
.color-input {
width: 48px;
height: 42px;
padding: 4px;
border: 2px solid hsl(var(--border));
border-radius: var(--radius-md);
cursor: pointer;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.calendar-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.calendar-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
}
.calendar-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.color-dot {
width: 16px;
height: 16px;
border-radius: var(--radius-full);
}
.calendar-name {
font-weight: 500;
}
.badge {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
background: hsl(var(--muted));
border-radius: var(--radius-sm);
color: hsl(var(--muted-foreground));
}
.calendar-actions {
display: flex;
gap: 0.5rem;
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
}
.text-destructive {
color: hsl(var(--destructive));
}
.empty-state {
text-align: center;
padding: 2rem;
color: hsl(var(--muted-foreground));
}
</style>

View file

@ -0,0 +1,203 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { toast } from '$lib/stores/toast';
import EventForm from '$lib/components/event/EventForm.svelte';
import type { CalendarEvent, UpdateEventInput } from '@calendar/shared';
import * as api from '$lib/api/events';
let event = $state<CalendarEvent | null>(null);
let loading = $state(true);
let isEditing = $state(false);
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
const eventId = $page.params.id;
const result = await api.getEvent(eventId);
if (result.error) {
toast.error('Termin nicht gefunden');
goto('/');
return;
}
event = result.data;
loading = false;
});
async function handleSave(data: UpdateEventInput) {
if (!event) return;
const result = await eventsStore.updateEvent(event.id, data);
if (result.error) {
toast.error(`Fehler beim Speichern: ${result.error.message}`);
return;
}
toast.success('Termin aktualisiert');
isEditing = false;
event = result.data;
}
async function handleDelete() {
if (!event) return;
if (!confirm('Möchten Sie diesen Termin wirklich löschen?')) {
return;
}
const result = await eventsStore.deleteEvent(event.id);
if (result.error) {
toast.error(`Fehler beim Löschen: ${result.error.message}`);
return;
}
toast.success('Termin gelöscht');
goto('/');
}
function handleCancel() {
if (isEditing) {
isEditing = false;
} else {
goto('/');
}
}
</script>
<svelte:head>
<title>{event?.title || 'Termin'} | Kalender</title>
</svelte:head>
<div class="page-container">
{#if loading}
<div class="loading">Laden...</div>
{:else if event}
<div class="card">
<div class="header">
<h1 class="page-title">{isEditing ? 'Termin bearbeiten' : event.title}</h1>
{#if !isEditing}
<div class="actions">
<button class="btn btn-ghost" onclick={() => (isEditing = true)}>
Bearbeiten
</button>
<button class="btn btn-ghost text-destructive" onclick={handleDelete}>
Löschen
</button>
</div>
{/if}
</div>
{#if isEditing}
<EventForm
mode="edit"
{event}
onSave={handleSave}
onCancel={handleCancel}
/>
{:else}
<div class="event-details">
<div class="detail-row">
<span class="label">Zeit</span>
<span class="value">
{#if event.isAllDay}
Ganztägig
{:else}
{new Date(event.startTime).toLocaleString('de-DE')} -
{new Date(event.endTime).toLocaleString('de-DE')}
{/if}
</span>
</div>
{#if event.location}
<div class="detail-row">
<span class="label">Ort</span>
<span class="value">{event.location}</span>
</div>
{/if}
{#if event.description}
<div class="detail-row">
<span class="label">Beschreibung</span>
<span class="value">{event.description}</span>
</div>
{/if}
<div class="detail-row">
<button class="btn btn-ghost" onclick={() => goto('/')}>
Zurück zum Kalender
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
<style>
.page-container {
max-width: 600px;
margin: 0 auto;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
.actions {
display: flex;
gap: 0.5rem;
}
.loading {
text-align: center;
padding: 2rem;
color: hsl(var(--muted-foreground));
}
.event-details {
display: flex;
flex-direction: column;
gap: 1rem;
}
.detail-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.value {
font-size: 1rem;
color: hsl(var(--foreground));
}
.text-destructive {
color: hsl(var(--destructive));
}
</style>

View file

@ -0,0 +1,73 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { toast } from '$lib/stores/toast';
import EventForm from '$lib/components/event/EventForm.svelte';
import type { CreateEventInput } from '@calendar/shared';
import { addHours, parseISO } from 'date-fns';
let initialStart = $state<Date | null>(null);
onMount(() => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Check for start time in URL params
const startParam = $page.url.searchParams.get('start');
if (startParam) {
initialStart = parseISO(startParam);
}
});
async function handleSave(data: CreateEventInput) {
const result = await eventsStore.createEvent(data);
if (result.error) {
toast.error(`Fehler beim Erstellen: ${result.error.message}`);
return;
}
toast.success('Termin erstellt');
goto('/');
}
function handleCancel() {
goto('/');
}
</script>
<svelte:head>
<title>Neuer Termin | Kalender</title>
</svelte:head>
<div class="page-container">
<div class="card">
<h1 class="page-title">Neuer Termin</h1>
<EventForm
mode="create"
initialStartTime={initialStart}
onSave={handleSave}
onCancel={handleCancel}
/>
</div>
</div>
<style>
.page-container {
max-width: 600px;
margin: 0 auto;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: hsl(var(--foreground));
}
</style>

View file

@ -0,0 +1,47 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from '$lib/stores/toast';
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { env } from '$env/dynamic/public';
const feedbackService = createFeedbackService(
env.PUBLIC_BACKEND_URL || 'http://localhost:3014'
);
onMount(() => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
});
async function handleSubmit(type: string, message: string) {
const token = await authStore.getAccessToken();
if (!token) {
toast.error('Bitte melden Sie sich an');
return { success: false, error: 'Not authenticated' };
}
const result = await feedbackService.submit(
{ type: type as any, message },
token
);
if (!result.success) {
toast.error(result.error || 'Fehler beim Senden');
return result;
}
toast.success('Feedback gesendet. Vielen Dank!');
return result;
}
</script>
<svelte:head>
<title>Feedback | Kalender</title>
</svelte:head>
<FeedbackPage onSubmit={handleSubmit} appName="Kalender" />

View file

@ -0,0 +1,240 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
onMount(() => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
});
function handleThemeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
</script>
<svelte:head>
<title>Einstellungen | Kalender</title>
</svelte:head>
<div class="settings-page">
<header class="page-header">
<h1>Einstellungen</h1>
</header>
<section class="settings-section card">
<h2>Erscheinungsbild</h2>
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">Design-Modus</span>
<span class="setting-description">Wählen Sie zwischen hell, dunkel oder automatisch</span>
</div>
<div class="theme-options">
<button
class="theme-option"
class:active={theme.mode === 'light'}
onclick={() => handleThemeChange('light')}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="5"></circle>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
</svg>
Hell
</button>
<button
class="theme-option"
class:active={theme.mode === 'dark'}
onclick={() => handleThemeChange('dark')}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
Dunkel
</button>
<button
class="theme-option"
class:active={theme.mode === 'system'}
onclick={() => handleThemeChange('system')}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
System
</button>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">Farbschema</span>
<span class="setting-description">Wählen Sie ein Farbschema für die App</span>
</div>
<div class="variant-grid">
{#each theme.variants as variant}
<button
class="variant-option"
class:active={theme.variant === variant}
onclick={() => theme.setVariant(variant)}
>
<span class="variant-icon">{THEME_DEFINITIONS[variant].icon}</span>
<span class="variant-label">{THEME_DEFINITIONS[variant].label}</span>
</button>
{/each}
</div>
</div>
</section>
<section class="settings-section card">
<h2>Konto</h2>
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">E-Mail</span>
<span class="setting-value">{authStore.user?.email || '-'}</span>
</div>
</div>
<div class="setting-item">
<button class="btn btn-ghost text-destructive" onclick={() => authStore.signOut().then(() => goto('/login'))}>
Abmelden
</button>
</div>
</section>
</div>
<style>
.settings-page {
max-width: 600px;
margin: 0 auto;
}
.page-header {
margin-bottom: 1.5rem;
}
.page-header h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.settings-section {
margin-bottom: 1.5rem;
}
.settings-section h2 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 1rem 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid hsl(var(--border));
}
.setting-item {
padding: 1rem 0;
border-bottom: 1px solid hsl(var(--border) / 0.5);
}
.setting-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.setting-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.setting-label {
font-weight: 500;
color: hsl(var(--foreground));
}
.setting-description {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
}
.setting-value {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
}
.theme-options {
display: flex;
gap: 0.5rem;
}
.theme-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 2px solid hsl(var(--border));
border-radius: var(--radius-md);
background: transparent;
color: hsl(var(--foreground));
font-size: 0.875rem;
cursor: pointer;
transition: all 150ms ease;
}
.theme-option:hover {
border-color: hsl(var(--primary) / 0.5);
}
.theme-option.active {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.variant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.5rem;
}
.variant-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.75rem;
border: 2px solid hsl(var(--border));
border-radius: var(--radius-md);
background: transparent;
cursor: pointer;
transition: all 150ms ease;
}
.variant-option:hover {
border-color: hsl(var(--primary) / 0.5);
}
.variant-option.active {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.variant-icon {
font-size: 1.5rem;
}
.variant-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.text-destructive {
color: hsl(var(--destructive));
}
</style>

View file

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,45 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
port: 5179,
strictPort: true,
},
ssr: {
noExternal: [
'@calendar/shared',
'@manacore/shared-icons',
'@manacore/shared-ui',
'@manacore/shared-tailwind',
'@manacore/shared-theme',
'@manacore/shared-theme-ui',
'@manacore/shared-feedback-ui',
'@manacore/shared-feedback-service',
'@manacore/shared-feedback-types',
'@manacore/shared-auth',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-subscription-ui',
],
},
optimizeDeps: {
exclude: [
'@calendar/shared',
'@manacore/shared-icons',
'@manacore/shared-ui',
'@manacore/shared-tailwind',
'@manacore/shared-theme',
'@manacore/shared-theme-ui',
'@manacore/shared-feedback-ui',
'@manacore/shared-feedback-service',
'@manacore/shared-feedback-types',
'@manacore/shared-auth',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-subscription-ui',
],
},
});

View file

@ -0,0 +1,23 @@
{
"name": "calendar",
"version": "1.0.0",
"private": true,
"description": "Calendar App - Personal and Shared Calendars with CalDAV/iCal Sync",
"scripts": {
"dev": "turbo run dev",
"dev:backend": "pnpm --filter @calendar/backend dev",
"dev:web": "pnpm --filter @calendar/web dev",
"dev:landing": "pnpm --filter @calendar/landing dev",
"dev:mobile": "pnpm --filter @calendar/mobile dev",
"build": "turbo run build",
"lint": "turbo run lint",
"clean": "turbo run clean",
"db:push": "pnpm --filter @calendar/backend db:push",
"db:studio": "pnpm --filter @calendar/backend db:studio",
"db:seed": "pnpm --filter @calendar/backend db:seed"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"packageManager": "pnpm@9.15.0"
}

Some files were not shown because too many files have changed in this diff Show more