mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
✨ 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:
parent
623b1a21b1
commit
00176a25e0
114 changed files with 9433 additions and 0 deletions
571
apps/calendar/CLAUDE.md
Normal file
571
apps/calendar/CLAUDE.md
Normal 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
79
apps/calendar/README.md
Normal 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
|
||||
12
apps/calendar/apps/backend/drizzle.config.ts
Normal file
12
apps/calendar/apps/backend/drizzle.config.ts
Normal 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,
|
||||
});
|
||||
10
apps/calendar/apps/backend/nest-cli.json
Normal file
10
apps/calendar/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": false,
|
||||
"assets": [],
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
57
apps/calendar/apps/backend/package.json
Normal file
57
apps/calendar/apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
26
apps/calendar/apps/backend/src/app.module.ts
Normal file
26
apps/calendar/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
10
apps/calendar/apps/backend/src/calendar/calendar.module.ts
Normal file
10
apps/calendar/apps/backend/src/calendar/calendar.module.ts
Normal 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 {}
|
||||
131
apps/calendar/apps/backend/src/calendar/calendar.service.ts
Normal file
131
apps/calendar/apps/backend/src/calendar/calendar.service.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
2
apps/calendar/apps/backend/src/calendar/dto/index.ts
Normal file
2
apps/calendar/apps/backend/src/calendar/dto/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-calendar.dto';
|
||||
export * from './update-calendar.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
38
apps/calendar/apps/backend/src/db/connection.ts
Normal file
38
apps/calendar/apps/backend/src/db/connection.ts
Normal 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>;
|
||||
28
apps/calendar/apps/backend/src/db/database.module.ts
Normal file
28
apps/calendar/apps/backend/src/db/database.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
32
apps/calendar/apps/backend/src/db/schema/calendars.schema.ts
Normal file
32
apps/calendar/apps/backend/src/db/schema/calendars.schema.ts
Normal 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;
|
||||
80
apps/calendar/apps/backend/src/db/schema/events.schema.ts
Normal file
80
apps/calendar/apps/backend/src/db/schema/events.schema.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
6
apps/calendar/apps/backend/src/db/schema/index.ts
Normal file
6
apps/calendar/apps/backend/src/db/schema/index.ts
Normal 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';
|
||||
41
apps/calendar/apps/backend/src/db/schema/reminders.schema.ts
Normal file
41
apps/calendar/apps/backend/src/db/schema/reminders.schema.ts
Normal 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;
|
||||
66
apps/calendar/apps/backend/src/event/dto/create-event.dto.ts
Normal file
66
apps/calendar/apps/backend/src/event/dto/create-event.dto.ts
Normal 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;
|
||||
}
|
||||
3
apps/calendar/apps/backend/src/event/dto/index.ts
Normal file
3
apps/calendar/apps/backend/src/event/dto/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './create-event.dto';
|
||||
export * from './update-event.dto';
|
||||
export * from './query-events.dto';
|
||||
27
apps/calendar/apps/backend/src/event/dto/query-events.dto.ts
Normal file
27
apps/calendar/apps/backend/src/event/dto/query-events.dto.ts
Normal 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;
|
||||
}
|
||||
76
apps/calendar/apps/backend/src/event/dto/update-event.dto.ts
Normal file
76
apps/calendar/apps/backend/src/event/dto/update-event.dto.ts
Normal 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;
|
||||
}
|
||||
64
apps/calendar/apps/backend/src/event/event.controller.ts
Normal file
64
apps/calendar/apps/backend/src/event/event.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
12
apps/calendar/apps/backend/src/event/event.module.ts
Normal file
12
apps/calendar/apps/backend/src/event/event.module.ts
Normal 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 {}
|
||||
183
apps/calendar/apps/backend/src/event/event.service.ts
Normal file
183
apps/calendar/apps/backend/src/event/event.service.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
13
apps/calendar/apps/backend/src/health/health.controller.ts
Normal file
13
apps/calendar/apps/backend/src/health/health.controller.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/calendar/apps/backend/src/health/health.module.ts
Normal file
7
apps/calendar/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
40
apps/calendar/apps/backend/src/main.ts
Normal file
40
apps/calendar/apps/backend/src/main.ts
Normal 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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
1
apps/calendar/apps/backend/src/reminder/dto/index.ts
Normal file
1
apps/calendar/apps/backend/src/reminder/dto/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './create-reminder.dto';
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
12
apps/calendar/apps/backend/src/reminder/reminder.module.ts
Normal file
12
apps/calendar/apps/backend/src/reminder/reminder.module.ts
Normal 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 {}
|
||||
158
apps/calendar/apps/backend/src/reminder/reminder.service.ts
Normal file
158
apps/calendar/apps/backend/src/reminder/reminder.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
21
apps/calendar/apps/backend/src/share/dto/create-share.dto.ts
Normal file
21
apps/calendar/apps/backend/src/share/dto/create-share.dto.ts
Normal 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;
|
||||
}
|
||||
2
apps/calendar/apps/backend/src/share/dto/index.ts
Normal file
2
apps/calendar/apps/backend/src/share/dto/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-share.dto';
|
||||
export * from './update-share.dto';
|
||||
11
apps/calendar/apps/backend/src/share/dto/update-share.dto.ts
Normal file
11
apps/calendar/apps/backend/src/share/dto/update-share.dto.ts
Normal 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;
|
||||
}
|
||||
94
apps/calendar/apps/backend/src/share/share.controller.ts
Normal file
94
apps/calendar/apps/backend/src/share/share.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
12
apps/calendar/apps/backend/src/share/share.module.ts
Normal file
12
apps/calendar/apps/backend/src/share/share.module.ts
Normal 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 {}
|
||||
183
apps/calendar/apps/backend/src/share/share.service.ts
Normal file
183
apps/calendar/apps/backend/src/share/share.service.ts
Normal 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')
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
25
apps/calendar/apps/backend/tsconfig.json
Normal file
25
apps/calendar/apps/backend/tsconfig.json
Normal 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
32
apps/calendar/apps/landing/.gitignore
vendored
Normal 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/
|
||||
15
apps/calendar/apps/landing/.prettierrc
Normal file
15
apps/calendar/apps/landing/.prettierrc
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
19
apps/calendar/apps/landing/astro.config.mjs
Normal file
19
apps/calendar/apps/landing/astro.config.mjs
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
35
apps/calendar/apps/landing/package.json
Normal file
35
apps/calendar/apps/landing/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/calendar/apps/landing/public/favicon.svg
Normal file
6
apps/calendar/apps/landing/public/favicon.svg
Normal 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 |
54
apps/calendar/apps/landing/src/components/CTA.astro
Normal file
54
apps/calendar/apps/landing/src/components/CTA.astro
Normal 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>
|
||||
78
apps/calendar/apps/landing/src/components/Features.astro
Normal file
78
apps/calendar/apps/landing/src/components/Features.astro
Normal 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>
|
||||
87
apps/calendar/apps/landing/src/components/Footer.astro
Normal file
87
apps/calendar/apps/landing/src/components/Footer.astro
Normal 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">
|
||||
© {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>
|
||||
106
apps/calendar/apps/landing/src/components/Hero.astro
Normal file
106
apps/calendar/apps/landing/src/components/Hero.astro
Normal 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>
|
||||
45
apps/calendar/apps/landing/src/layouts/Layout.astro
Normal file
45
apps/calendar/apps/landing/src/layouts/Layout.astro
Normal 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>
|
||||
218
apps/calendar/apps/landing/src/pages/index.astro
Normal file
218
apps/calendar/apps/landing/src/pages/index.astro
Normal 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>
|
||||
78
apps/calendar/apps/landing/src/styles/global.css
Normal file
78
apps/calendar/apps/landing/src/styles/global.css
Normal 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;
|
||||
}
|
||||
53
apps/calendar/apps/landing/tailwind.config.mjs
Normal file
53
apps/calendar/apps/landing/tailwind.config.mjs
Normal 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')]
|
||||
};
|
||||
10
apps/calendar/apps/landing/tsconfig.json
Normal file
10
apps/calendar/apps/landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
49
apps/calendar/apps/web/package.json
Normal file
49
apps/calendar/apps/web/package.json
Normal 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"
|
||||
}
|
||||
218
apps/calendar/apps/web/src/app.css
Normal file
218
apps/calendar/apps/web/src/app.css
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/calendar/apps/web/src/app.html
Normal file
13
apps/calendar/apps/web/src/app.html
Normal 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>
|
||||
34
apps/calendar/apps/web/src/lib/api/calendars.ts
Normal file
34
apps/calendar/apps/web/src/lib/api/calendars.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
67
apps/calendar/apps/web/src/lib/api/client.ts
Normal file
67
apps/calendar/apps/web/src/lib/api/client.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
51
apps/calendar/apps/web/src/lib/api/events.ts
Normal file
51
apps/calendar/apps/web/src/lib/api/events.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
23
apps/calendar/apps/web/src/lib/api/reminders.ts
Normal file
23
apps/calendar/apps/web/src/lib/api/reminders.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
42
apps/calendar/apps/web/src/lib/api/shares.ts
Normal file
42
apps/calendar/apps/web/src/lib/api/shares.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
187
apps/calendar/apps/web/src/lib/components/ToastContainer.svelte
Normal file
187
apps/calendar/apps/web/src/lib/components/ToastContainer.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
219
apps/calendar/apps/web/src/lib/components/event/EventForm.svelte
Normal file
219
apps/calendar/apps/web/src/lib/components/event/EventForm.svelte
Normal 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>
|
||||
52
apps/calendar/apps/web/src/lib/i18n/index.ts
Normal file
52
apps/calendar/apps/web/src/lib/i18n/index.ts
Normal 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 };
|
||||
86
apps/calendar/apps/web/src/lib/i18n/locales/de.json
Normal file
86
apps/calendar/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
86
apps/calendar/apps/web/src/lib/i18n/locales/en.json
Normal file
86
apps/calendar/apps/web/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
86
apps/calendar/apps/web/src/lib/i18n/locales/es.json
Normal file
86
apps/calendar/apps/web/src/lib/i18n/locales/es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
86
apps/calendar/apps/web/src/lib/i18n/locales/fr.json
Normal file
86
apps/calendar/apps/web/src/lib/i18n/locales/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
86
apps/calendar/apps/web/src/lib/i18n/locales/it.json
Normal file
86
apps/calendar/apps/web/src/lib/i18n/locales/it.json
Normal 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"
|
||||
}
|
||||
}
|
||||
185
apps/calendar/apps/web/src/lib/stores/auth.svelte.ts
Normal file
185
apps/calendar/apps/web/src/lib/stores/auth.svelte.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
120
apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts
Normal file
120
apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts
Normal 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';
|
||||
},
|
||||
};
|
||||
135
apps/calendar/apps/web/src/lib/stores/events.svelte.ts
Normal file
135
apps/calendar/apps/web/src/lib/stores/events.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
4
apps/calendar/apps/web/src/lib/stores/navigation.ts
Normal file
4
apps/calendar/apps/web/src/lib/stores/navigation.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isSidebarMode = writable(false);
|
||||
export const isNavCollapsed = writable(false);
|
||||
7
apps/calendar/apps/web/src/lib/stores/theme.ts
Normal file
7
apps/calendar/apps/web/src/lib/stores/theme.ts
Normal 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',
|
||||
});
|
||||
50
apps/calendar/apps/web/src/lib/stores/toast.ts
Normal file
50
apps/calendar/apps/web/src/lib/stores/toast.ts
Normal 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();
|
||||
158
apps/calendar/apps/web/src/lib/stores/view.svelte.ts
Normal file
158
apps/calendar/apps/web/src/lib/stores/view.svelte.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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"
|
||||
/>
|
||||
30
apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
30
apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte
Normal 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"
|
||||
/>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
265
apps/calendar/apps/web/src/routes/+layout.svelte
Normal file
265
apps/calendar/apps/web/src/routes/+layout.svelte
Normal 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>
|
||||
123
apps/calendar/apps/web/src/routes/+page.svelte
Normal file
123
apps/calendar/apps/web/src/routes/+page.svelte
Normal 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>
|
||||
231
apps/calendar/apps/web/src/routes/agenda/+page.svelte
Normal file
231
apps/calendar/apps/web/src/routes/agenda/+page.svelte
Normal 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>
|
||||
290
apps/calendar/apps/web/src/routes/calendars/+page.svelte
Normal file
290
apps/calendar/apps/web/src/routes/calendars/+page.svelte
Normal 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>
|
||||
203
apps/calendar/apps/web/src/routes/event/[id]/+page.svelte
Normal file
203
apps/calendar/apps/web/src/routes/event/[id]/+page.svelte
Normal 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>
|
||||
73
apps/calendar/apps/web/src/routes/event/new/+page.svelte
Normal file
73
apps/calendar/apps/web/src/routes/event/new/+page.svelte
Normal 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>
|
||||
47
apps/calendar/apps/web/src/routes/feedback/+page.svelte
Normal file
47
apps/calendar/apps/web/src/routes/feedback/+page.svelte
Normal 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" />
|
||||
240
apps/calendar/apps/web/src/routes/settings/+page.svelte
Normal file
240
apps/calendar/apps/web/src/routes/settings/+page.svelte
Normal 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>
|
||||
12
apps/calendar/apps/web/svelte.config.js
Normal file
12
apps/calendar/apps/web/svelte.config.js
Normal 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;
|
||||
14
apps/calendar/apps/web/tsconfig.json
Normal file
14
apps/calendar/apps/web/tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
45
apps/calendar/apps/web/vite.config.ts
Normal file
45
apps/calendar/apps/web/vite.config.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
});
|
||||
23
apps/calendar/package.json
Normal file
23
apps/calendar/package.json
Normal 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
Loading…
Add table
Add a link
Reference in a new issue