Compare commits

...

2 Commits

Author SHA1 Message Date
Tim Lappe
7bc741e506 Add details page 2025-04-30 18:43:27 +02:00
Tim Lappe
ac995b1b83 Added edit calendar events and fixed timezone 2025-04-28 07:42:42 +02:00
51 changed files with 2173 additions and 835 deletions

View File

@ -3,5 +3,6 @@
"phpstan.configFile": "backend/phpstan.dist.neon", "phpstan.configFile": "backend/phpstan.dist.neon",
"phpstan.checkValidity": true, "phpstan.checkValidity": true,
"phpstan.showTypeOnHover": false, "phpstan.showTypeOnHover": false,
"phpstan.showProgress": true "phpstan.showProgress": true,
"php.version": "8.4"
} }

View File

@ -26,5 +26,5 @@ APP_SECRET=71bf50bfb778d456b3a376ff60d5dcd8
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://postgres:postgres@calendi-postgres:5432/postgres?serverVersion=16&charset=utf8" DATABASE_URL="postgresql://postgres:postgres@calendi-postgres.test:5432/postgres?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###

View File

@ -4,7 +4,7 @@
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.1", "php": ">=8.4",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"doctrine/annotations": "^2.0", "doctrine/annotations": "^2.0",
@ -14,20 +14,22 @@
"nelmio/api-doc-bundle": "^5.0", "nelmio/api-doc-bundle": "^5.0",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1", "phpstan/phpdoc-parser": "^2.1",
"symfony/asset": "6.4.*", "prinsfrank/standards": "^3.12",
"symfony/console": "6.4.*", "symfony/asset": "7.2.*",
"symfony/dotenv": "6.4.*", "symfony/console": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "6.4.*", "symfony/framework-bundle": "7.2.*",
"symfony/http-client": "6.4.*", "symfony/http-client": "7.2.*",
"symfony/property-access": "6.4.*", "symfony/monolog-bundle": "^3.10",
"symfony/property-info": "6.4.*", "symfony/property-access": "7.2.*",
"symfony/runtime": "6.4.*", "symfony/property-info": "7.2.*",
"symfony/serializer": "6.4.*", "symfony/runtime": "7.2.*",
"symfony/twig-bundle": "6.4.*", "symfony/serializer": "7.2.*",
"symfony/uid": "6.4.*", "symfony/twig-bundle": "7.2.*",
"symfony/validator": "6.4.*", "symfony/uid": "7.2.*",
"symfony/yaml": "6.4.*" "symfony/validator": "7.2.*",
"symfony/yaml": "7.2.*"
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {
@ -76,13 +78,15 @@
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": false,
"require": "6.4.*" "require": "7.2.*"
} }
}, },
"require-dev": { "require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.1", "doctrine/doctrine-fixtures-bundle": "^4.1",
"phpstan/phpstan": "^2.1", "phpstan/phpstan": "^2.1",
"phpstan/phpstan-symfony": "^2.0", "phpstan/phpstan-symfony": "^2.0",
"symfony/maker-bundle": "^1.62" "symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"
} }
} }

1670
backend/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -8,4 +8,6 @@ return [
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
]; ];

View File

@ -0,0 +1,62 @@
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
# type: firephp
# level: info
#chromephp:
# type: chromephp
# level: info
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested:
type: stream
path: php://stderr
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json

View File

@ -0,0 +1,11 @@
when@dev:
web_profiler:
toolbar: true
framework:
profiler:
collect_serializer_data: true
when@test:
framework:
profiler: { collect: false }

View File

@ -0,0 +1,8 @@
when@dev:
web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
prefix: /_wdt
web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
prefix: /_profiler

View File

@ -4,6 +4,7 @@
# Put parameters here that don't need to change on each machine where the app is deployed # Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
app.timezone: 'Europe/Berlin'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file

View File

