From a86890da2bc7be01d0aec2334eb66b5a95778132 Mon Sep 17 00:00:00 2001 From: Tim Lappe Date: Sat, 26 Apr 2025 05:43:35 +0200 Subject: [PATCH] Add event popup and edit page concept --- .cursor/rules/react.mdc | 4 +- .cursor/rules/symfony.mdc | 5 +- .../{ => Calendar}/CalendarController.php | 2 +- .../Event/GenerateDraftController.php | 36 +++ .../Controller/Event/GetEventsController.php | 69 +++++ .../Controller/EventController.php | 243 ------------------ .../Controller/{ => User}/UserController.php | 2 +- backend/src/Application/DTO/EventDTO.php | 4 +- backend/src/Application/DTO/EventDraftDTO.php | 40 +++ .../src/Application/DTO/GenerateDraftDTO.php | 15 ++ backend/src/Application/DTO/UserDTO.php | 4 +- backend/src/Domain/Event/GenerateDraft.php | 16 ++ .../src/Domain/Event/GenerateDraftHandler.php | 75 ++++++ backend/src/Domain/Event/ReadEvents.php | 18 ++ .../src/Domain/Event/ReadEventsHandler.php | 22 ++ backend/src/Domain/Model/EventDraft.php | 48 ++++ .../PersistedEvent.php} | 4 +- .../{User.php => Persisted/PersistedUser.php} | 4 +- backend/src/Domain/Model/UserContext.php | 18 ++ .../src/Domain/User/UserContextProvider.php | 14 + .../DataFixtures/EventFixtures.php | 34 +-- .../DataFixtures/UserFixtures.php | 4 +- .../Repository/EventRepository.php | 10 +- .../Repository/UserRepository.php | 10 +- frontend/package-lock.json | 61 +++++ frontend/package.json | 1 + frontend/src/App.tsx | 25 +- .../src/components/navigation/TabView.tsx | 3 +- frontend/src/components/ui/EventDraftCard.css | 164 ++++++++++++ frontend/src/components/ui/EventDraftCard.tsx | 118 +++++++++ frontend/src/components/ui/LoadingOverlay.css | 44 ++++ frontend/src/components/ui/LoadingOverlay.tsx | 31 +++ frontend/src/components/ui/LoadingSpinner.css | 85 ++++++ frontend/src/components/ui/LoadingSpinner.tsx | 35 +++ frontend/src/lib/api/endpoints.ts | 18 ++ frontend/src/lib/context/UserContext.tsx | 13 +- frontend/src/pages/edit-event/EditEvent.css | 132 ++++++++++ frontend/src/pages/edit-event/EditEvent.tsx | 218 ++++++++++++++++ frontend/src/pages/home/Home.css | 14 + frontend/src/pages/home/Home.tsx | 90 ++++++- frontend/src/pages/profile/Profile.tsx | 7 +- 41 files changed, 1464 insertions(+), 296 deletions(-) rename backend/src/Application/Controller/{ => Calendar}/CalendarController.php (97%) create mode 100644 backend/src/Application/Controller/Event/GenerateDraftController.php create mode 100644 backend/src/Application/Controller/Event/GetEventsController.php delete mode 100644 backend/src/Application/Controller/EventController.php rename backend/src/Application/Controller/{ => User}/UserController.php (96%) create mode 100644 backend/src/Application/DTO/EventDraftDTO.php create mode 100644 backend/src/Application/DTO/GenerateDraftDTO.php create mode 100644 backend/src/Domain/Event/GenerateDraft.php create mode 100644 backend/src/Domain/Event/GenerateDraftHandler.php create mode 100644 backend/src/Domain/Event/ReadEvents.php create mode 100644 backend/src/Domain/Event/ReadEventsHandler.php create mode 100644 backend/src/Domain/Model/EventDraft.php rename backend/src/Domain/Model/{Event.php => Persisted/PersistedEvent.php} (97%) rename backend/src/Domain/Model/{User.php => Persisted/PersistedUser.php} (96%) create mode 100644 backend/src/Domain/Model/UserContext.php create mode 100644 backend/src/Domain/User/UserContextProvider.php create mode 100644 frontend/src/components/ui/EventDraftCard.css create mode 100644 frontend/src/components/ui/EventDraftCard.tsx create mode 100644 frontend/src/components/ui/LoadingOverlay.css create mode 100644 frontend/src/components/ui/LoadingOverlay.tsx create mode 100644 frontend/src/components/ui/LoadingSpinner.css create mode 100644 frontend/src/components/ui/LoadingSpinner.tsx create mode 100644 frontend/src/pages/edit-event/EditEvent.css create mode 100644 frontend/src/pages/edit-event/EditEvent.tsx diff --git a/.cursor/rules/react.mdc b/.cursor/rules/react.mdc index 76a3ac1..6380146 100644 --- a/.cursor/rules/react.mdc +++ b/.cursor/rules/react.mdc @@ -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 \ No newline at end of file +- 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) \ No newline at end of file diff --git a/.cursor/rules/symfony.mdc b/.cursor/rules/symfony.mdc index c8f37cd..add65c9 100644 --- a/.cursor/rules/symfony.mdc +++ b/.cursor/rules/symfony.mdc @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/backend/src/Application/Controller/CalendarController.php b/backend/src/Application/Controller/Calendar/CalendarController.php similarity index 97% rename from backend/src/Application/Controller/CalendarController.php rename to backend/src/Application/Controller/Calendar/CalendarController.php index 05de992..ac5b8d3 100644 --- a/backend/src/Application/Controller/CalendarController.php +++ b/backend/src/Application/Controller/Calendar/CalendarController.php @@ -1,6 +1,6 @@ generateDraftHandler->handle(new GenerateDraft($generateDraftDTO->input)); + return $this->json(EventDraftDTO::fromDraft($draft)); + } +} diff --git a/backend/src/Application/Controller/Event/GetEventsController.php b/backend/src/Application/Controller/Event/GetEventsController.php new file mode 100644 index 0000000..69d1166 --- /dev/null +++ b/backend/src/Application/Controller/Event/GetEventsController.php @@ -0,0 +1,69 @@ +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'))); + } +} \ No newline at end of file diff --git a/backend/src/Application/Controller/EventController.php b/backend/src/Application/Controller/EventController.php deleted file mode 100644 index c95745e..0000000 --- a/backend/src/Application/Controller/EventController.php +++ /dev/null @@ -1,243 +0,0 @@ -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|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|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); - } -} \ No newline at end of file diff --git a/backend/src/Application/Controller/UserController.php b/backend/src/Application/Controller/User/UserController.php similarity index 96% rename from backend/src/Application/Controller/UserController.php rename to backend/src/Application/Controller/User/UserController.php index 6a34d32..aa8d1f7 100644 --- a/backend/src/Application/Controller/UserController.php +++ b/backend/src/Application/Controller/User/UserController.php @@ -1,6 +1,6 @@ getId(), diff --git a/backend/src/Application/DTO/EventDraftDTO.php b/backend/src/Application/DTO/EventDraftDTO.php new file mode 100644 index 0000000..404be83 --- /dev/null +++ b/backend/src/Application/DTO/EventDraftDTO.php @@ -0,0 +1,40 @@ +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() + ); + } +} \ No newline at end of file diff --git a/backend/src/Application/DTO/GenerateDraftDTO.php b/backend/src/Application/DTO/GenerateDraftDTO.php new file mode 100644 index 0000000..fad8d72 --- /dev/null +++ b/backend/src/Application/DTO/GenerateDraftDTO.php @@ -0,0 +1,15 @@ +getId(), diff --git a/backend/src/Domain/Event/GenerateDraft.php b/backend/src/Domain/Event/GenerateDraft.php new file mode 100644 index 0000000..b63f93a --- /dev/null +++ b/backend/src/Domain/Event/GenerateDraft.php @@ -0,0 +1,16 @@ +input; + } +} \ No newline at end of file diff --git a/backend/src/Domain/Event/GenerateDraftHandler.php b/backend/src/Domain/Event/GenerateDraftHandler.php new file mode 100644 index 0000000..99c340c --- /dev/null +++ b/backend/src/Domain/Event/GenerateDraftHandler.php @@ -0,0 +1,75 @@ +userContextProvider->getUserContext(); + $chat = new ChatSession($this->chatProvider); + $chat->system(<<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, + ); + } +} diff --git a/backend/src/Domain/Event/ReadEvents.php b/backend/src/Domain/Event/ReadEvents.php new file mode 100644 index 0000000..5dc2bd2 --- /dev/null +++ b/backend/src/Domain/Event/ReadEvents.php @@ -0,0 +1,18 @@ +id; + } +} \ No newline at end of file diff --git a/backend/src/Domain/Event/ReadEventsHandler.php b/backend/src/Domain/Event/ReadEventsHandler.php new file mode 100644 index 0000000..b64ed4c --- /dev/null +++ b/backend/src/Domain/Event/ReadEventsHandler.php @@ -0,0 +1,22 @@ +eventRepository->findAll(); + } +} diff --git a/backend/src/Domain/Model/EventDraft.php b/backend/src/Domain/Model/EventDraft.php new file mode 100644 index 0000000..ccef9fa --- /dev/null +++ b/backend/src/Domain/Model/EventDraft.php @@ -0,0 +1,48 @@ +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; + } +} \ No newline at end of file diff --git a/backend/src/Domain/Model/Event.php b/backend/src/Domain/Model/Persisted/PersistedEvent.php similarity index 97% rename from backend/src/Domain/Model/Event.php rename to backend/src/Domain/Model/Persisted/PersistedEvent.php index 85f3de5..2121519 100644 --- a/backend/src/Domain/Model/Event.php +++ b/backend/src/Domain/Model/Persisted/PersistedEvent.php @@ -1,6 +1,6 @@ now; + } +} \ No newline at end of file diff --git a/backend/src/Domain/User/UserContextProvider.php b/backend/src/Domain/User/UserContextProvider.php new file mode 100644 index 0000000..75413ee --- /dev/null +++ b/backend/src/Domain/User/UserContextProvider.php @@ -0,0 +1,14 @@ +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)) diff --git a/backend/src/Infrastructure/DataFixtures/UserFixtures.php b/backend/src/Infrastructure/DataFixtures/UserFixtures.php index e6ec4cd..2c08c50 100644 --- a/backend/src/Infrastructure/DataFixtures/UserFixtures.php +++ b/backend/src/Infrastructure/DataFixtures/UserFixtures.php @@ -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'); diff --git a/backend/src/Infrastructure/Repository/EventRepository.php b/backend/src/Infrastructure/Repository/EventRepository.php index 67357ba..f92093f 100644 --- a/backend/src/Infrastructure/Repository/EventRepository.php +++ b/backend/src/Infrastructure/Repository/EventRepository.php @@ -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 + * @extends ServiceEntityRepository */ class EventRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { - parent::__construct($registry, Event::class); + parent::__construct($registry, PersistedEvent::class); } /** - * @return array + * @return array */ public function findByDateRange(\DateTimeInterface $start, \DateTimeInterface $end): array { - /** @var array $result */ + /** @var array $result */ $result = $this->createQueryBuilder('e') ->andWhere('e.from >= :start AND e.from <= :end') ->orWhere('e.to >= :start AND e.to <= :end') diff --git a/backend/src/Infrastructure/Repository/UserRepository.php b/backend/src/Infrastructure/Repository/UserRepository.php index 4b33bb5..7d947fb 100644 --- a/backend/src/Infrastructure/Repository/UserRepository.php +++ b/backend/src/Infrastructure/Repository/UserRepository.php @@ -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 + * @extends ServiceEntityRepository */ 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(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1ed8cdb..2204b0d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index b2b667b..6c24b76 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b427820..b1fa03d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 (
- + {renderContent && ( + + + } /> + } /> + + + )}
); } diff --git a/frontend/src/components/navigation/TabView.tsx b/frontend/src/components/navigation/TabView.tsx index 172bb58..96b0644 100644 --- a/frontend/src/components/navigation/TabView.tsx +++ b/frontend/src/components/navigation/TabView.tsx @@ -70,7 +70,8 @@ export const TabView: React.FC = ({ 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 && } diff --git a/frontend/src/components/ui/EventDraftCard.css b/frontend/src/components/ui/EventDraftCard.css new file mode 100644 index 0000000..3976534 --- /dev/null +++ b/frontend/src/components/ui/EventDraftCard.css @@ -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); +} \ No newline at end of file diff --git a/frontend/src/components/ui/EventDraftCard.tsx b/frontend/src/components/ui/EventDraftCard.tsx new file mode 100644 index 0000000..8a6d180 --- /dev/null +++ b/frontend/src/components/ui/EventDraftCard.tsx @@ -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 = ({ + draft, + onClose, + onSave, + onEdit +}) => { + const [isVisible, setIsVisible] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const cardRef = useRef(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 ( +
+
+
+

{draft.title}

+ + {draft.description && ( +
+ {draft.description} +
+ )} + +
+ {draft.start && ( +
+ Start: {formatDateTime(draft.start)} +
+ )} + + {draft.end && ( +
+ Ende: {formatDateTime(draft.end)} +
+ )} + + {draft.allDay && ( +
Ganztägig
+ )} +
+ +
+ + {onSave && ( + + )} +
+
+
+
+ ); +}; + +export default EventDraftCard; \ No newline at end of file diff --git a/frontend/src/components/ui/LoadingOverlay.css b/frontend/src/components/ui/LoadingOverlay.css new file mode 100644 index 0000000..28e5682 --- /dev/null +++ b/frontend/src/components/ui/LoadingOverlay.css @@ -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); + } +} \ No newline at end of file diff --git a/frontend/src/components/ui/LoadingOverlay.tsx b/frontend/src/components/ui/LoadingOverlay.tsx new file mode 100644 index 0000000..c539736 --- /dev/null +++ b/frontend/src/components/ui/LoadingOverlay.tsx @@ -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 = ({ + isVisible, + message = 'Loading...', + transparentBackground = false, +}) => { + if (!isVisible) return null; + + const overlayClass = transparentBackground + ? 'loading-overlay transparent-background' + : 'loading-overlay'; + + return ( +
+
+ +
+
+ ); +}; + +export default LoadingOverlay; \ No newline at end of file diff --git a/frontend/src/components/ui/LoadingSpinner.css b/frontend/src/components/ui/LoadingSpinner.css new file mode 100644 index 0000000..ea839b6 --- /dev/null +++ b/frontend/src/components/ui/LoadingSpinner.css @@ -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); + } +} \ No newline at end of file diff --git a/frontend/src/components/ui/LoadingSpinner.tsx b/frontend/src/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000..7ebee90 --- /dev/null +++ b/frontend/src/components/ui/LoadingSpinner.tsx @@ -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 = ({ + size = 'medium', + message = 'Loading...', + fullScreen = false, +}) => { + const containerClass = fullScreen + ? 'loading-spinner-container full-screen' + : 'loading-spinner-container'; + + const sizeClass = `loading-spinner-${size}`; + + return ( +
+
+
+ +
+ {message &&

{message}

} +
+
+ ); +}; + +export default LoadingSpinner; \ No newline at end of file diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index 59d5a92..16f7ae9 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -41,6 +41,24 @@ export const createEvent = (data: CreateEventRequest) => post('/api/event export const updateEvent = (id: string, data: Partial) => put(`/api/events/${id}`, data); export const deleteEvent = (id: string) => del(`/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('/api/events/generation', data); + // Calendar types export type Calendar = { events: Event[]; diff --git a/frontend/src/lib/context/UserContext.tsx b/frontend/src/lib/context/UserContext.tsx index 36463e9..ac5b41a 100644 --- a/frontend/src/lib/context/UserContext.tsx +++ b/frontend/src/lib/context/UserContext.tsx @@ -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; }; @@ -14,6 +16,7 @@ export const UserProvider = ({ children }: { children: ReactNode }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 }} > + {children} ); diff --git a/frontend/src/pages/edit-event/EditEvent.css b/frontend/src/pages/edit-event/EditEvent.css new file mode 100644 index 0000000..de1c8b2 --- /dev/null +++ b/frontend/src/pages/edit-event/EditEvent.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/pages/edit-event/EditEvent.tsx b/frontend/src/pages/edit-event/EditEvent.tsx new file mode 100644 index 0000000..217b917 --- /dev/null +++ b/frontend/src/pages/edit-event/EditEvent.tsx @@ -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(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 ; + } + + return ( +
+

Termin bearbeiten

+ +
+
+ + setTitle(e.target.value)} + required + /> +
+ +
+ +