Compare commits
2 Commits
85ea87201d
...
7bc741e506
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bc741e506 | ||
|
|
ac995b1b83 |
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -3,5 +3,6 @@
|
||||
"phpstan.configFile": "backend/phpstan.dist.neon",
|
||||
"phpstan.checkValidity": true,
|
||||
"phpstan.showTypeOnHover": false,
|
||||
"phpstan.showProgress": true
|
||||
"phpstan.showProgress": true,
|
||||
"php.version": "8.4"
|
||||
}
|
||||
@ -26,5 +26,5 @@ APP_SECRET=71bf50bfb778d456b3a376ff60d5dcd8
|
||||
# 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=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 ###
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"php": ">=8.4",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"doctrine/annotations": "^2.0",
|
||||
@ -14,20 +14,22 @@
|
||||
"nelmio/api-doc-bundle": "^5.0",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpstan/phpdoc-parser": "^2.1",
|
||||
"symfony/asset": "6.4.*",
|
||||
"symfony/console": "6.4.*",
|
||||
"symfony/dotenv": "6.4.*",
|
||||
"prinsfrank/standards": "^3.12",
|
||||
"symfony/asset": "7.2.*",
|
||||
"symfony/console": "7.2.*",
|
||||
"symfony/dotenv": "7.2.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "6.4.*",
|
||||
"symfony/http-client": "6.4.*",
|
||||
"symfony/property-access": "6.4.*",
|
||||
"symfony/property-info": "6.4.*",
|
||||
"symfony/runtime": "6.4.*",
|
||||
"symfony/serializer": "6.4.*",
|
||||
"symfony/twig-bundle": "6.4.*",
|
||||
"symfony/uid": "6.4.*",
|
||||
"symfony/validator": "6.4.*",
|
||||
"symfony/yaml": "6.4.*"
|
||||
"symfony/framework-bundle": "7.2.*",
|
||||
"symfony/http-client": "7.2.*",
|
||||
"symfony/monolog-bundle": "^3.10",
|
||||
"symfony/property-access": "7.2.*",
|
||||
"symfony/property-info": "7.2.*",
|
||||
"symfony/runtime": "7.2.*",
|
||||
"symfony/serializer": "7.2.*",
|
||||
"symfony/twig-bundle": "7.2.*",
|
||||
"symfony/uid": "7.2.*",
|
||||
"symfony/validator": "7.2.*",
|
||||
"symfony/yaml": "7.2.*"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
@ -76,13 +78,15 @@
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "6.4.*"
|
||||
"require": "7.2.*"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.1",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"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
1670
backend/composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -8,4 +8,6 @@ return [
|
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
62
backend/config/packages/monolog.yaml
Normal file
62
backend/config/packages/monolog.yaml
Normal 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
|
||||
11
backend/config/packages/web_profiler.yaml
Normal file
11
backend/config/packages/web_profiler.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
when@dev:
|
||||
web_profiler:
|
||||
toolbar: true
|
||||
|
||||
framework:
|
||||
profiler:
|
||||
collect_serializer_data: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
profiler: { collect: false }
|
||||
8
backend/config/routes/web_profiler.yaml
Normal file
8
backend/config/routes/web_profiler.yaml
Normal 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
|
||||
@ -4,6 +4,7 @@
|
||||
# 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
|
||||
parameters:
|
||||
app.timezone: 'Europe/Berlin'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
|
||||
@ -15,6 +15,7 @@ use Nelmio\ApiDocBundle\Attribute\Model;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[Route('/api/events', name: 'api_events_')]
|
||||
#[OA\Tag(name: 'Events')]
|
||||
class GetEventsController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
@ -23,7 +24,6 @@ class GetEventsController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('', name: 'list', methods: ['GET'])]
|
||||
#[OA\Tag(name: 'Events')]
|
||||
#[OA\Response(
|
||||
response: 200,
|
||||
description: 'Returns list of events',
|
||||
@ -39,7 +39,6 @@ class GetEventsController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'get', methods: ['GET'])]
|
||||
#[OA\Tag(name: 'Events')]
|
||||
#[OA\Parameter(
|
||||
name: 'id',
|
||||
description: 'Event ID',
|
||||
@ -58,7 +57,7 @@ class GetEventsController extends AbstractController
|
||||
)]
|
||||
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) {
|
||||
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
@ -5,36 +5,48 @@ declare(strict_types=1);
|
||||
namespace App\Application\DTO;
|
||||
|
||||
use App\Domain\Model\EventDraft;
|
||||
use DateTimeInterface;
|
||||
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
|
||||
#[OA\Property(type: 'string', nullable: true)]
|
||||
public ?string $title = null,
|
||||
#[OA\Property(type: 'string', nullable: true)]
|
||||
public ?string $description = null,
|
||||
#[OA\Property(type: 'string', nullable: true)]
|
||||
public ?string $location = null,
|
||||
#[OA\Property(type: 'datetime', nullable: true)]
|
||||
public ?DateTimeInterface $start = null,
|
||||
#[OA\Property(type: 'datetime', nullable: true)]
|
||||
public ?DateTimeInterface $end = null,
|
||||
#[OA\Property(type: 'boolean', nullable: true)]
|
||||
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
|
||||
{
|
||||
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()
|
||||
$draft->title,
|
||||
$draft->description,
|
||||
$draft->location,
|
||||
$draft->start,
|
||||
$draft->end,
|
||||
$draft->allDay,
|
||||
);
|
||||
}
|
||||
}
|
||||
24
backend/src/Application/DTO/PersistEventDTO.php
Normal file
24
backend/src/Application/DTO/PersistEventDTO.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -6,12 +6,14 @@ use App\Domain\Chat\ChatProviderInterface;
|
||||
use App\Domain\Chat\ChatSession;
|
||||
use App\Domain\Model\EventDraft;
|
||||
use App\Domain\User\UserContextProvider;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class GenerateDraftHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ChatProviderInterface $chatProvider,
|
||||
private readonly UserContextProvider $userContextProvider,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -19,14 +21,20 @@ class GenerateDraftHandler
|
||||
{
|
||||
$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.
|
||||
|
||||
$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.).
|
||||
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.
|
||||
- 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.
|
||||
- Finally, return the draft in the following format:
|
||||
|
||||
The event draft should be in the following format:
|
||||
```json
|
||||
@ -42,9 +50,14 @@ class GenerateDraftHandler
|
||||
|
||||
This is the current context:
|
||||
- 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.
|
||||
PROMPT);
|
||||
PROMPT;
|
||||
|
||||
$chat->system($systemPrompt);
|
||||
|
||||
$this->logger->info('Generating draft for input: ' . $generateDraft->input() . " System prompt: " . $systemPrompt);
|
||||
|
||||
$chat->user($generateDraft->input());
|
||||
$chat->commit(reasoning: false);
|
||||
|
||||
19
backend/src/Domain/Event/PersistEvent.php
Normal file
19
backend/src/Domain/Event/PersistEvent.php
Normal 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);
|
||||
}
|
||||
}
|
||||
28
backend/src/Domain/Event/PersistEventHandler.php
Normal file
28
backend/src/Domain/Event/PersistEventHandler.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -7,11 +7,11 @@ use App\Domain\Model\PersistedEvent;
|
||||
class ReadEvents
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?int $id = null
|
||||
private readonly ?string $id = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function id(): ?int
|
||||
public function id(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
@ -17,6 +17,10 @@ class ReadEventsHandler
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
22
backend/src/Domain/Location/Model/Address.php
Normal file
22
backend/src/Domain/Location/Model/Address.php
Normal 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));
|
||||
}
|
||||
}
|
||||
23
backend/src/Domain/Location/Model/City.php
Normal file
23
backend/src/Domain/Location/Model/City.php
Normal 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);
|
||||
}
|
||||
}
|
||||
11
backend/src/Domain/Location/Model/Country.php
Normal file
11
backend/src/Domain/Location/Model/Country.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Location\Model;
|
||||
|
||||
class Country
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $alpha2,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
16
backend/src/Domain/Location/Model/ZipCode.php
Normal file
16
backend/src/Domain/Location/Model/ZipCode.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,47 +2,49 @@
|
||||
|
||||
namespace App\Domain\Model;
|
||||
|
||||
use App\Domain\Model\Persisted\PersistedEvent;
|
||||
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 readonly ?string $title,
|
||||
public readonly ?string $description,
|
||||
public readonly ?string $location,
|
||||
public readonly ?DateTimeInterface $start,
|
||||
public readonly ?DateTimeInterface $end,
|
||||
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
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
if (!$this->end instanceof \DateTimeImmutable) {
|
||||
throw new \Exception('End date must be a DateTimeImmutable');
|
||||
}
|
||||
|
||||
public function location(): string
|
||||
{
|
||||
return $this->location;
|
||||
}
|
||||
if ($this->allDay === null) {
|
||||
throw new \Exception('All day must be a boolean');
|
||||
}
|
||||
|
||||
public function start(): ?DateTimeInterface
|
||||
{
|
||||
return $this->start;
|
||||
}
|
||||
if ($this->title === null) {
|
||||
throw new \Exception('Title must be a string');
|
||||
}
|
||||
|
||||
public function end(): ?DateTimeInterface
|
||||
{
|
||||
return $this->end;
|
||||
}
|
||||
if ($this->description === null) {
|
||||
throw new \Exception('Description must be a string');
|
||||
}
|
||||
|
||||
public function allDay(): bool
|
||||
{
|
||||
return $this->allDay;
|
||||
return $persistedEvent
|
||||
->setTitle($this->title)
|
||||
->setDescription($this->description)
|
||||
->setLocation($this->location)
|
||||
->setStart($this->start)
|
||||
->setEnd($this->end)
|
||||
->setAllDay($this->allDay);
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,9 @@ class PersistedEvent
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
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')]
|
||||
#[Assert\NotNull]
|
||||
private \DateTimeImmutable $from;
|
||||
@ -128,4 +131,16 @@ class PersistedEvent
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLocation(): ?string
|
||||
{
|
||||
return $this->location;
|
||||
}
|
||||
|
||||
public function setLocation(?string $location): self
|
||||
{
|
||||
$this->location = $location;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,12 @@ class EventRepository extends ServiceEntityRepository
|
||||
parent::__construct($registry, PersistedEvent::class);
|
||||
}
|
||||
|
||||
public function save(PersistedEvent $event): void
|
||||
{
|
||||
$this->getEntityManager()->persist($event);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<PersistedEvent>
|
||||
*/
|
||||
|
||||
@ -121,6 +121,18 @@
|
||||
"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": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
@ -170,5 +182,18 @@
|
||||
"files": [
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
services:
|
||||
app:
|
||||
image: app:latest
|
||||
hostname: calendi.test
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
@ -17,7 +18,7 @@ services:
|
||||
- proxy
|
||||
|
||||
postgres:
|
||||
hostname: calendi-postgres
|
||||
hostname: calendi-postgres.test
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
@ -29,6 +30,11 @@ services:
|
||||
- ./var/postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- 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:
|
||||
proxy:
|
||||
|
||||
@ -10,7 +10,13 @@ RUN apt-get update && apt-get install -y \
|
||||
apt-transport-https \
|
||||
software-properties-common \
|
||||
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
|
||||
RUN wget -O /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg \
|
||||
|
||||
@ -9,6 +9,11 @@ server {
|
||||
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$ {
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_index index.php;
|
||||
|
||||
@ -17,18 +17,18 @@
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
background-color: var(--color-secondary);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
color: var(--text-on-dark);
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
|
||||
@ -3,6 +3,7 @@ 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 EventDetails from './pages/event-details/EventDetails';
|
||||
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faHome } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useUser } from './lib/context';
|
||||
@ -32,6 +33,7 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/edit-event/:id" element={<EditEvent />} />
|
||||
<Route path="/event/:id" element={<EventDetails />} />
|
||||
<Route path="*" element={<TabView tabs={tabs} />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
max-width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #f8fafc;
|
||||
background-color: var(--bg-primary);
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@ -24,10 +24,10 @@
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
background-color: #ffffff;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background-color: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-light);
|
||||
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;
|
||||
padding: 5px 10px;
|
||||
position: fixed;
|
||||
@ -45,7 +45,7 @@
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
color: var(--color-slate);
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 12px;
|
||||
margin: 0 4px;
|
||||
@ -60,15 +60,15 @@
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 3px;
|
||||
background-color: #3182ce;
|
||||
background-color: var(--color-accent);
|
||||
transition: all 0.3s ease;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.tab-bar-item:hover {
|
||||
color: #334155;
|
||||
background-color: #f1f5f9;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--state-hover);
|
||||
}
|
||||
|
||||
.tab-bar-item:hover::after {
|
||||
@ -76,9 +76,9 @@
|
||||
}
|
||||
|
||||
.tab-bar-item.active {
|
||||
color: #3182ce;
|
||||
background-color: #ebf8ff;
|
||||
box-shadow: 0 2px 6px rgba(49, 130, 206, 0.15);
|
||||
color: var(--color-secondary);
|
||||
background-color: var(--state-active);
|
||||
box-shadow: 0 2px 6px var(--shadow-color);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@ -115,8 +115,8 @@
|
||||
flex-direction: column;
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
|
||||
background-color: var(--bg-primary);
|
||||
box-shadow: 0 5px 15px var(--shadow-color);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
.calendar {
|
||||
background-color: #fff;
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 4px 12px var(--shadow-color);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
@ -23,15 +23,15 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #333;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.navButton {
|
||||
@ -44,18 +44,18 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
color: var(--color-slate);
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.navButton:hover {
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
background-color: var(--state-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.navButton:focus {
|
||||
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 */
|
||||
@ -71,8 +71,8 @@
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #777;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
color: var(--color-slate);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.day, .emptyDay {
|
||||
@ -84,7 +84,7 @@
|
||||
}
|
||||
|
||||
.day:hover {
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--state-hover);
|
||||
}
|
||||
|
||||
.dayNumber {
|
||||
@ -93,7 +93,7 @@
|
||||
left: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #444;
|
||||
color: var(--text-primary);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
@ -103,12 +103,12 @@
|
||||
}
|
||||
|
||||
.today .dayNumber {
|
||||
background-color: #4285f4;
|
||||
color: white;
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--text-on-dark);
|
||||
}
|
||||
|
||||
.selectedDay {
|
||||
background-color: rgba(66, 133, 244, 0.08);
|
||||
background-color: var(--state-active);
|
||||
}
|
||||
|
||||
.selectedDay .dayNumber {
|
||||
@ -134,21 +134,21 @@
|
||||
|
||||
.moreEvents {
|
||||
font-size: 10px;
|
||||
color: #777;
|
||||
color: var(--color-slate);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Events list styles */
|
||||
.eventsList {
|
||||
padding: 16px;
|
||||
border-left: 1px solid #f0f0f0;
|
||||
border-left: 1px solid var(--border-light);
|
||||
flex: 1;
|
||||
max-height: 460px;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
border-left: none;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,13 +156,13 @@
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
color: #333;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.eventsListEmpty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #777;
|
||||
color: var(--color-slate);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@ -175,26 +175,26 @@
|
||||
.event {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #4285f4;
|
||||
background-color: var(--state-hover);
|
||||
border-left: 4px solid var(--event-default);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.event:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
}
|
||||
|
||||
.eventTime {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
color: var(--color-slate);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.eventTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CalendarHeader } from './CalendarHeader';
|
||||
import { CalendarGrid } from './CalendarGrid';
|
||||
import { CalendarEventsList } from './CalendarEventsList';
|
||||
@ -25,6 +26,7 @@ export const Calendar = ({
|
||||
onEventClick,
|
||||
initialDate = new Date()
|
||||
}: CalendarProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [currentDate, setCurrentDate] = useState<Date>(initialDate);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
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
|
||||
? events.filter(event => formatDate(event.date) === formatDate(selectedDate))
|
||||
: [];
|
||||
@ -90,7 +101,7 @@ export const Calendar = ({
|
||||
<CalendarEventsList
|
||||
events={filteredEvents}
|
||||
date={selectedDate}
|
||||
onEventClick={onEventClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -35,9 +35,9 @@
|
||||
}
|
||||
|
||||
.event-draft-card {
|
||||
background-color: #ffffff;
|
||||
background-color: var(--bg-primary);
|
||||
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%;
|
||||
max-width: 100%;
|
||||
padding: 24px;
|
||||
@ -58,7 +58,7 @@
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
color: var(--color-slate);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
@ -69,8 +69,8 @@
|
||||
}
|
||||
|
||||
.draft-close-button:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: #000;
|
||||
background-color: var(--state-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.draft-card-content {
|
||||
@ -82,14 +82,14 @@
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.2;
|
||||
color: #000000;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.draft-description {
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
@ -104,7 +104,7 @@
|
||||
.draft-time {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
color: #555;
|
||||
color: var(--color-slate);
|
||||
}
|
||||
|
||||
.draft-label {
|
||||
@ -116,8 +116,8 @@
|
||||
|
||||
.draft-all-day {
|
||||
display: inline-block;
|
||||
background-color: #F2ADAD;
|
||||
color: #000;
|
||||
background-color: var(--color-accent);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
@ -144,21 +144,21 @@
|
||||
}
|
||||
|
||||
.draft-save-button {
|
||||
background-color: #F2ADAD;
|
||||
color: #000000;
|
||||
background-color: var(--color-accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.draft-save-button:hover {
|
||||
background-color: #f09e9e;
|
||||
background-color: var(--color-mint);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.draft-edit-button {
|
||||
background-color: #e8f0fe;
|
||||
color: #1a73e8;
|
||||
background-color: var(--state-hover);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.draft-edit-button:hover {
|
||||
background-color: #d2e3fc;
|
||||
background-color: var(--state-active);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@ -4,7 +4,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
background-color: var(--overlay-dark);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@ -14,14 +14,14 @@
|
||||
}
|
||||
|
||||
.loading-overlay.transparent-background {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
background-color: var(--overlay-light);
|
||||
}
|
||||
|
||||
.loading-overlay-content {
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
background-color: white;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
background-color: var(--bg-primary);
|
||||
box-shadow: 0 10px 25px var(--shadow-color);
|
||||
animation: scaleInOverlay 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
background-color: var(--overlay-dark);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
color: #F2ADAD;
|
||||
color: var(--color-accent);
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
animation: scaleIn 0.6s ease-out forwards 0.2s;
|
||||
@ -47,7 +47,7 @@
|
||||
.loading-spinner-message {
|
||||
margin-top: 1rem;
|
||||
font-size: 1.2rem;
|
||||
color: #555;
|
||||
color: var(--color-slate);
|
||||
font-weight: 500;
|
||||
opacity: 0;
|
||||
animation: slideUp 0.6s ease-out forwards 0.3s;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
@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;
|
||||
@ -17,6 +18,8 @@ body {
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
code {
|
||||
|
||||
@ -26,19 +26,16 @@ export type Event = {
|
||||
allDay: boolean;
|
||||
};
|
||||
|
||||
export type CreateEventRequest = {
|
||||
title: string;
|
||||
description?: string;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay?: boolean;
|
||||
export type PersistEventRequest = {
|
||||
draft: EventDraft;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
// Event endpoints
|
||||
export const getEvents = () => get<Event[]>('/api/events');
|
||||
export const getEvent = (id: string) => get<Event>(`/api/events/${id}`);
|
||||
export const createEvent = (data: CreateEventRequest) => post<Event>('/api/events', data);
|
||||
export const updateEvent = (id: string, data: Partial<CreateEventRequest>) => put<Event>(`/api/events/${id}`, data);
|
||||
export const persistEvent = (data: PersistEventRequest) => post<Event>('/api/events', 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}`);
|
||||
|
||||
// Event draft types
|
||||
|
||||
@ -43,25 +43,4 @@ export function useApi<T, P extends unknown[]>(
|
||||
);
|
||||
|
||||
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>
|
||||
// );
|
||||
// }
|
||||
}
|
||||
@ -86,4 +86,66 @@ export class DateUtils {
|
||||
result.setFullYear(result.getFullYear() + years);
|
||||
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()
|
||||
);
|
||||
};
|
||||
51
frontend/src/lib/utils/colors.css
Normal file
51
frontend/src/lib/utils/colors.css
Normal 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;
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
background-color: #ffffff;
|
||||
background-color: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2rem;
|
||||
color: #000000;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.edit-event-form {
|
||||
@ -28,7 +28,7 @@
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
@ -36,10 +36,10 @@
|
||||
.form-group input[type="date"],
|
||||
.form-group input[type="time"] {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background-color: #f9f9f9;
|
||||
background-color: var(--bg-primary);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
@ -48,8 +48,8 @@
|
||||
.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);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--state-focus);
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
@ -61,7 +61,7 @@
|
||||
.form-checkbox input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #F2ADAD;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
@ -91,22 +91,22 @@
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #F2ADAD;
|
||||
color: #000000;
|
||||
background-color: var(--color-accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: #f09e9e;
|
||||
background-color: var(--color-mint);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background-color: #f0f0f0;
|
||||
color: #555;
|
||||
background-color: var(--state-hover);
|
||||
color: var(--color-slate);
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background-color: #e5e5e5;
|
||||
background-color: var(--state-active);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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 './EditEvent.css';
|
||||
|
||||
@ -85,14 +85,17 @@ const EditEvent: React.FC = () => {
|
||||
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
|
||||
draft: {
|
||||
id: id || '',
|
||||
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);
|
||||
const savedEvent = await persistEvent(eventData);
|
||||
console.log('Event created:', savedEvent);
|
||||
|
||||
// Clear the draft from sessionStorage
|
||||
|
||||
116
frontend/src/pages/event-details/EventDetails.css
Normal file
116
frontend/src/pages/event-details/EventDetails.css
Normal 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;
|
||||
}
|
||||
}
|
||||
117
frontend/src/pages/event-details/EventDetails.tsx
Normal file
117
frontend/src/pages/event-details/EventDetails.tsx
Normal 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;
|
||||
1
frontend/src/pages/event-details/index.ts
Normal file
1
frontend/src/pages/event-details/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './EventDetails';
|
||||
@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem;
|
||||
background-color: #ffffff;
|
||||
background-color: var(--bg-primary);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@ -23,7 +23,7 @@
|
||||
.greeting {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #000000;
|
||||
color: var(--text-primary);
|
||||
line-height: 0.75em;
|
||||
font-family: 'Inter', sans-serif;
|
||||
margin: 0;
|
||||
@ -33,21 +33,21 @@
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #F2ADAD;
|
||||
color: #000000;
|
||||
background-color: var(--color-accent);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
transform: scale(1.05);
|
||||
background-color: #f09e9e;
|
||||
background-color: var(--color-mint);
|
||||
}
|
||||
|
||||
.add-button span {
|
||||
@ -105,13 +105,13 @@
|
||||
}
|
||||
|
||||
.textbox-container {
|
||||
background: white;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
max-width: 90%;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -128,7 +128,7 @@
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 20px;
|
||||
resize: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 4px 12px var(--shadow-color);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
@ -139,12 +139,12 @@
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
color: var(--color-slate);
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #000;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.events-section {
|
||||
@ -155,7 +155,7 @@
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: #000000;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Inter', sans-serif;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
@ -190,52 +190,54 @@
|
||||
}
|
||||
|
||||
.event-item {
|
||||
background-color: #F2ADAD;
|
||||
background-color: var(--color-light);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.125rem 1.25rem;
|
||||
color: #000000;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(242, 173, 173, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 80px;
|
||||
transition: transform 0.2s, box-shadow 0.2s, border-left-width 0.2s;
|
||||
box-shadow: 0 2px 8px var(--shadow-light);
|
||||
border-left: 8px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.event-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(242, 173, 173, 0.4);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 12px var(--shadow-color);
|
||||
border-left-width: 12px;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
word-break: break-word;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.no-events {
|
||||
color: #888;
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: var(--color-slate);
|
||||
font-style: italic;
|
||||
padding: 1rem 0;
|
||||
background-color: var(--state-hover);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
font-size: 1.125rem;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: var(--color-slate);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #F2ADAD;
|
||||
color: var(--color-error, #e53e3e);
|
||||
}
|
||||
|
||||
.draft-loading-overlay {
|
||||
@ -244,37 +246,35 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
background-color: var(--overlay-background);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
backdrop-filter: blur(3px);
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.greeting {
|
||||
font-size: 1.75rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.events-section {
|
||||
margin-bottom: 2rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tomorrow-events {
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tomorrow-events .event-item {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
min-width: 230px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -284,11 +284,11 @@
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.tomorrow-events .event-item {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.week-events {
|
||||
@ -299,6 +299,6 @@
|
||||
/* Ensure scrolling works properly within the TabView */
|
||||
@media (max-height: 700px) {
|
||||
.home-container {
|
||||
padding-bottom: 6.25rem;
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
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 EventDraftCard from '../../components/ui/EventDraftCard';
|
||||
import { useUser } from '../../lib/context';
|
||||
@ -20,6 +20,7 @@ const Home: React.FC = () => {
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isDraftLoading, setIsDraftLoading] = useState(false);
|
||||
const [eventDraft, setEventDraft] = useState<EventDraft | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const textInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -135,8 +136,43 @@ const Home: React.FC = () => {
|
||||
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 }) => (
|
||||
<div className="event-item">
|
||||
<div className="event-item" onClick={() => navigate(`/event/${event.id}`)}>
|
||||
<div className="event-title">{event.title}</div>
|
||||
<div className="event-time">
|
||||
{new Date(event.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
@ -156,9 +192,9 @@ const Home: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
{isDraftLoading && (
|
||||
{(isDraftLoading || isSaving) && (
|
||||
<div className="draft-loading-overlay">
|
||||
<LoadingSpinner message="Termin wird erstellt..." size="medium" />
|
||||
<LoadingSpinner message={isSaving ? "Termin wird gespeichert..." : "Termin wird erstellt..."} size="medium" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -167,19 +203,7 @@ const Home: React.FC = () => {
|
||||
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();
|
||||
}}
|
||||
onSave={() => saveEvent(eventDraft)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -230,8 +254,8 @@ const Home: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{isTextboxOpen && (
|
||||
<div className={`textbox-overlay ${isClosing ? 'closing' : ''}`}>
|
||||
<div className={`textbox-container ${isClosing ? 'closing' : ''}`}>
|
||||
<div className={`textbox-overlay ${isClosing ? 'closing' : ''}`} onClick={handleCloseTextbox}>
|
||||
<div className={`textbox-container ${isClosing ? 'closing' : ''}`} onClick={(e) => e.stopPropagation()}>
|
||||
<button className="close-button" onClick={handleCloseTextbox}>×</button>
|
||||
<input
|
||||
ref={textInputRef}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user