@ -15,6 +15,7 @@ use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
#[Route('/api/events', name: 'api_events_')] #[Route('/api/events', name: 'api_events_')]
#[OA\Tag(name: 'Events')]
class GetEventsController extends AbstractController class GetEventsController extends AbstractController
{ {
public function __construct( public function __construct(
@ -23,7 +24,6 @@ class GetEventsController extends AbstractController
} }
#[Route('', name: 'list', methods: ['GET'])] #[Route('', name: 'list', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Response( #[OA\Response(
response: 200, response: 200,
description: 'Returns list of events', description: 'Returns list of events',
@ -39,7 +39,6 @@ class GetEventsController extends AbstractController
} }
#[Route('/{id}', name: 'get', methods: ['GET'])] #[Route('/{id}', name: 'get', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Parameter( #[OA\Parameter(
name: 'id', name: 'id',
description: 'Event ID', description: 'Event ID',
@ -58,7 +57,7 @@ class GetEventsController extends AbstractController
)] )]
public function get(string $id): JsonResponse public function get(string $id): JsonResponse
{ {
$events = $this->readEventsHandler->handle(new ReadEvents((int)$id)); $events = $this->readEventsHandler->handle(new ReadEvents($id));
if (count($events) === 0) { if (count($events) === 0) {
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND); return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);

View File

@ -0,0 +1,35 @@
<?php
namespace App\Application\Controller\Event;
use App\Application\DTO\PersistEventDTO;
use App\Domain\Event\PersistEventHandler;
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;
#[Route('/api/events', name: 'api_events_persist', methods: ['POST'])]
#[OA\Tag(name: 'Events')]
class PersistEventController extends AbstractController
{
public function __construct(
private readonly PersistEventHandler $persistEventHandler,
) {
}
#[Route('', name: 'persist', methods: ['POST'])]
public function persist(#[MapRequestPayload] PersistEventDTO $dto): JsonResponse
{
$this->persistEventHandler->handle($dto->toDomain());
return $this->json(['message' => 'Event persisted']);
}
#[Route('/{id}', name: 'update', methods: ['PUT'])]
public function update(#[MapRequestPayload] PersistEventDTO $dto, string $id): JsonResponse
{
$this->persistEventHandler->handle($dto->toDomain()->withId($id));
return $this->json(['message' => 'Event updated']);
}
}

View File

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

View File

@ -0,0 +1,24 @@
<?php
namespace App\Application\DTO;
use App\Domain\Event\PersistEvent;
use OpenApi\Attributes as OA;
use App\Application\DTO\EventDraftDTO;
#[OA\Schema]
final readonly class PersistEventDTO
{
public function __construct(
#[OA\Property]
public readonly EventDraftDTO $draft,
) {
}
public function toDomain(): PersistEvent
{
return new PersistEvent(
$this->draft->toDomain(),
);
}
}

View File

@ -6,12 +6,14 @@ use App\Domain\Chat\ChatProviderInterface;
use App\Domain\Chat\ChatSession; use App\Domain\Chat\ChatSession;
use App\Domain\Model\EventDraft; use App\Domain\Model\EventDraft;
use App\Domain\User\UserContextProvider; use App\Domain\User\UserContextProvider;
use Psr\Log\LoggerInterface;
class GenerateDraftHandler class GenerateDraftHandler
{ {
public function __construct( public function __construct(
private readonly ChatProviderInterface $chatProvider, private readonly ChatProviderInterface $chatProvider,
private readonly UserContextProvider $userContextProvider, private readonly UserContextProvider $userContextProvider,
private readonly LoggerInterface $logger,
) { ) {
} }
@ -19,14 +21,20 @@ class GenerateDraftHandler
{ {
$userContext = $this->userContextProvider->getUserContext(); $userContext = $this->userContextProvider->getUserContext();
$chat = new ChatSession($this->chatProvider); $chat = new ChatSession($this->chatProvider);
$chat->system(<<<PROMPT
You are a helpful assistant that generates event drafts based on user input. $systemPrompt = <<<PROMPT
You are a helpful assistant that generates calendar 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.). 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. You should always generate a draft even if the user input is not very detailed.
Go Step by Step by step: Go Step by Step by step:
- First, analyze the user input and analyze the context of the user input. - First, analyze the user input and asking yourself what the user wants to achieve:
a. What is the title of the event?
b. What is the description of the event?
c. What is the location of the event?
d. What is the start datetime of the event?
e. What is the end datetime of the event?
f. Is the event all day?
- Then, generate a draft of the event. - Then, generate a draft of the event.
- Finally, return the draft in the following format:
The event draft should be in the following format: The event draft should be in the following format:
```json ```json
@ -42,9 +50,14 @@ class GenerateDraftHandler
This is the current context: This is the current context:
- Time: {$userContext->now()->format('Y-m-d H:i:s')} - Time: {$userContext->now()->format('Y-m-d H:i:s')}
- Timezone: {$userContext->now()->getTimezone()->getName()}
You will only respond with the JSON object in ```json``` tags, nothing else. You will only respond with the JSON object in ```json``` tags, nothing else.
PROMPT); PROMPT;
$chat->system($systemPrompt);
$this->logger->info('Generating draft for input: ' . $generateDraft->input() . " System prompt: " . $systemPrompt);
$chat->user($generateDraft->input()); $chat->user($generateDraft->input());
$chat->commit(reasoning: false); $chat->commit(reasoning: false);

View File

@ -0,0 +1,19 @@
<?php
namespace App\Domain\Event;
use App\Domain\Model\EventDraft;
class PersistEvent
{
public function __construct(
public readonly EventDraft $draft,
public readonly ?string $id = null,
) {
}
public function withId(string $id): self
{
return new self($this->draft, $id);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Domain\Event;
use App\Domain\Event\PersistEvent;
use App\Domain\Model\Persisted\PersistedEvent;
use App\Infrastructure\Repository\EventRepository;
class PersistEventHandler
{
public function __construct(
private readonly EventRepository $eventRepository,
) {
}
public function handle(PersistEvent $event): void
{
$persistedEvent = new PersistedEvent();
if ($event->id !== null) {
$persistedEvent = $this->eventRepository->find($event->id);
if (!$persistedEvent) {
throw new \Exception('Event not found');
}
}
$this->eventRepository->save($event->draft->mergeIntoPersisted($persistedEvent));
}
}

View File

@ -7,11 +7,11 @@ use App\Domain\Model\PersistedEvent;
class ReadEvents class ReadEvents
{ {
public function __construct( public function __construct(
private readonly ?int $id = null private readonly ?string $id = null
) { ) {
} }
public function id(): ?int public function id(): ?string
{ {
return $this->id; return $this->id;
} }

View File

@ -17,6 +17,10 @@ class ReadEventsHandler
*/ */
public function handle(ReadEvents $readEvents): array public function handle(ReadEvents $readEvents): array
{ {
if ($readEvents->id() !== null) {
return array_filter([$this->eventRepository->find($readEvents->id())], static fn (?PersistedEvent $event) => $event !== null);
}
return $this->eventRepository->findAll(); return $this->eventRepository->findAll();
} }
} }

View File

@ -0,0 +1,22 @@
<?php
namespace App\Domain\Location\Model;
use InvalidArgumentException;
class Address
{
public function __construct(
public readonly string $addressLine,
public readonly City $city,
) {
if (strlen($addressLine) >= 100) {
throw new InvalidArgumentException('Address line cannot be longer than 100 characters');
}
}
public static function create(string $addressLine, string $city, string $countryAlpha2, ?string $zipCode = null): self
{
return new self($addressLine, City::create($city, $countryAlpha2, $zipCode));
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Domain\Location\Model;
use InvalidArgumentException;
class City
{
public function __construct(
public readonly string $name,
public readonly Country $country,
public readonly ?ZipCode $zipCode = null,
) {
if (strlen($name) >= 100) {
throw new InvalidArgumentException('City name cannot be longer than 100 characters');
}
}
public static function create(string $name, string $countryAlpha2, ?string $zipCode = null): self
{
return new self($name, new Country($countryAlpha2), $zipCode !== null ? new ZipCode($zipCode) : null);
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Domain\Location\Model;
class Country
{
public function __construct(
public readonly string $alpha2,
) {
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Domain\Location\Model\Persisted;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'addresses')]
class PersistedAddress
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
public private(set) ?int $id = null {
get => $this->id;
set => $this->id = $value;
}
#[ORM\Column(type: 'string', length: 100)]
public private(set) string $addressLine {
get => $this->addressLine;
set => $this->addressLine = $value;
}
#[ORM\Column(type: 'string', length: 100)]
public private(set) string $city {
get => $this->city;
set => $this->city = $value;
}
#[ORM\Column(type: 'string', length: 2)]
public private(set) string $countryAlpha2 {
get => $this->countryAlpha2;
set => $this->countryAlpha2 = $value;
}
#[ORM\Column(type: 'string', length: 10, nullable: true)]
public private(set) ?string $zipCode = null {
get => $this->zipCode;
set => $this->zipCode = $value;
}
public function __construct(
string $addressLine,
string $city,
string $countryAlpha2,
?string $zipCode = null,
) {
$this->addressLine = $addressLine;
$this->city = $city;
$this->countryAlpha2 = $countryAlpha2;
$this->zipCode = $zipCode;
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Domain\Location\Model;
use InvalidArgumentException;
class ZipCode
{
public function __construct(
public readonly string $code,
) {
if (strlen($code) >= 16) {
throw new InvalidArgumentException('Zip code cannot be longer than 16 characters');
}
}
}

View File

@ -2,47 +2,49 @@
namespace App\Domain\Model; namespace App\Domain\Model;
use App\Domain\Model\Persisted\PersistedEvent;
use DateTimeInterface; use DateTimeInterface;
class EventDraft class EventDraft
{ {
public function __construct( public function __construct(
private readonly string $title, public readonly ?string $title,
private readonly string $description, public readonly ?string $description,
private readonly string $location, public readonly ?string $location,
private readonly ?DateTimeInterface $start, public readonly ?DateTimeInterface $start,
private readonly ?DateTimeInterface $end, public readonly ?DateTimeInterface $end,
private readonly bool $allDay, public readonly ?bool $allDay,
) { ) {
} }
public function title(): string public function mergeIntoPersisted(PersistedEvent $persistedEvent = new PersistedEvent()): PersistedEvent
{ {
return $this->title; if (!$this->start instanceof \DateTimeImmutable) {
} throw new \Exception('Start date must be a DateTimeImmutable');
}
public function description(): string if (!$this->end instanceof \DateTimeImmutable) {
{ throw new \Exception('End date must be a DateTimeImmutable');
return $this->description; }
}
public function location(): string if ($this->allDay === null) {
{ throw new \Exception('All day must be a boolean');
return $this->location; }
}
public function start(): ?DateTimeInterface if ($this->title === null) {
{ throw new \Exception('Title must be a string');
return $this->start; }
}
public function end(): ?DateTimeInterface if ($this->description === null) {
{ throw new \Exception('Description must be a string');
return $this->end; }
}
public function allDay(): bool return $persistedEvent
{ ->setTitle($this->title)
return $this->allDay; ->setDescription($this->description)
->setLocation($this->location)
->setStart($this->start)
->setEnd($this->end)
->setAllDay($this->allDay);
} }
} }

View File

@ -23,6 +23,9 @@ class PersistedEvent
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null; private ?string $description = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $location = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'event_from')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'event_from')]
#[Assert\NotNull] #[Assert\NotNull]
private \DateTimeImmutable $from; private \DateTimeImmutable $from;
@ -128,4 +131,16 @@ class PersistedEvent
return $this; return $this;
} }
public function getLocation(): ?string
{
return $this->location;
}
public function setLocation(?string $location): self
{
$this->location = $location;
return $this;
}
} }

View File

@ -0,0 +1,33 @@
<?php
namespace App\Infrastructure\EventSubscriber;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class TimezoneSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly ParameterBagInterface $params,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['setTimezone', 100],
];
}
public function setTimezone(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$timezone = $this->params->get('app.timezone');
date_default_timezone_set($timezone);
}
}

View File

@ -16,6 +16,12 @@ class EventRepository extends ServiceEntityRepository
parent::__construct($registry, PersistedEvent::class); parent::__construct($registry, PersistedEvent::class);
} }
public function save(PersistedEvent $event): void
{
$this->getEntityManager()->persist($event);
$this->getEntityManager()->flush();
}
/** /**
* @return array<PersistedEvent> * @return array<PersistedEvent>
*/ */

View File

@ -121,6 +121,18 @@
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
} }
}, },
"symfony/monolog-bundle": {
"version": "3.10",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.7",
"ref": "aff23899c4440dd995907613c1dd709b6f59503f"
},
"files": [
"config/packages/monolog.yaml"
]
},
"symfony/routing": { "symfony/routing": {
"version": "6.4", "version": "6.4",
"recipe": { "recipe": {
@ -170,5 +182,18 @@
"files": [ "files": [
"config/packages/validator.yaml" "config/packages/validator.yaml"
] ]
},
"symfony/web-profiler-bundle": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.1",
"ref": "8b51135b84f4266e3b4c8a6dc23c9d1e32e543b7"
},
"files": [
"config/packages/web_profiler.yaml",
"config/routes/web_profiler.yaml"
]
} }
} }

