Add event popup and edit page concept

This commit is contained in:
Tim Lappe 2025-04-26 05:43:35 +02:00
parent 86aa6f62f8
commit a86890da2b
41 changed files with 1464 additions and 296 deletions

View File

@ -11,4 +11,6 @@ alwaysApply: true
# Folders
- components: general reusable components
- pages: specific components related to the domain and the application
- lib: plain typescript for building the domain logic, services etc. you will never put react code in here
- lib: plain typescript for building the domain logic, services etc. you will never put react code in here
Every component must be in its own folder (together with optional custom css)

View File

@ -11,4 +11,7 @@ The Symfony project is designed in a domain driven design pattern with 3 Main Fo
# Updating / Creating Entities
When updating or creating entities, always update and the related DTO classes.
DTO Classes are a representation of a JSON Schema
DTO Classes are a representation of a JSON Schema
# Code style
You will always write perfect and strict PHP 8.4 code that aligns to phpstan level 10 rules

View File

@ -1,6 +1,6 @@
<?php
namespace App\Application\Controller;
namespace App\Application\Controller\Calendar;
use App\Infrastructure\Repository\EventRepository;
use App\Application\DTO\CalendarDTO;

View File

@ -0,0 +1,36 @@
<?php
namespace App\Application\Controller\Event;
use App\Application\DTO\EventDraftDTO;
use App\Application\DTO\GenerateDraftDTO;
use App\Domain\Event\GenerateDraftHandler;
use App\Domain\Event\GenerateDraft;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use OpenApi\Attributes as OA;
use Nelmio\ApiDocBundle\Attribute\Model;
#[Route('/api/events', name: 'generate_draft', methods: ['POST'])]
#[OA\Tag(name: 'Events')]
class GenerateDraftController extends AbstractController
{
public function __construct(
private readonly GenerateDraftHandler $generateDraftHandler
) {
}
#[Route('/generation', name: 'generate_draft', methods: ['POST'])]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: GenerateDraftDTO::class)))]
#[OA\Response(
response: 200,
description: 'Returns the generated draft',
content: new OA\JsonContent(ref: new Model(type: EventDraftDTO::class))
)]
public function generateDraft(#[MapRequestPayload] GenerateDraftDTO $generateDraftDTO): JsonResponse
{
$draft = $this->generateDraftHandler->handle(new GenerateDraft($generateDraftDTO->input));
return $this->json(EventDraftDTO::fromDraft($draft));
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Application\Controller\Event;
use App\Domain\Event\ReadEventsHandler;
use App\Domain\Event\ReadEvents;
use App\Domain\Model\Persisted\PersistedEvent;
use App\Application\DTO\EventDTO;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
#[Route('/api/events', name: 'api_events_')]
class GetEventsController extends AbstractController
{
public function __construct(
private readonly ReadEventsHandler $readEventsHandler
) {
}
#[Route('', name: 'list', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Response(
response: 200,
description: 'Returns list of events',
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
public function list(): JsonResponse
{
/** @var PersistedEvent[] $events */
$events = $this->readEventsHandler->handle(new ReadEvents(null));
$eventDTOs = array_map(static fn (PersistedEvent $event) => EventDTO::fromEntity($event), $events);
return $this->json($eventDTOs);
}
#[Route('/{id}', name: 'get', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Parameter(
name: 'id',
description: 'Event ID',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string')
)]
#[OA\Response(
response: 200,
description: 'Returns event details',
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
#[OA\Response(
response: 404,
description: 'Event not found'
)]
public function get(string $id): JsonResponse
{
$events = $this->readEventsHandler->handle(new ReadEvents((int)$id));
if (count($events) === 0) {
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);
}
return $this->json(EventDTO::fromEntity($events[0] ?? throw new NotFoundHttpException('Event not found')));
}
}

View File

