Add event popup and edit page concept
This commit is contained in:
parent
86aa6f62f8
commit
a86890da2b
@ -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)
|
||||
@ -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
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Application\Controller;
|
||||
namespace App\Application\Controller\Calendar;
|
||||
|
||||
use App\Infrastructure\Repository\EventRepository;
|
||||
use App\Application\DTO\CalendarDTO;
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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')));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Application\Controller;
|
||||
namespace App\Application\Controller\User;
|
||||
|
||||
use App\Application\DTO\UserDTO;
|
||||
use App\Infrastructure\Repository\UserRepository;
|
||||
@ -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(),
|
||||
|
||||
40
backend/src/Application/DTO/EventDraftDTO.php
Normal file
40
backend/src/Application/DTO/EventDraftDTO.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
15
backend/src/Application/DTO/GenerateDraftDTO.php
Normal file
15
backend/src/Application/DTO/GenerateDraftDTO.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
16
backend/src/Domain/Event/GenerateDraft.php
Normal file
16
backend/src/Domain/Event/GenerateDraft.php
Normal 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;
|
||||
}
|
||||
}
|
||||
75
backend/src/Domain/Event/GenerateDraftHandler.php
Normal file
75
backend/src/Domain/Event/GenerateDraftHandler.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
18
backend/src/Domain/Event/ReadEvents.php
Normal file
18
backend/src/Domain/Event/ReadEvents.php
Normal 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;
|
||||
}
|
||||
}
|
||||
22
backend/src/Domain/Event/ReadEventsHandler.php
Normal file
22
backend/src/Domain/Event/ReadEventsHandler.php
Normal 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();
|
||||
}
|
||||
}
|
||||
48
backend/src/Domain/Model/EventDraft.php
Normal file
48
backend/src/Domain/Model/EventDraft.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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')]
|
||||
@ -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')]
|
||||
18
backend/src/Domain/Model/UserContext.php
Normal file
18
backend/src/Domain/Model/UserContext.php
Normal 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;
|
||||
}
|
||||
}
|
||||
14
backend/src/Domain/User/UserContextProvider.php
Normal file
14
backend/src/Domain/User/UserContextProvider.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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();
|
||||
|
||||
61
frontend/package-lock.json
generated
61
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 />}
|
||||
|
||||
164
frontend/src/components/ui/EventDraftCard.css
Normal file
164
frontend/src/components/ui/EventDraftCard.css
Normal 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);
|
||||
}
|
||||
118
frontend/src/components/ui/EventDraftCard.tsx
Normal file
118
frontend/src/components/ui/EventDraftCard.tsx
Normal 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;
|
||||
44
frontend/src/components/ui/LoadingOverlay.css
Normal file
44
frontend/src/components/ui/LoadingOverlay.css
Normal 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);
|
||||
}
|
||||
}
|
||||
31
frontend/src/components/ui/LoadingOverlay.tsx
Normal file
31
frontend/src/components/ui/LoadingOverlay.tsx
Normal 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;
|
||||
85
frontend/src/components/ui/LoadingSpinner.css
Normal file
85
frontend/src/components/ui/LoadingSpinner.css
Normal 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);
|
||||
}
|
||||
}
|
||||
35
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
35
frontend/src/components/ui/LoadingSpinner.tsx
Normal 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;
|
||||
@ -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[];
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
132
frontend/src/pages/edit-event/EditEvent.css
Normal file
132
frontend/src/pages/edit-event/EditEvent.css
Normal 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;
|
||||
}
|
||||
}
|
||||
218
frontend/src/pages/edit-event/EditEvent.tsx
Normal file
218
frontend/src/pages/edit-event/EditEvent.tsx
Normal 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;
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user