View File

@ -1,6 +1,7 @@
services: services:
app: app:
image: app:latest image: app:latest
hostname: calendi.test
build: build:
context: . context: .
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
@ -17,7 +18,7 @@ services:
- proxy - proxy
postgres: postgres:
hostname: calendi-postgres hostname: calendi-postgres.test
image: postgres:15 image: postgres:15
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
@ -29,6 +30,11 @@ services:
- ./var/postgres_data:/var/lib/postgresql/data - ./var/postgres_data:/var/lib/postgresql/data
networks: networks:
- proxy - proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.postgres.entrypoints=postgres"
- "traefik.http.routers.postgres.rule=Host(`calendi-postgres.test`)"
- "traefik.http.services.postgres.loadbalancer.server.port=5432"
networks: networks:
proxy: proxy:

View File

@ -10,7 +10,13 @@ RUN apt-get update && apt-get install -y \
apt-transport-https \ apt-transport-https \
software-properties-common \ software-properties-common \
nginx \ nginx \
unzip unzip \
tzdata
# Set timezone to Germany
RUN ln -fs /usr/share/zoneinfo/Europe/Berlin /etc/localtime && \
dpkg-reconfigure -f noninteractive tzdata && \
echo "Europe/Berlin" > /etc/timezone
# Add PHP repository # Add PHP repository
RUN wget -O /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg \ RUN wget -O /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg \

View File