@ -1,243 +0,0 @@
<?php
namespace App\Application\Controller;
use App\Domain\Model\Event;
use App\Infrastructure\Repository\EventRepository;
use App\Application\DTO\EventDTO;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
#[Route('/api/events', name: 'api_events_')]
class EventController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ValidatorInterface $validator,
private readonly EventRepository $eventRepository
) {
}
#[Route('', name: 'list', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Response(
response: 200,
description: 'Returns list of events',
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
public function list(): JsonResponse
{
/** @var Event[] $events */
$events = $this->eventRepository->findAll();
$eventDTOs = array_map(static fn (Event $event) => EventDTO::fromEntity($event), $events);
return $this->json($eventDTOs);
}
#[Route('/{id}', name: 'get', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Parameter(
name: 'id',
description: 'Event ID',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string')
)]
#[OA\Response(
response: 200,
description: 'Returns event details',
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
#[OA\Response(
response: 404,
description: 'Event not found'
)]
public function get(string $id): JsonResponse
{
$event = $this->eventRepository->find($id);
if (!$event instanceof Event) {
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);
}
return $this->json(EventDTO::fromEntity($event));
}
#[Route('', name: 'create', methods: ['POST'])]
#[OA\Tag(name: 'Events')]
#[OA\RequestBody(
required: true,
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
#[OA\Response(
response: 201,
description: 'Event created',
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
#[OA\Response(
response: 400,
description: 'Invalid input'
)]
public function create(Request $request): JsonResponse
{
/** @var array<string, mixed>|null $data */
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->json(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST);
}
$event = new Event();
$event->setTitle((string)$data['title']);
if (array_key_exists('description', $data)) {
$event->setDescription($data['description'] !== null ? (string)$data['description'] : null);
}
try {
$event->setStart(new \DateTimeImmutable($data['start'] ?? 'now'));
$event->setEnd(new \DateTimeImmutable($data['end'] ?? 'now'));
} catch (\Exception) {
return $this->json(['error' => 'Invalid date format'], Response::HTTP_BAD_REQUEST);
}
$event->setAllDay(isset($data['allDay']) ? (bool)$data['allDay'] : false);
$errors = $this->validator->validate($event);
if (count($errors) > 0) {
$errorMessages = [];
foreach ($errors as $error) {
$errorMessages[$error->getPropertyPath()] = $error->getMessage();
}
return $this->json(['errors' => $errorMessages], Response::HTTP_BAD_REQUEST);
}
$this->entityManager->persist($event);
$this->entityManager->flush();
return $this->json(EventDTO::fromEntity($event), Response::HTTP_CREATED);
}
#[Route('/{id}', name: 'update', methods: ['PUT'])]
#[OA\Tag(name: 'Events')]
#[OA\Parameter(
name: 'id',
description: 'Event ID',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string')
)]
#[OA\RequestBody(
required: true,
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
#[OA\Response(
response: 200,
description: 'Event updated',
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
#[OA\Response(
response: 400,
description: 'Invalid input'
)]
#[OA\Response(
response: 404,
description: 'Event not found'
)]
public function update(string $id, Request $request): JsonResponse
{
$event = $this->eventRepository->find($id);
if (!$event instanceof Event) {
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);
}
/** @var array<string, mixed>|null $data */
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->json(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST);
}
if (isset($data['title'])) {
$event->setTitle((string)$data['title']);
}
if (array_key_exists('description', $data)) {
$event->setDescription($data['description'] !== null ? (string)$data['description'] : null);
}
if (isset($data['start'])) {
try {
$event->setStart(new \DateTimeImmutable((string)$data['start']));
} catch (\Exception) {
return $this->json(['error' => 'Invalid start date format'], Response::HTTP_BAD_REQUEST);
}
}
if (isset($data['end'])) {
try {
$event->setEnd(new \DateTimeImmutable((string)$data['end']));
} catch (\Exception) {
return $this->json(['error' => 'Invalid end date format'], Response::HTTP_BAD_REQUEST);
}
}
if (isset($data['allDay'])) {
$event->setAllDay((bool)$data['allDay']);
}
$errors = $this->validator->validate($event);
if (count($errors) > 0) {
$errorMessages = [];
foreach ($errors as $error) {
$errorMessages[$error->getPropertyPath()] = $error->getMessage();
}
return $this->json(['errors' => $errorMessages], Response::HTTP_BAD_REQUEST);
}
$this->entityManager->flush();
return $this->json(EventDTO::fromEntity($event));
}
#[Route('/{id}', name: 'delete', methods: ['DELETE'])]
#[OA\Tag(name: 'Events')]
#[OA\Parameter(
name: 'id',
description: 'Event ID',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string')
)]
#[OA\Response(
response: 204,
description: 'Event deleted'
)]
#[OA\Response(
response: 404,
description: 'Event not found'
)]
public function delete(string $id): JsonResponse
{
$event = $this->eventRepository->find($id);
if (!$event instanceof Event) {
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);
}
$this->entityManager->remove($event);
$this->entityManager->flush();
return $this->json(null, Response::HTTP_NO_CONTENT);
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Application\Controller;
namespace App\Application\Controller\User;
use App\Application\DTO\UserDTO;
use App\Infrastructure\Repository\UserRepository;

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Application\DTO;
use App\Domain\Model\Event;
use App\Domain\Model\Persisted\PersistedEvent;
use OpenApi\Attributes as OA;
#[OA\Schema]
@ -26,7 +26,7 @@ final readonly class EventDTO
) {
}
public static function fromEntity(Event $event): self
public static function fromEntity(PersistedEvent $event): self
{
return new self(
$event->getId(),

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Application\DTO;
use App\Domain\Model\EventDraft;
use OpenApi\Attributes as OA;
#[OA\Schema]
final readonly class EventDraftDTO
{
public function __construct(
#[OA\Property(type: 'string')]
public string $id,
#[OA\Property(type: 'string')]
public string $title,
#[OA\Property(type: 'string')]
public string $description,
#[OA\Property(type: 'string')]
public ?string $start,
#[OA\Property(type: 'string')]
public ?string $end,
#[OA\Property(type: 'boolean')]
public bool $allDay
) {
}
public static function fromDraft(EventDraft $draft): self
{
return new self(
$draft->title(),
$draft->description(),
$draft->location(),
$draft->start()?->format('Y-m-d H:i:s'),
$draft->end()?->format('Y-m-d H:i:s'),
$draft->allDay()
);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Application\DTO;
use OpenApi\Attributes as OA;
#[OA\Schema]
class GenerateDraftDTO
{
public function __construct(
#[OA\Property(type: 'string')]
public readonly string $input,
) {
}
}

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Application\DTO;
use App\Domain\Model\User;
use App\Domain\Model\Persisted\PersistedUser;
use OpenApi\Attributes as OA;
#[OA\Schema]
@ -24,7 +24,7 @@ final readonly class UserDTO
) {
}
public static function fromEntity(User $user): self
public static function fromEntity(PersistedUser $user): self
{
return new self(
$user->getId(),

View File

@ -0,0 +1,16 @@
<?php
namespace App\Domain\Event;
class GenerateDraft
{
public function __construct(
private readonly string $input,
) {
}
public function input(): string
{
return $this->input;
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Domain\Event;
use App\Domain\Chat\ChatProviderInterface;
use App\Domain\Chat\ChatSession;
use App\Domain\Model\EventDraft;
use App\Domain\User\UserContextProvider;
class GenerateDraftHandler
{
public function __construct(
private readonly ChatProviderInterface $chatProvider,
private readonly UserContextProvider $userContextProvider,
) {
}
public function handle(GenerateDraft $generateDraft): EventDraft
{
$userContext = $this->userContextProvider->getUserContext();
$chat = new ChatSession($this->chatProvider);
$chat->system(<<<PROMPT
You are a helpful assistant that generates event drafts based on user input.
The user input can be anything from a very detailed description to a simple sentence or just a snippet from another source (Chat, email, etc.).
You should always generate a draft even if the user input is not very detailed.
Go Step by Step by step:
- First, analyze the user input and analyze the context of the user input.
- Then, generate a draft of the event.
- Finally, return the draft in the following format:
The event draft should be in the following format:
```json
{
"title": "Event title",
"description": "Event description",
"location": "Event location",
"start": "Event start date (YYYY-MM-DD HH:MM:SS)",
"end": "Event end date (YYYY-MM-DD HH:MM:SS)",
"allDay": "Event all day (true or false)"
}
```
This is the current context:
- Time: {$userContext->now()->format('Y-m-d H:i:s')}
You will only respond with the JSON object in ```json``` tags, nothing else.
PROMPT);
$chat->user($generateDraft->input());
$chat->commit(reasoning: false);
$response = $chat->getMessages()->getLastMessage()->getJson() ?? throw new \Exception('JSON response is required: ' . $chat->getMessages()->getLastMessage()->getContent());
if (!isset($response['title']) || !is_string($response['title'])) {
throw new \Exception('Title is required and must be a string: ' . json_encode($response));
}
if (!isset($response['description']) || !is_string($response['description'])) {
throw new \Exception('Description is required and must be a string: ' . json_encode($response));
}
if (!isset($response['location']) || !is_string($response['location'])) {
throw new \Exception('Location is required and must be a string: ' . json_encode($response));
}
return new EventDraft(
$response['title'],
$response['description'],
$response['location'],
isset($response['start']) && is_string($response['start']) ? new \DateTime($response['start']) : null,
isset($response['end']) && is_string($response['end']) ? new \DateTime($response['end']) : null,
isset($response['allDay']) ? (bool)$response['allDay'] : false,
);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Domain\Event;
use App\Domain\Model\PersistedEvent;
class ReadEvents
{
public function __construct(
private readonly ?int $id = null
) {
}
public function id(): ?int
{
return $this->id;
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Domain\Event;
use App\Domain\Model\Persisted\PersistedEvent;
use App\Infrastructure\Repository\EventRepository;
class ReadEventsHandler
{
public function __construct(
private readonly EventRepository $eventRepository,
) {
}
/**
* @return PersistedEvent[]
*/
public function handle(ReadEvents $readEvents): array
{
return $this->eventRepository->findAll();
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Domain\Model;
use DateTimeInterface;
class EventDraft
{
public function __construct(
private readonly string $title,
private readonly string $description,
private readonly string $location,
private readonly ?DateTimeInterface $start,
private readonly ?DateTimeInterface $end,
private readonly bool $allDay,
) {
}
public function title(): string
{
return $this->title;
}
public function description(): string
{
return $this->description;
}
public function location(): string
{
return $this->location;
}
public function start(): ?DateTimeInterface
{
return $this->start;
}
public function end(): ?DateTimeInterface
{
return $this->end;
}
public function allDay(): bool
{
return $this->allDay;
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Model;
namespace App\Domain\Model\Persisted;
use App\Infrastructure\Repository\EventRepository;
use Doctrine\DBAL\Types\Types;
@ -10,7 +10,7 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: EventRepository::class)]
#[ORM\Table(name: 'app_event')]
class Event
class PersistedEvent
{
#[ORM\Id]
#[ORM\Column(type: 'string')]

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Model;
namespace App\Domain\Model\Persisted;
use App\Infrastructure\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
@ -9,7 +9,7 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'app_user')]
class User
class PersistedUser
{
#[ORM\Id]
#[ORM\Column(type: 'string')]

View File

@ -0,0 +1,18 @@
<?php
namespace App\Domain\Model;
use DateTimeInterface;
final readonly class UserContext
{
public function __construct(
private readonly DateTimeInterface $now,
) {
}
public function now(): DateTimeInterface
{
return $this->now;
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Domain\User;
use App\Domain\Model\UserContext;
use DateTimeImmutable;
final readonly class UserContextProvider
{
public function getUserContext(): UserContext
{
return new UserContext(new DateTimeImmutable());
}
}

View File

@ -2,7 +2,7 @@
namespace App\Infrastructure\DataFixtures;
use App\Domain\Model\Event;
use App\Domain\Model\PersistedEvent;
use DateTimeImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
@ -44,7 +44,7 @@ final class EventFixtures extends Fixture
private function createDoctorAppointment(ObjectManager $manager): void
{
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Doctor Appointment')
->setDescription('Annual checkup with Dr. Smith')
->setFrom(new DateTimeImmutable('next monday 09:30'))
@ -56,7 +56,7 @@ final class EventFixtures extends Fixture
private function createDentistAppointment(ObjectManager $manager): void
{
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Dentist Appointment')
->setDescription('Teeth cleaning with Dr. Johnson')
->setFrom(new DateTimeImmutable('next friday 14:00'))
@ -70,7 +70,7 @@ final class EventFixtures extends Fixture
{
$tomorrow = new DateTimeImmutable('tomorrow');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Yoga Class')
->setDescription('Vinyasa flow at Peaceful Mind Studio')
->setFrom($tomorrow->setTime(18, 0))
@ -84,7 +84,7 @@ final class EventFixtures extends Fixture
{
$dayAfterTomorrow = new DateTimeImmutable('today +2 days');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Gym Workout')
->setDescription('Strength training at Fitness Center')
->setFrom($dayAfterTomorrow->setTime(7, 0))
@ -98,7 +98,7 @@ final class EventFixtures extends Fixture
{
$thisWeekend = new DateTimeImmutable('next saturday');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Dinner with Friends')
->setDescription('Dinner at Italiano Restaurant with Alex and Jamie')
->setFrom($thisWeekend->setTime(19, 0))
@ -112,7 +112,7 @@ final class EventFixtures extends Fixture
{
$nextFriday = new DateTimeImmutable('next friday');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Movie Night')
->setDescription('Watching new Marvel movie at Cinema City')
->setFrom($nextFriday->setTime(20, 0))
@ -126,7 +126,7 @@ final class EventFixtures extends Fixture
{
$twoWeeksLater = new DateTimeImmutable('today +14 days');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Sarah\'s Birthday Party')
->setDescription('Birthday celebration at Rooftop Bar')
->setFrom($twoWeeksLater->setTime(18, 0))
@ -141,7 +141,7 @@ final class EventFixtures extends Fixture
$nextWeekend = new DateTimeImmutable('next saturday');
$nextWeekendEnd = new DateTimeImmutable('next sunday');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Weekend Getaway')
->setDescription('Short trip to the mountains')
->setFrom($nextWeekend)
@ -156,7 +156,7 @@ final class EventFixtures extends Fixture
$vacationStart = new DateTimeImmutable('next month first day');
$vacationEnd = new DateTimeImmutable('next month first day +6 days');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Summer Vacation')
->setDescription('Beach vacation in Hawaii')
->setFrom($vacationStart)
@ -170,7 +170,7 @@ final class EventFixtures extends Fixture
{
$nextSaturday = new DateTimeImmutable('next saturday');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Italian Cooking Class')
->setDescription('Learn to make pasta from scratch at Culinary Center')
->setFrom($nextSaturday->setTime(14, 0))
@ -184,7 +184,7 @@ final class EventFixtures extends Fixture
{
$nextThursday = new DateTimeImmutable('next thursday');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Book Club Meeting')
->setDescription('Discussing "The Midnight Library" at Local Cafe')
->setFrom($nextThursday->setTime(19, 0))
@ -198,7 +198,7 @@ final class EventFixtures extends Fixture
{
$inTwoWeeks = new DateTimeImmutable('today +14 days');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Hiking Trip')
->setDescription('Explore Blue Mountain Trail')
->setFrom($inTwoWeeks->setTime(8, 0))
@ -212,7 +212,7 @@ final class EventFixtures extends Fixture
{
$nextSunday = new DateTimeImmutable('next sunday');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Grocery Shopping')
->setDescription('Weekly grocery run at Farmer\'s Market')
->setFrom($nextSunday->setTime(10, 0))
@ -226,7 +226,7 @@ final class EventFixtures extends Fixture
{
$firstOfNextMonth = new DateTimeImmutable('first day of next month');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Monthly Deep Cleaning')
->setDescription('House deep cleaning day')
->setFrom($firstOfNextMonth)
@ -240,7 +240,7 @@ final class EventFixtures extends Fixture
{
$twoMonthsFromNow = new DateTimeImmutable('today +2 months');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Relationship Anniversary')
->setDescription('Dinner reservation at Sunset Restaurant')
->setFrom($twoMonthsFromNow->setTime(19, 0))
@ -254,7 +254,7 @@ final class EventFixtures extends Fixture
{
$nextMonth = new DateTimeImmutable('next month');
$event = new Event();
$event = new PersistedEvent();
$event->setTitle('Live Concert')
->setDescription('Favorite band performing at Central Arena')
->setFrom($nextMonth->setTime(20, 0))

View File

@ -2,7 +2,7 @@
namespace App\Infrastructure\DataFixtures;
use App\Domain\Model\User;
use App\Domain\Model\PersistedUser;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
@ -12,7 +12,7 @@ final class UserFixtures extends Fixture
public function load(ObjectManager $manager): void
{
$user = new User();
$user = new PersistedUser();
$user->setEmail('user@example.com')
->setFirstName('John')
->setLastName('Doe');

View File

@ -2,26 +2,26 @@
namespace App\Infrastructure\Repository;
use App\Domain\Model\Event;
use App\Domain\Model\Persisted\PersistedEvent;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Event>
* @extends ServiceEntityRepository<PersistedEvent>
*/
class EventRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Event::class);
parent::__construct($registry, PersistedEvent::class);
}
/**
* @return array<Event>
* @return array<PersistedEvent>
*/
public function findByDateRange(\DateTimeInterface $start, \DateTimeInterface $end): array
{
/** @var array<Event> $result */
/** @var array<PersistedEvent> $result */
$result = $this->createQueryBuilder('e')
->andWhere('e.from >= :start AND e.from <= :end')
->orWhere('e.to >= :start AND e.to <= :end')

View File

@ -2,27 +2,27 @@
namespace App\Infrastructure\Repository;
use App\Domain\Model\User;
use App\Domain\Model\Persisted\PersistedUser;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
* @extends ServiceEntityRepository<PersistedUser>
*/
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
parent::__construct($registry, PersistedUser::class);
}
public function save(User $user): void
public function save(PersistedUser $user): void
{
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
public function remove(User $user): void
public function remove(PersistedUser $user): void
{
$this->getEntityManager()->remove($user);
$this->getEntityManager()->flush();

View File

@ -21,6 +21,7 @@
"@types/react-dom": "^19.1.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.5.2",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
@ -14035,6 +14036,54 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.2.tgz",
"integrity": "sha512-9Rw8r199klMnlGZ8VAsV/I8WrIF6IyJ90JQUdboupx1cdkgYqwnrYjH+I/nY/7cA1X5zia4mDJqH36npP7sxGQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0",
"turbo-stream": "2.4.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.5.2.tgz",
"integrity": "sha512-yk1XW8Fj7gK7flpYBXF3yzd2NbX6P7Kxjvs2b5nu1M04rb5pg/Zc4fGdBNTeT4eDYL2bvzWNyKaIMJX/RKHTTg==",
"license": "MIT",
"dependencies": {
"react-router": "7.5.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/react-scripts": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
@ -14950,6 +14999,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@ -16328,6 +16383,12 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/turbo-stream": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
"license": "ISC"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -15,6 +15,7 @@
"@types/react-dom": "^19.1.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.5.2",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"

View File

@ -2,8 +2,12 @@ import './App.css';
import { TabView } from './components/navigation/TabView';
import Home from './pages/home/Home';
import Profile from './pages/profile/Profile';
import EditEvent from './pages/edit-event/EditEvent';
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons';
import { faHome } from '@fortawesome/free-solid-svg-icons';
import { useUser } from './lib/context';
import React, { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
const tabs = [
{ id: 'home', label: 'Home', component: Home, icon: faHome },
@ -12,11 +16,26 @@ const tabs = [
];
function App() {
const { initialLoad } = useUser();
const [renderContent, setRenderContent] = useState(false);
useEffect(() => {
// Only render content when initial loading is done or after a timeout
if (!initialLoad) {
setRenderContent(true);
}
}, [initialLoad]);
return (
<div className="App">
<TabView
tabs={tabs}
/>
{renderContent && (
<BrowserRouter>
<Routes>
<Route path="/edit-event/:id" element={<EditEvent />} />
<Route path="*" element={<TabView tabs={tabs} />} />
</Routes>
</BrowserRouter>
)}
</div>
);
}

View File

@ -70,7 +70,8 @@ export const TabView: React.FC<TabBarProps> = ({
className="tab-content"
ref={contentRef}
style={{
transition: 'opacity 0.3s ease, transform 0.3s ease',
transition: 'opacity 0.4s ease, transform 0.4s ease',
opacity: isTransitioning ? 0 : 1,
}}
>
{ActiveComponent && <ActiveComponent />}

View File

@ -0,0 +1,164 @@
.event-draft-card-container {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 2000;
display: flex;
justify-content: center;
padding: 16px;
pointer-events: none;
transform: translateY(-100%);
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
padding-top: 0;
width: 100%;
}
.event-draft-card-container.visible {
transform: translateY(0);
pointer-events: auto;
}
.event-draft-card-container.closing {
transform: translateY(-100%);
}
@keyframes cardDisappear {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
}
.event-draft-card {
background-color: #ffffff;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2), 0 6px 12px rgba(0, 0, 0, 0.15);
width: 100%;
max-width: 100%;
padding: 24px;
position: relative;
margin: 0 16px;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.event-draft-card-container.closing .event-draft-card {
animation: cardDisappear 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.draft-close-button {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 22px;
cursor: pointer;
color: #666;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s;
}
.draft-close-button:hover {
background-color: #f0f0f0;
color: #000;
}
.draft-card-content {
width: 100%;
}
.draft-title {
font-size: 22px;
font-weight: 600;
margin: 0 0 16px 0;
line-height: 1.2;
color: #000000;
}
.draft-description {
font-size: 16px;
line-height: 1.4;
margin-bottom: 20px;
color: #333;
white-space: pre-line;
}
.draft-details {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 24px;
font-size: 15px;
}
.draft-time {
display: flex;
align-items: flex-start;
color: #555;
}
.draft-label {
font-weight: 600;
margin-right: 6px;
min-width: 60px;
display: inline-block;
}
.draft-all-day {
display: inline-block;
background-color: #F2ADAD;
color: #000;
padding: 4px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
margin-top: 6px;
width: fit-content;
}
.draft-actions {
display: flex;
gap: 16px;
width: 100%;
}
.draft-save-button, .draft-edit-button {
padding: 12px 20px;
border-radius: 10px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
flex: 1;
border: none;
}
.draft-save-button {
background-color: #F2ADAD;
color: #000000;
}
.draft-save-button:hover {
background-color: #f09e9e;
transform: translateY(-1px);
}
.draft-edit-button {
background-color: #e8f0fe;
color: #1a73e8;
}
.draft-edit-button:hover {
background-color: #d2e3fc;
transform: translateY(-1px);
}

View File

@ -0,0 +1,118 @@
import React, { useState, useEffect, useRef } from 'react';
import { EventDraft } from '../../lib/api/endpoints';
import { useNavigate } from 'react-router-dom';
import './EventDraftCard.css';
interface EventDraftCardProps {
draft: EventDraft;
onClose: () => void;
onSave?: () => void;
onEdit?: (draft: EventDraft) => void;
}
const EventDraftCard: React.FC<EventDraftCardProps> = ({
draft,
onClose,
onSave,
onEdit
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
useEffect(() => {
setIsVisible(true);
// Add click event listener to detect clicks outside the card
const handleClickOutside = (event: MouseEvent) => {
if (cardRef.current && !cardRef.current.contains(event.target as Node)) {
handleClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleClose = () => {
setIsClosing(true);
setTimeout(() => {
setIsVisible(false);
onClose();
}, 400); // Match animation duration
};
const handleEdit = () => {
if (onEdit) {
onEdit(draft);
} else {
// Default behavior if no onEdit handler is provided
console.log('Navigate to edit page for draft:', draft);
sessionStorage.setItem('editingEventDraft', JSON.stringify(draft));
navigate(`/edit-event/${draft.id}`);
}
};
const formatDateTime = (dateStr: string | null) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
return (
<div className={`event-draft-card-container ${isVisible ? 'visible' : ''} ${isClosing ? 'closing' : ''}`}>
<div className="event-draft-card" ref={cardRef}>
<div className="draft-card-content">
<h2 className="draft-title">{draft.title}</h2>
{draft.description && (
<div className="draft-description">
{draft.description}
</div>
)}
<div className="draft-details">
{draft.start && (
<div className="draft-time">
<span className="draft-label">Start:</span> {formatDateTime(draft.start)}
</div>
)}
{draft.end && (
<div className="draft-time">
<span className="draft-label">Ende:</span> {formatDateTime(draft.end)}
</div>
)}
{draft.allDay && (
<div className="draft-all-day">Ganztägig</div>
)}
</div>
<div className="draft-actions">
<button className="draft-edit-button" onClick={handleEdit}>
Bearbeiten
</button>
{onSave && (
<button className="draft-save-button" onClick={onSave}>
Speichern
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default EventDraftCard;

View File

@ -0,0 +1,44 @@
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.9);
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
animation: fadeInOverlay 0.5s ease-in-out;
transition: opacity 0.5s ease;
}
.loading-overlay.transparent-background {
background-color: rgba(255, 255, 255, 0.5);
}
.loading-overlay-content {
padding: 2rem;
border-radius: 1rem;
background-color: white;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
animation: scaleInOverlay 0.5s ease-in-out;
}
@keyframes fadeInOverlay {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleInOverlay {
from {
transform: scale(0.95);
}
to {
transform: scale(1);
}
}

View File

@ -0,0 +1,31 @@
import React from 'react';
import LoadingSpinner from './LoadingSpinner';
import './LoadingOverlay.css';
interface LoadingOverlayProps {
isVisible: boolean;
message?: string;
transparentBackground?: boolean;
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
isVisible,
message = 'Loading...',
transparentBackground = false,
}) => {
if (!isVisible) return null;
const overlayClass = transparentBackground
? 'loading-overlay transparent-background'
: 'loading-overlay';
return (
<div className={overlayClass}>
<div className="loading-overlay-content">
<LoadingSpinner size="large" message={message} />
</div>
</div>
);
};
export default LoadingOverlay;

View File

@ -0,0 +1,85 @@
.loading-spinner-container {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
transition: all 0.5s ease;
min-height: 200px;
opacity: 1;
}
.loading-spinner-container.full-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.9);
z-index: 1000;
}
.loading-spinner-content {
display: flex;
flex-direction: column;
align-items: center;
animation: fadeIn 0.6s ease-out forwards;
}
.loading-spinner {
color: #F2ADAD;
opacity: 0;
transform: scale(0.9);
animation: scaleIn 0.6s ease-out forwards 0.2s;
}
.loading-spinner-small {
font-size: 1.5rem;
}
.loading-spinner-medium {
font-size: 2.5rem;
}
.loading-spinner-large {
font-size: 4rem;
}
.loading-spinner-message {
margin-top: 1rem;
font-size: 1.2rem;
color: #555;
font-weight: 500;
opacity: 0;
animation: slideUp 0.6s ease-out forwards 0.3s;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -0,0 +1,35 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import './LoadingSpinner.css';
interface LoadingSpinnerProps {
size?: 'small' | 'medium' | 'large';
message?: string;
fullScreen?: boolean;
}
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'medium',
message = 'Loading...',
fullScreen = false,
}) => {
const containerClass = fullScreen
? 'loading-spinner-container full-screen'
: 'loading-spinner-container';
const sizeClass = `loading-spinner-${size}`;
return (
<div className={containerClass}>
<div className="loading-spinner-content">
<div className={`loading-spinner ${sizeClass}`}>
<FontAwesomeIcon icon={faSpinner} spin />
</div>
{message && <p className="loading-spinner-message">{message}</p>}
</div>
</div>
);
};
export default LoadingSpinner;

View File

@ -41,6 +41,24 @@ export const createEvent = (data: CreateEventRequest) => post<Event>('/api/event
export const updateEvent = (id: string, data: Partial<CreateEventRequest>) => put<Event>(`/api/events/${id}`, data);
export const deleteEvent = (id: string) => del<void>(`/api/events/${id}`);
// Event draft types
export type GenerateDraftRequest = {
input: string;
};
export type EventDraft = {
id: string;
title: string;
description: string;
start: string | null;
end: string | null;
allDay: boolean;
};
// Event draft endpoints
export const generateDraft = (data: GenerateDraftRequest) =>
post<EventDraft>('/api/events/generation', data);
// Calendar types
export type Calendar = {
events: Event[];

View File

@ -1,10 +1,12 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { User, getUser } from '../api/endpoints';
import LoadingOverlay from '../../components/ui/LoadingOverlay';
type UserContextType = {
user: User | null;
loading: boolean;
error: Error | null;
initialLoad: boolean;
refetch: () => Promise<void>;
};
@ -14,6 +16,7 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [initialLoad, setInitialLoad] = useState(true);
const fetchUser = async () => {
try {
@ -29,7 +32,10 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
};
useEffect(() => {
fetchUser();
fetchUser().finally(() => {
// Longer delay to ensure child components are ready before removing overlay
setTimeout(() => setInitialLoad(false), 800);
});
}, []);
return (
@ -38,9 +44,14 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
user,
loading,
error,
initialLoad,
refetch: fetchUser
}}
>
<LoadingOverlay
isVisible={initialLoad}
message="Welcome to Calendi..."
/>
{children}
</UserContext.Provider>
);

View File

@ -0,0 +1,132 @@
.edit-event-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background-color: #ffffff;
min-height: 100vh;
}
.edit-event-title {
font-size: 1.8rem;
font-weight: 600;
margin-bottom: 2rem;
color: #000000;
}
.edit-event-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 500;
font-size: 1rem;
color: #333;
}
.form-group input[type="text"],
.form-group textarea,
.form-group input[type="date"],
.form-group input[type="time"] {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
background-color: #f9f9f9;
transition: border-color 0.2s ease;
}
.form-group input[type="text"]:focus,
.form-group textarea:focus,
.form-group input[type="date"]:focus,
.form-group input[type="time"]:focus {
outline: none;
border-color: #F2ADAD;
box-shadow: 0 0 0 2px rgba(242, 173, 173, 0.2);
}
.form-checkbox {
flex-direction: row;
align-items: center;
gap: 0.75rem;
}
.form-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #F2ADAD;
}
.form-row {
display: flex;
gap: 1rem;
}
.form-row .form-group {
flex: 1;
}
.edit-event-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
}
.save-button, .cancel-button {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
border: none;
}
.save-button {
background-color: #F2ADAD;
color: #000000;
}
.save-button:hover {
background-color: #f09e9e;
transform: translateY(-1px);
}
.cancel-button {
background-color: #f0f0f0;
color: #555;
}
.cancel-button:hover {
background-color: #e5e5e5;
transform: translateY(-1px);
}
@media (max-width: 768px) {
.edit-event-container {
padding: 1.5rem;
}
.form-row {
flex-direction: column;
gap: 1.5rem;
}
.edit-event-actions {
flex-direction: column-reverse;
gap: 0.75rem;
}
.save-button, .cancel-button {
width: 100%;
padding: 1rem;
}
}

View File

@ -0,0 +1,218 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { EventDraft, createEvent } from '../../lib/api/endpoints';
import LoadingSpinner from '../../components/ui/LoadingSpinner';
import './EditEvent.css';
interface EditEventParams {
id: string;
}
const EditEvent: React.FC = () => {
const params = useParams<{ id: string }>();
const id = params.id;
const navigate = useNavigate();
const [eventDraft, setEventDraft] = useState<EventDraft | null>(null);
const [loading, setLoading] = useState(true);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [startDate, setStartDate] = useState('');
const [startTime, setStartTime] = useState('');
const [endDate, setEndDate] = useState('');
const [endTime, setEndTime] = useState('');
const [allDay, setAllDay] = useState(false);
useEffect(() => {
// Try to load draft data from sessionStorage
const draftData = sessionStorage.getItem('editingEventDraft');
if (draftData) {
try {
const draft = JSON.parse(draftData) as EventDraft;
setEventDraft(draft);
// Set form values
setTitle(draft.title);
setDescription(draft.description || '');
if (draft.start) {
const startDateTime = new Date(draft.start);
setStartDate(formatDateForInput(startDateTime));
setStartTime(formatTimeForInput(startDateTime));
}
if (draft.end) {
const endDateTime = new Date(draft.end);
setEndDate(formatDateForInput(endDateTime));
setEndTime(formatTimeForInput(endDateTime));
}
setAllDay(draft.allDay);
} catch (error) {
console.error('Error parsing draft data:', error);
}
} else {
console.error('No event draft data found in session storage');
// Redirect back to home if no draft data is found
setTimeout(() => navigate('/'), 500);
}
setLoading(false);
}, [id, navigate]);
const formatDateForInput = (date: Date): string => {
return date.toISOString().split('T')[0];
};
const formatTimeForInput = (date: Date): string => {
return date.toTimeString().slice(0, 5);
};
const formatForServer = (date: string, time: string): string => {
return `${date}T${time}:00`;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title) {
alert('Bitte geben Sie einen Titel ein');
return;
}
try {
setLoading(true);
const eventData = {
title,
description,
start: startDate ? formatForServer(startDate, startTime || '00:00') : new Date().toISOString(),
end: endDate ? formatForServer(endDate, endTime || '00:00') : new Date().toISOString(),
allDay
};
const savedEvent = await createEvent(eventData);
console.log('Event created:', savedEvent);
// Clear the draft from sessionStorage
sessionStorage.removeItem('editingEventDraft');
// Redirect back to home page after successful save
navigate('/');
} catch (error) {
console.error('Error saving event:', error);
setLoading(false);
alert('Fehler beim Speichern des Termins');
}
};
const handleCancel = () => {
// Clear the draft data when canceling
sessionStorage.removeItem('editingEventDraft');
// Navigate back to home
navigate('/');
};
if (loading) {
return <LoadingSpinner message="Lade Termin..." size="large" />;
}
return (
<div className="edit-event-container">
<h1 className="edit-event-title">Termin bearbeiten</h1>
<form className="edit-event-form" onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="title">Titel</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="description">Beschreibung</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</div>
<div className="form-group form-checkbox">
<input
type="checkbox"
id="allDay"
checked={allDay}
onChange={(e) => setAllDay(e.target.checked)}
/>
<label htmlFor="allDay">Ganztägig</label>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="startDate">Startdatum</label>
<input
type="date"
id="startDate"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
{!allDay && (
<div className="form-group">
<label htmlFor="startTime">Startzeit</label>
<input
type="time"
id="startTime"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
</div>
)}
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="endDate">Enddatum</label>
<input
type="date"
id="endDate"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
{!allDay && (
<div className="form-group">
<label htmlFor="endTime">Endzeit</label>
<input
type="time"
id="endTime"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
)}
</div>
<div className="edit-event-actions">
<button type="button" className="cancel-button" onClick={handleCancel}>
Abbrechen
</button>
<button type="submit" className="save-button">
Speichern
</button>
</div>
</form>
</div>
);
};
export default EditEvent;

View File

@ -238,6 +238,20 @@
color: #F2ADAD;
}
.draft-loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
backdrop-filter: blur(3px);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.greeting {

View File

@ -1,8 +1,14 @@
import React, { useEffect, useState, useRef } from 'react';
import { getEvents, Event } from '../../lib/api/endpoints';
import React, { useEffect, useState, useRef, KeyboardEvent } from 'react';
import { getEvents, Event, generateDraft, EventDraft, GenerateDraftRequest } from '../../lib/api/endpoints';
import LoadingSpinner from '../../components/ui/LoadingSpinner';
import EventDraftCard from '../../components/ui/EventDraftCard';
import { useUser } from '../../lib/context';
import { useNavigate } from 'react-router-dom';
import './Home.css';
const Home: React.FC = () => {
const { initialLoad } = useUser();
const navigate = useNavigate();
const [todayEvents, setTodayEvents] = useState<Event[]>([]);
const [tomorrowEvents, setTomorrowEvents] = useState<Event[]>([]);
const [weekEvents, setWeekEvents] = useState<Event[]>([]);
@ -11,6 +17,9 @@ const Home: React.FC = () => {
const [userName, setUserName] = useState('John');
const [isTextboxOpen, setIsTextboxOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [inputText, setInputText] = useState('');
const [isDraftLoading, setIsDraftLoading] = useState(false);
const [eventDraft, setEventDraft] = useState<EventDraft | null>(null);
const textInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@ -80,9 +89,52 @@ const Home: React.FC = () => {
setTimeout(() => {
setIsTextboxOpen(false);
setIsClosing(false);
setInputText('');
}, 300); // Match animation duration
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputText(e.target.value);
};
const handleKeyDown = async (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && inputText.trim()) {
await generateEventDraft();
} else if (e.key === 'Escape') {
handleCloseTextbox();
}
};
const generateEventDraft = async () => {
if (!inputText.trim()) return;
try {
setIsDraftLoading(true);
handleCloseTextbox();
const request: GenerateDraftRequest = { input: inputText.trim() };
const draft = await generateDraft(request);
setEventDraft(draft);
setIsDraftLoading(false);
} catch (err) {
setError('Failed to generate event draft');
setIsDraftLoading(false);
}
};
const handleDraftClose = () => {
setEventDraft(null);
};
const handleEditDraft = (draft: EventDraft) => {
// Store the draft data in sessionStorage for use on the edit page
sessionStorage.setItem('editingEventDraft', JSON.stringify(draft));
// Navigate to the edit page using React Router
navigate(`/edit-event/${draft.id}`);
};
const EventItem = ({ event }: { event: Event }) => (
<div className="event-item">
<div className="event-title">{event.title}</div>
@ -94,8 +146,8 @@ const Home: React.FC = () => {
</div>
);
if (loading) {
return <div className="loading">Loading events...</div>;
if (loading && !initialLoad) {
return <LoadingSpinner message="Loading events..." size="large" />;
}
if (error) {
@ -104,6 +156,33 @@ const Home: React.FC = () => {
return (
<div className="home-container">
{isDraftLoading && (
<div className="draft-loading-overlay">
<LoadingSpinner message="Termin wird erstellt..." size="medium" />
</div>
)}
{eventDraft && (
<EventDraftCard
draft={eventDraft}
onClose={handleDraftClose}
onEdit={handleEditDraft}
onSave={() => {
// For now we just close the card and log the data
const eventData = {
title: eventDraft.title,
description: eventDraft.description,
start: eventDraft.start || new Date().toISOString(),
end: eventDraft.end || new Date().toISOString(),
allDay: eventDraft.allDay
};
console.log('Saving event:', eventData);
// Add actual implementation for saving the event
handleDraftClose();
}}
/>
)}
<div className="header-container">
<h1 className="greeting">Hallo {userName}</h1>
<button className="add-button" onClick={handleOpenTextbox}>
@ -159,6 +238,9 @@ const Home: React.FC = () => {
className="large-textbox"
type="text"
placeholder="Neuen Termin eingeben..."
value={inputText}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
</div>
</div>

View File

@ -1,10 +1,11 @@
import { useUser } from '../../lib/context';
import LoadingSpinner from '../../components/ui/LoadingSpinner';
const Profile = () => {
const { user, loading, error, refetch } = useUser();
const { user, loading, error, refetch, initialLoad } = useUser();
if (loading) {
return <div>Loading user data...</div>;
if (loading && !initialLoad) {
return <LoadingSpinner message="Loading user data..." />;
}
if (error) {