@ -9,6 +9,11 @@ server {
try_files $uri $uri/ /index.php$is_args$args; try_files $uri $uri/ /index.php$is_args$args;
} }
# PHP Backend API
location /_profiler {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ { location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000; fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php; fastcgi_index index.php;

View File

@ -17,18 +17,18 @@
} }
.App-header { .App-header {
background-color: #282c34; background-color: var(--color-secondary);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: calc(10px + 2vmin); font-size: calc(10px + 2vmin);
color: white; color: var(--text-on-dark);
} }
.App-link { .App-link {
color: #61dafb; color: var(--color-accent);
} }
@keyframes App-logo-spin { @keyframes App-logo-spin {

View File

@ -3,6 +3,7 @@ import { TabView } from './components/navigation/TabView';
import Home from './pages/home/Home'; import Home from './pages/home/Home';
import Profile from './pages/profile/Profile'; import Profile from './pages/profile/Profile';
import EditEvent from './pages/edit-event/EditEvent'; import EditEvent from './pages/edit-event/EditEvent';
import EventDetails from './pages/event-details/EventDetails';
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons'; import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons';
import { faHome } from '@fortawesome/free-solid-svg-icons'; import { faHome } from '@fortawesome/free-solid-svg-icons';
import { useUser } from './lib/context'; import { useUser } from './lib/context';
@ -32,6 +33,7 @@ function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/edit-event/:id" element={<EditEvent />} /> <Route path="/edit-event/:id" element={<EditEvent />} />
<Route path="/event/:id" element={<EventDetails />} />
<Route path="*" element={<TabView tabs={tabs} />} /> <Route path="*" element={<TabView tabs={tabs} />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@ -6,7 +6,7 @@
max-width: 100%; max-width: 100%;
top: 0; top: 0;
left: 0; left: 0;
background-color: #f8fafc; background-color: var(--bg-primary);
overflow-x: hidden; overflow-x: hidden;
box-sizing: border-box; box-sizing: border-box;
} }
@ -24,10 +24,10 @@
.tab-bar { .tab-bar {
display: flex; display: flex;
background-color: #ffffff; background-color: var(--bg-primary);
border-top: 1px solid #e2e8f0; border-top: 1px solid var(--border-light);
margin-top: auto; margin-top: auto;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05); box-shadow: 0 -2px 10px var(--shadow-color);
border-radius: 16px 16px 0 0; border-radius: 16px 16px 0 0;
padding: 5px 10px; padding: 5px 10px;
position: fixed; position: fixed;
@ -45,7 +45,7 @@
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
color: #64748b; color: var(--color-slate);
transition: all 0.3s ease; transition: all 0.3s ease;
border-radius: 12px; border-radius: 12px;
margin: 0 4px; margin: 0 4px;
@ -60,15 +60,15 @@
left: 50%; left: 50%;
width: 0; width: 0;
height: 3px; height: 3px;
background-color: #3182ce; background-color: var(--color-accent);
transition: all 0.3s ease; transition: all 0.3s ease;
transform: translateX(-50%); transform: translateX(-50%);
border-radius: 3px 3px 0 0; border-radius: 3px 3px 0 0;
} }
.tab-bar-item:hover { .tab-bar-item:hover {
color: #334155; color: var(--text-secondary);
background-color: #f1f5f9; background-color: var(--state-hover);
} }
.tab-bar-item:hover::after { .tab-bar-item:hover::after {
@ -76,9 +76,9 @@
} }
.tab-bar-item.active { .tab-bar-item.active {
color: #3182ce; color: var(--color-secondary);
background-color: #ebf8ff; background-color: var(--state-active);
box-shadow: 0 2px 6px rgba(49, 130, 206, 0.15); box-shadow: 0 2px 6px var(--shadow-color);
transform: translateY(-2px); transform: translateY(-2px);
} }
@ -115,8 +115,8 @@
flex-direction: column; flex-direction: column;
justify-content: start; justify-content: start;
align-items: start; align-items: start;
background-color: #ffffff; background-color: var(--bg-primary);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05); box-shadow: 0 5px 15px var(--shadow-color);
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
box-sizing: border-box; box-sizing: border-box;

View File

@ -1,7 +1,7 @@
.calendar { .calendar {
background-color: #fff; background-color: var(--bg-primary);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); box-shadow: 0 4px 12px var(--shadow-color);
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
max-width: 900px; max-width: 900px;
@ -23,15 +23,15 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 20px 24px; padding: 20px 24px;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid var(--border-light);
background-color: #fff; background-color: var(--bg-primary);
} }
.headerTitle { .headerTitle {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
color: #333; color: var(--text-secondary);
} }
.navButton { .navButton {
@ -44,18 +44,18 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
color: #666; color: var(--color-slate);
transition: background-color 0.2s, color 0.2s; transition: background-color 0.2s, color 0.2s;
} }
.navButton:hover { .navButton:hover {
background-color: #f5f5f5; background-color: var(--state-hover);
color: #333; color: var(--text-secondary);
} }
.navButton:focus { .navButton:focus {
outline: none; outline: none;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3); box-shadow: 0 0 0 2px var(--state-focus);
} }
/* Calendar grid styles */ /* Calendar grid styles */
@ -71,8 +71,8 @@
text-align: center; text-align: center;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #777; color: var(--color-slate);
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid var(--border-light);
} }
.day, .emptyDay { .day, .emptyDay {
@ -84,7 +84,7 @@
} }
.day:hover { .day:hover {
background-color: #f5f5f5; background-color: var(--state-hover);
} }
.dayNumber { .dayNumber {
@ -93,7 +93,7 @@
left: 6px; left: 6px;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: #444; color: var(--text-primary);
height: 24px; height: 24px;
width: 24px; width: 24px;
display: flex; display: flex;
@ -103,12 +103,12 @@
} }
.today .dayNumber { .today .dayNumber {
background-color: #4285f4; background-color: var(--color-secondary);
color: white; color: var(--text-on-dark);
} }
.selectedDay { .selectedDay {
background-color: rgba(66, 133, 244, 0.08); background-color: var(--state-active);
} }
.selectedDay .dayNumber { .selectedDay .dayNumber {
@ -134,21 +134,21 @@
.moreEvents { .moreEvents {
font-size: 10px; font-size: 10px;
color: #777; color: var(--color-slate);
margin-top: 2px; margin-top: 2px;
} }
/* Events list styles */ /* Events list styles */
.eventsList { .eventsList {
padding: 16px; padding: 16px;
border-left: 1px solid #f0f0f0; border-left: 1px solid var(--border-light);
flex: 1; flex: 1;
max-height: 460px; max-height: 460px;
overflow-y: auto; overflow-y: auto;
@media (max-width: 767px) { @media (max-width: 767px) {
border-left: none; border-left: none;
border-top: 1px solid #f0f0f0; border-top: 1px solid var(--border-light);
} }
} }
@ -156,13 +156,13 @@
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
margin: 0 0 16px; margin: 0 0 16px;
color: #333; color: var(--text-secondary);
} }
.eventsListEmpty { .eventsListEmpty {
padding: 20px; padding: 20px;
text-align: center; text-align: center;
color: #777; color: var(--color-slate);
font-style: italic; font-style: italic;
} }
@ -175,26 +175,26 @@
.event { .event {
padding: 12px; padding: 12px;
border-radius: 8px; border-radius: 8px;
background-color: #f8f9fa; background-color: var(--state-hover);
border-left: 4px solid #4285f4; border-left: 4px solid var(--event-default);
cursor: pointer; cursor: pointer;
transition: transform 0.1s, box-shadow 0.2s; transition: transform 0.1s, box-shadow 0.2s;
} }
.event:hover { .event:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 8px var(--shadow-color);
} }
.eventTime { .eventTime {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: #666; color: var(--color-slate);
margin-bottom: 4px; margin-bottom: 4px;
} }
.eventTitle { .eventTitle {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: #333; color: var(--text-primary);
} }

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { CalendarHeader } from './CalendarHeader'; import { CalendarHeader } from './CalendarHeader';
import { CalendarGrid } from './CalendarGrid'; import { CalendarGrid } from './CalendarGrid';
import { CalendarEventsList } from './CalendarEventsList'; import { CalendarEventsList } from './CalendarEventsList';
@ -25,6 +26,7 @@ export const Calendar = ({
onEventClick, onEventClick,
initialDate = new Date() initialDate = new Date()
}: CalendarProps) => { }: CalendarProps) => {
const navigate = useNavigate();
const [currentDate, setCurrentDate] = useState<Date>(initialDate); const [currentDate, setCurrentDate] = useState<Date>(initialDate);
const [selectedDate, setSelectedDate] = useState<Date | null>(null); const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [daysInMonth, setDaysInMonth] = useState<number[]>([]); const [daysInMonth, setDaysInMonth] = useState<number[]>([]);
@ -64,6 +66,15 @@ export const Calendar = ({
} }
}; };
const handleEventClick = (event: CalendarEvent) => {
if (onEventClick) {
onEventClick(event);
} else {
// Default behavior: navigate to event details
navigate(`/event/${event.id}`);
}
};
const filteredEvents = selectedDate const filteredEvents = selectedDate
? events.filter(event => formatDate(event.date) === formatDate(selectedDate)) ? events.filter(event => formatDate(event.date) === formatDate(selectedDate))
: []; : [];
@ -90,7 +101,7 @@ export const Calendar = ({
<CalendarEventsList <CalendarEventsList
events={filteredEvents} events={filteredEvents}
date={selectedDate} date={selectedDate}
onEventClick={onEventClick} onEventClick={handleEventClick}
/> />
)} )}
</div> </div>

View File

@ -35,9 +35,9 @@
} }
.event-draft-card { .event-draft-card {
background-color: #ffffff; background-color: var(--bg-primary);
border-radius: 20px; border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2), 0 6px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 10px 30px var(--shadow-color), 0 6px 12px var(--shadow-color);
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
padding: 24px; padding: 24px;
@ -58,7 +58,7 @@
border: none; border: none;
font-size: 22px; font-size: 22px;
cursor: pointer; cursor: pointer;
color: #666; color: var(--color-slate);
width: 32px; width: 32px;
height: 32px; height: 32px;
display: flex; display: flex;
@ -69,8 +69,8 @@
} }
.draft-close-button:hover { .draft-close-button:hover {
background-color: #f0f0f0; background-color: var(--state-hover);
color: #000; color: var(--text-primary);
} }
.draft-card-content { .draft-card-content {
@ -82,14 +82,14 @@
font-weight: 600; font-weight: 600;
margin: 0 0 16px 0; margin: 0 0 16px 0;
line-height: 1.2; line-height: 1.2;
color: #000000; color: var(--text-primary);
} }
.draft-description { .draft-description {
font-size: 16px; font-size: 16px;
line-height: 1.4; line-height: 1.4;
margin-bottom: 20px; margin-bottom: 20px;
color: #333; color: var(--text-secondary);
white-space: pre-line; white-space: pre-line;
} }
@ -104,7 +104,7 @@
.draft-time { .draft-time {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
color: #555; color: var(--color-slate);
} }
.draft-label { .draft-label {
@ -116,8 +116,8 @@
.draft-all-day { .draft-all-day {
display: inline-block; display: inline-block;
background-color: #F2ADAD; background-color: var(--color-accent);
color: #000; color: var(--text-primary);
padding: 4px 12px; padding: 4px 12px;
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;
@ -144,21 +144,21 @@
} }
.draft-save-button { .draft-save-button {
background-color: #F2ADAD; background-color: var(--color-accent);
color: #000000; color: var(--text-primary);
} }
.draft-save-button:hover { .draft-save-button:hover {
background-color: #f09e9e; background-color: var(--color-mint);
transform: translateY(-1px); transform: translateY(-1px);
} }
.draft-edit-button { .draft-edit-button {
background-color: #e8f0fe; background-color: var(--state-hover);
color: #1a73e8; color: var(--color-secondary);
} }
.draft-edit-button:hover { .draft-edit-button:hover {
background-color: #d2e3fc; background-color: var(--state-active);
transform: translateY(-1px); transform: translateY(-1px);
} }

View File

@ -4,7 +4,7 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: rgba(255, 255, 255, 0.9); background-color: var(--overlay-dark);
z-index: 1000; z-index: 1000;
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -14,14 +14,14 @@
} }
.loading-overlay.transparent-background { .loading-overlay.transparent-background {
background-color: rgba(255, 255, 255, 0.5); background-color: var(--overlay-light);
} }
.loading-overlay-content { .loading-overlay-content {
padding: 2rem; padding: 2rem;
border-radius: 1rem; border-radius: 1rem;
background-color: white; background-color: var(--bg-primary);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); box-shadow: 0 10px 25px var(--shadow-color);
animation: scaleInOverlay 0.5s ease-in-out; animation: scaleInOverlay 0.5s ease-in-out;
} }

View File

@ -14,7 +14,7 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: rgba(255, 255, 255, 0.9); background-color: var(--overlay-dark);
z-index: 1000; z-index: 1000;
} }
@ -26,7 +26,7 @@
} }
.loading-spinner { .loading-spinner {
color: #F2ADAD; color: var(--color-accent);
opacity: 0; opacity: 0;
transform: scale(0.9); transform: scale(0.9);
animation: scaleIn 0.6s ease-out forwards 0.2s; animation: scaleIn 0.6s ease-out forwards 0.2s;
@ -47,7 +47,7 @@
.loading-spinner-message { .loading-spinner-message {
margin-top: 1rem; margin-top: 1rem;
font-size: 1.2rem; font-size: 1.2rem;
color: #555; color: var(--color-slate);
font-weight: 500; font-weight: 500;
opacity: 0; opacity: 0;
animation: slideUp 0.6s ease-out forwards 0.3s; animation: slideUp 0.6s ease-out forwards 0.3s;

View File

@ -1,4 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@import './lib/utils/colors.css';
* { * {
box-sizing: border-box; box-sizing: border-box;
@ -17,6 +18,8 @@ body {
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
color: var(--text-primary);
background-color: var(--bg-primary);
} }
code { code {

View File

@ -26,19 +26,16 @@ export type Event = {
allDay: boolean; allDay: boolean;
}; };
export type CreateEventRequest = { export type PersistEventRequest = {
title: string; draft: EventDraft;
description?: string; id?: string;
start: string;
end: string;
allDay?: boolean;
}; };
// Event endpoints // Event endpoints
export const getEvents = () => get<Event[]>('/api/events'); export const getEvents = () => get<Event[]>('/api/events');
export const getEvent = (id: string) => get<Event>(`/api/events/${id}`); export const getEvent = (id: string) => get<Event>(`/api/events/${id}`);
export const createEvent = (data: CreateEventRequest) => post<Event>('/api/events', data); export const persistEvent = (data: PersistEventRequest) => post<Event>('/api/events', data);
export const updateEvent = (id: string, data: Partial<CreateEventRequest>) => put<Event>(`/api/events/${id}`, data); export const updateEvent = (id: string, data: Partial<PersistEventRequest>) => put<Event>(`/api/events/${id}`, data);
export const deleteEvent = (id: string) => del<void>(`/api/events/${id}`); export const deleteEvent = (id: string) => del<void>(`/api/events/${id}`);
// Event draft types // Event draft types

View File

@ -43,25 +43,4 @@ export function useApi<T, P extends unknown[]>(
); );
return [execute, state]; return [execute, state];
} }
// Example usage:
//
// function UserList() {
// const [fetchUsers, { data: users, loading, error }] = useApi(getUsers, []);
//
// useEffect(() => {
// fetchUsers();
// }, [fetchUsers]);
//
// if (loading) return <div>Loading...</div>;
// if (error) return <div>Error: {error}</div>;
//
// return (
// <ul>
// {users?.map(user => (
// <li key={user.id}>{user.name}</li>
// ))}
// </ul>
// );
// }

View File

@ -86,4 +86,66 @@ export class DateUtils {
result.setFullYear(result.getFullYear() + years); result.setFullYear(result.getFullYear() + years);
return result; return result;
} }
} }
/**
* Format a date to a human-readable date string (e.g., "Monday, January 1, 2023")
*/
export const formatDate = (date: Date): string => {
return date.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
/**
* Format a date to a time string (e.g., "9:00 AM")
*/
export const formatTime = (date: Date): string => {
return date.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit'
});
};
/**
* Format a date range for display
*/
export const formatDateRange = (start: Date, end: Date, allDay = false): string => {
// Same day
if (start.toDateString() === end.toDateString()) {
if (allDay) {
return formatDate(start);
}
return `${formatDate(start)}, ${formatTime(start)} - ${formatTime(end)}`;
}
// Different days
if (allDay) {
return `${formatDate(start)} - ${formatDate(end)}`;
}
return `${formatDate(start)}, ${formatTime(start)} - ${formatDate(end)}, ${formatTime(end)}`;
};
/**
* Get a short formatted date (e.g., "Jan 1")
*/
export const formatShortDate = (date: Date): string => {
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
});
};
/**
* Check if two dates are on the same day
*/
export const isSameDay = (date1: Date, date2: Date): boolean => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
};

View File

@ -0,0 +1,51 @@
:root {
/* Color Palette */
--color-dark: #222222;
--color-white: #FFFFFF;
--color-light: #F5F5F5;
--color-indigo: #4B4E6D;
--color-mint: #84DCC6;
--color-slate: #95A3B3;
/* 60-30-10 Rule Application */
--color-primary: var(--color-white); /* 60% - dominant */
--color-secondary: var(--color-indigo); /* 30% - secondary */
--color-accent: var(--color-mint); /* 10% - accent */
/* Text Colors */
--text-primary: var(--color-dark);
--text-secondary: var(--color-indigo);
--text-on-dark: var(--color-white);
/* Background Colors */
--bg-primary: var(--color-white);
--bg-secondary: var(--color-slate);
--bg-tertiary: var(--color-indigo);
/* Border and Shadow Colors */
--border-light: rgba(34, 34, 34, 0.1);
--shadow-color: rgba(34, 34, 34, 0.08);
--shadow-accent: rgba(132, 220, 198, 0.3);
--shadow-accent-hover: rgba(132, 220, 198, 0.4);
/* Overlay Colors */
--overlay-background: rgba(255, 255, 255, 0.8);
--overlay-light: rgba(255, 255, 255, 0.5);
--overlay-dark: rgba(255, 255, 255, 0.9);
/* State Colors */
--state-hover: rgba(75, 78, 109, 0.08);
--state-active: rgba(75, 78, 109, 0.15);
--state-focus: rgba(132, 220, 198, 0.3);
/* Calendar Event Colors */
--event-default: var(--color-indigo);
--event-highlight: var(--color-mint);
--event-secondary: var(--color-slate);
/* Feedback Colors */
--color-error: #e53e3e;
--color-success: #38a169;
--color-warning: #d69e2e;
--color-info: #3182ce;
}

View File

@ -2,7 +2,7 @@
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding: 2rem;
background-color: #ffffff; background-color: var(--bg-primary);
min-height: 100vh; min-height: 100vh;
} }
@ -10,7 +10,7 @@
font-size: 1.8rem; font-size: 1.8rem;
font-weight: 600; font-weight: 600;
margin-bottom: 2rem; margin-bottom: 2rem;
color: #000000; color: var(--text-secondary);
} }
.edit-event-form { .edit-event-form {
@ -28,7 +28,7 @@
.form-group label { .form-group label {
font-weight: 500; font-weight: 500;
font-size: 1rem; font-size: 1rem;
color: #333; color: var(--text-primary);
} }
.form-group input[type="text"], .form-group input[type="text"],
@ -36,10 +36,10 @@
.form-group input[type="date"], .form-group input[type="date"],
.form-group input[type="time"] { .form-group input[type="time"] {
padding: 0.75rem; padding: 0.75rem;
border: 1px solid #ddd; border: 1px solid var(--border-light);
border-radius: 8px; border-radius: 8px;
font-size: 1rem; font-size: 1rem;
background-color: #f9f9f9; background-color: var(--bg-primary);
transition: border-color 0.2s ease; transition: border-color 0.2s ease;
} }
@ -48,8 +48,8 @@
.form-group input[type="date"]:focus, .form-group input[type="date"]:focus,
.form-group input[type="time"]:focus { .form-group input[type="time"]:focus {
outline: none; outline: none;
border-color: #F2ADAD; border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(242, 173, 173, 0.2); box-shadow: 0 0 0 2px var(--state-focus);
} }
.form-checkbox { .form-checkbox {
@ -61,7 +61,7 @@
.form-checkbox input[type="checkbox"] { .form-checkbox input[type="checkbox"] {
width: 18px; width: 18px;
height: 18px; height: 18px;
accent-color: #F2ADAD; accent-color: var(--color-accent);
} }
.form-row { .form-row {
@ -91,22 +91,22 @@
} }
.save-button { .save-button {
background-color: #F2ADAD; background-color: var(--color-accent);
color: #000000; color: var(--text-primary);
} }
.save-button:hover { .save-button:hover {
background-color: #f09e9e; background-color: var(--color-mint);
transform: translateY(-1px); transform: translateY(-1px);
} }
.cancel-button { .cancel-button {
background-color: #f0f0f0; background-color: var(--state-hover);
color: #555; color: var(--color-slate);
} }
.cancel-button:hover { .cancel-button:hover {
background-color: #e5e5e5; background-color: var(--state-active);
transform: translateY(-1px); transform: translateY(-1px);
} }

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { EventDraft, createEvent } from '../../lib/api/endpoints'; import { EventDraft, persistEvent } from '../../lib/api/endpoints';
import LoadingSpinner from '../../components/ui/LoadingSpinner'; import LoadingSpinner from '../../components/ui/LoadingSpinner';
import './EditEvent.css'; import './EditEvent.css';
@ -85,14 +85,17 @@ const EditEvent: React.FC = () => {
setLoading(true); setLoading(true);
const eventData = { const eventData = {
title, draft: {
description, id: id || '',
start: startDate ? formatForServer(startDate, startTime || '00:00') : new Date().toISOString(), title,
end: endDate ? formatForServer(endDate, endTime || '00:00') : new Date().toISOString(), description,
allDay 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); const savedEvent = await persistEvent(eventData);
console.log('Event created:', savedEvent); console.log('Event created:', savedEvent);
// Clear the draft from sessionStorage // Clear the draft from sessionStorage

View File

@ -0,0 +1,116 @@
.event-details-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.event-details-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.event-details-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
text-align: center;
flex: 1;
}
.back-button, .edit-button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #f5f5f5;
color: #333;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.back-button:hover, .edit-button:hover {
background-color: #e0e0e0;
}
.edit-button {
background-color: #4285f4;
color: white;
}
.edit-button:hover {
background-color: #3367d6;
}
.event-details-content {
padding: 16px 0;
}
.event-details-time {
margin-bottom: 24px;
padding: 16px;
background-color: #f8f9fa;
border-radius: 8px;
}
.event-date, .event-time, .event-all-day {
margin-bottom: 8px;
font-size: 16px;
}
.event-all-day {
font-weight: 500;
color: #4285f4;
}
.event-description {
margin-top: 24px;
}
.event-description h3 {
font-size: 18px;
margin-bottom: 8px;
color: #333;
}
.event-description p {
font-size: 16px;
line-height: 1.6;
color: #555;
white-space: pre-line;
}
.event-details-error {
max-width: 600px;
margin: 100px auto;
padding: 24px;
text-align: center;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.event-details-error p {
margin-bottom: 20px;
font-size: 18px;
color: #d93025;
}
@media (max-width: 768px) {
.event-details-container {
border-radius: 0;
box-shadow: none;
padding: 16px;
}
.event-details-title {
font-size: 20px;
}
}

View File

@ -0,0 +1,117 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { getEvent, Event } from '../../lib/api/endpoints';
import LoadingSpinner from '../../components/ui/LoadingSpinner';
import { formatDate, formatTime } from '../../lib/utils/DateUtils';
import './EventDetails.css';
const EventDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [event, setEvent] = useState<Event | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchEventDetails = async () => {
if (!id) {
setError('Event ID is missing');
setLoading(false);
return;
}
try {
setLoading(true);
const eventData = await getEvent(id);
setEvent(eventData);
setLoading(false);
} catch (err) {
setError('Failed to load event details');
setLoading(false);
}
};
fetchEventDetails();
}, [id]);
const handleBack = () => {
navigate(-1);
};
const handleEdit = () => {
if (event) {
// Convert the Event to EventDraft format and store in session storage
const eventDraft = {
id: event.id,
title: event.title,
description: event.description || '',
start: event.start,
end: event.end || null,
allDay: event.allDay || false
};
// Store the draft data in sessionStorage for use on the edit page
sessionStorage.setItem('editingEventDraft', JSON.stringify(eventDraft));
// Navigate to the edit page
navigate(`/edit-event/${event.id}`);
}
};
if (loading) {
return <LoadingSpinner message="Loading event details..." size="large" />;
}
if (error || !event) {
return (
<div className="event-details-error">
<p>{error || 'Event not found'}</p>
<button className="back-button" onClick={handleBack}>
Back
</button>
</div>
);
}
const startDate = new Date(event.start);
const endDate = event.end ? new Date(event.end) : null;
return (
<div className="event-details-container">
<div className="event-details-header">
<button className="back-button" onClick={handleBack}>
Back
</button>
<h1 className="event-details-title">{event.title}</h1>
<button className="edit-button" onClick={handleEdit}>
Edit
</button>
</div>
<div className="event-details-content">
<div className="event-details-time">
<div className="event-date">
<strong>Date:</strong> {formatDate(startDate)}
</div>
{event.allDay ? (
<div className="event-all-day">All day</div>
) : (
<div className="event-time">
<strong>Time:</strong> {formatTime(startDate)}
{endDate && ` - ${formatTime(endDate)}`}
</div>
)}
</div>
{event.description && (
<div className="event-description">
<h3>Description</h3>
<p>{event.description}</p>
</div>
)}
</div>
</div>
);
};
export default EventDetails;

View File

@ -0,0 +1 @@
export { default } from './EventDetails';

View File

@ -2,7 +2,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 1.25rem; padding: 1.25rem;
background-color: #ffffff; background-color: var(--bg-primary);
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
@ -23,7 +23,7 @@
.greeting { .greeting {
font-size: 2rem; font-size: 2rem;
font-weight: 600; font-weight: 600;
color: #000000; color: var(--text-primary);
line-height: 0.75em; line-height: 0.75em;
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
margin: 0; margin: 0;
@ -33,21 +33,21 @@
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 50%; border-radius: 50%;
background-color: #F2ADAD; background-color: var(--color-accent);
color: #000000; color: var(--text-primary);
border: none; border: none;
font-size: 24px; font-size: 24px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px var(--shadow-color);
transition: transform 0.2s, background-color 0.2s; transition: transform 0.2s, background-color 0.2s;
} }
.add-button:hover { .add-button:hover {
transform: scale(1.05); transform: scale(1.05);
background-color: #f09e9e; background-color: var(--color-mint);
} }
.add-button span { .add-button span {
@ -105,13 +105,13 @@
} }
.textbox-container { .textbox-container {
background: white; background: var(--bg-primary);
border-radius: 8px; border-radius: 8px;
padding: 0; padding: 0;
width: 100%; width: 100%;
max-width: 90%; max-width: 90%;
position: relative; position: relative;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); box-shadow: 0 4px 20px var(--shadow-color);
animation: slideDown 0.3s ease-out; animation: slideDown 0.3s ease-out;
} }
@ -128,7 +128,7 @@
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-size: 20px; font-size: 20px;
resize: none; resize: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px var(--shadow-color);
} }
.close-button { .close-button {
@ -139,12 +139,12 @@
border: none; border: none;
font-size: 24px; font-size: 24px;
cursor: pointer; cursor: pointer;
color: #666; color: var(--color-slate);
z-index: 1001; z-index: 1001;
} }
.close-button:hover { .close-button:hover {
color: #000; color: var(--text-primary);
} }
.events-section { .events-section {
@ -155,7 +155,7 @@
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
margin-bottom: 1rem; margin-bottom: 1rem;
color: #000000; color: var(--text-secondary);
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
line-height: 1.2em; line-height: 1.2em;
} }
@ -190,52 +190,54 @@
} }
.event-item { .event-item {
background-color: #F2ADAD; background-color: var(--color-light);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1.125rem 1.25rem; padding: 1.125rem 1.25rem;
color: #000000; color: var(--text-primary);
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s, border-left-width 0.2s;
box-shadow: 0 2px 8px rgba(242, 173, 173, 0.3); box-shadow: 0 2px 8px var(--shadow-light);
display: flex; border-left: 8px solid var(--color-accent);
flex-direction: column;
min-height: 80px;
} }
.event-item:hover { .event-item:hover {
transform: translateY(-2px); transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(242, 173, 173, 0.4); box-shadow: 0 4px 12px var(--shadow-color);
border-left-width: 12px;
} }
.event-title { .event-title {
font-size: 1.125rem;
font-weight: 600; font-weight: 600;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
word-break: break-word; font-size: 1rem;
line-height: 1.3;
color: var(--text-primary);
} }
.event-time { .event-time {
font-size: 0.875rem; font-size: 0.85rem;
opacity: 0.9; color: var(--text-primary);
opacity: 0.8;
} }
.no-events { .no-events {
color: #888; text-align: center;
padding: 1.5rem;
color: var(--color-slate);
font-style: italic; font-style: italic;
padding: 1rem 0; background-color: var(--state-hover);
border-radius: 0.5rem;
} }
.loading, .error { .loading, .error {
display: flex; text-align: center;
justify-content: center; padding: 1.5rem;
align-items: center; color: var(--color-slate);
height: 100%; font-style: italic;
font-size: 1.125rem;
color: #555;
} }
.error { .error {
color: #F2ADAD; color: var(--color-error, #e53e3e);
} }
.draft-loading-overlay { .draft-loading-overlay {
@ -244,37 +246,35 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: rgba(255, 255, 255, 0.8); background-color: var(--overlay-background);
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
z-index: 2000; align-items: center;
backdrop-filter: blur(3px); z-index: 1000;
} }
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.greeting { .greeting {
font-size: 1.75rem; font-size: 1.5rem;
line-height: 1em;
} }
.section-title { .section-title {
font-size: 1.25rem; font-size: 1.125rem;
} }
.events-section { .events-section {
margin-bottom: 2rem; margin-bottom: 1.5rem;
width: 100%;
max-width: 100%;
} }
.tomorrow-events { .tomorrow-events {
flex-direction: column; overflow-x: auto;
padding-bottom: 1rem;
} }
.tomorrow-events .event-item { .tomorrow-events .event-item {
width: 100%; min-width: 230px;
min-width: auto;
} }
} }
@ -284,11 +284,11 @@
} }
.greeting { .greeting {
font-size: 1.5rem; font-size: 1.25rem;
} }
.tomorrow-events .event-item { .tomorrow-events .event-item {
width: 100%; min-width: 200px;
} }
.week-events { .week-events {
@ -299,6 +299,6 @@
/* Ensure scrolling works properly within the TabView */ /* Ensure scrolling works properly within the TabView */
@media (max-height: 700px) { @media (max-height: 700px) {
.home-container { .home-container {
padding-bottom: 6.25rem; padding-bottom: 5rem;
} }
} }

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef, KeyboardEvent } from 'react'; import React, { useEffect, useState, useRef, KeyboardEvent } from 'react';
import { getEvents, Event, generateDraft, EventDraft, GenerateDraftRequest } from '../../lib/api/endpoints'; import { getEvents, Event, generateDraft, EventDraft, GenerateDraftRequest, persistEvent, PersistEventRequest } from '../../lib/api/endpoints';
import LoadingSpinner from '../../components/ui/LoadingSpinner'; import LoadingSpinner from '../../components/ui/LoadingSpinner';
import EventDraftCard from '../../components/ui/EventDraftCard'; import EventDraftCard from '../../components/ui/EventDraftCard';
import { useUser } from '../../lib/context'; import { useUser } from '../../lib/context';
@ -20,6 +20,7 @@ const Home: React.FC = () => {
const [inputText, setInputText] = useState(''); const [inputText, setInputText] = useState('');
const [isDraftLoading, setIsDraftLoading] = useState(false); const [isDraftLoading, setIsDraftLoading] = useState(false);
const [eventDraft, setEventDraft] = useState<EventDraft | null>(null); const [eventDraft, setEventDraft] = useState<EventDraft | null>(null);
const [isSaving, setIsSaving] = useState(false);
const textInputRef = useRef<HTMLInputElement>(null); const textInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
@ -135,8 +136,43 @@ const Home: React.FC = () => {
navigate(`/edit-event/${draft.id}`); navigate(`/edit-event/${draft.id}`);
}; };
const saveEvent = async (draft: EventDraft) => {
try {
setIsSaving(true);
const request: PersistEventRequest = { draft };
const savedEvent = await persistEvent(request);
// Add the new event to the appropriate list
const eventDate = new Date(savedEvent.start);
eventDate.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + 7);
if (eventDate.getTime() === today.getTime()) {
setTodayEvents(prev => [...prev, savedEvent]);
} else if (eventDate.getTime() === tomorrow.getTime()) {
setTomorrowEvents(prev => [...prev, savedEvent]);
} else if (eventDate > today && eventDate <= weekEnd) {
setWeekEvents(prev => [...prev, savedEvent]);
}
setIsSaving(false);
handleDraftClose();
} catch (err) {
setError('Failed to save event');
setIsSaving(false);
}
};
const EventItem = ({ event }: { event: Event }) => ( const EventItem = ({ event }: { event: Event }) => (
<div className="event-item"> <div className="event-item" onClick={() => navigate(`/event/${event.id}`)}>
<div className="event-title">{event.title}</div> <div className="event-title">{event.title}</div>
<div className="event-time"> <div className="event-time">
{new Date(event.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(event.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
@ -156,9 +192,9 @@ const Home: React.FC = () => {
return ( return (
<div className="home-container"> <div className="home-container">
{isDraftLoading && ( {(isDraftLoading || isSaving) && (
<div className="draft-loading-overlay"> <div className="draft-loading-overlay">
<LoadingSpinner message="Termin wird erstellt..." size="medium" /> <LoadingSpinner message={isSaving ? "Termin wird gespeichert..." : "Termin wird erstellt..."} size="medium" />
</div> </div>
)} )}
@ -167,19 +203,7 @@ const Home: React.FC = () => {
draft={eventDraft} draft={eventDraft}
onClose={handleDraftClose} onClose={handleDraftClose}
onEdit={handleEditDraft} onEdit={handleEditDraft}
onSave={() => { onSave={() => saveEvent(eventDraft)}
// 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();
}}
/> />
)} )}
@ -230,8 +254,8 @@ const Home: React.FC = () => {
</section> </section>
{isTextboxOpen && ( {isTextboxOpen && (
<div className={`textbox-overlay ${isClosing ? 'closing' : ''}`}> <div className={`textbox-overlay ${isClosing ? 'closing' : ''}`} onClick={handleCloseTextbox}>
<div className={`textbox-container ${isClosing ? 'closing' : ''}`}> <div className={`textbox-container ${isClosing ? 'closing' : ''}`} onClick={(e) => e.stopPropagation()}>
<button className="close-button" onClick={handleCloseTextbox}>×</button> <button className="close-button" onClick={handleCloseTextbox}>×</button>
<input <input
ref={textInputRef} ref={textInputRef}