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.checkValidity": true,
"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="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 ###

View File

@ -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

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\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],
];

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
# 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

View 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);

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;
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,
);
}
}

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\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);

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
{
public function __construct(
private readonly ?int $id = null
private readonly ?string $id = null
) {
}
public function id(): ?int
public function id(): ?string
{
return $this->id;
}

View File

@ -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();
}
}

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;
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);
}
}

View File

@ -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;
}
}

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);
}
public function save(PersistedEvent $event): void
{
$this->getEntityManager()->persist($event);
$this->getEntityManager()->flush();
}
/**
* @return array<PersistedEvent>
*/

View File

@ -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"
]
}
}

View File

@ -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:

View File

@ -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 \

View File

@ -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;

View File

@ -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 {

View File

@ -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>

View File

@ -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;

View File

@ -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);
}

View File

@ -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>

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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 {

View File

@ -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

View File

@ -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>
// );
// }
}

View File

@ -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()
);
};

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;
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);
}

View File

@ -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

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;
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;
}
}

View File

@ -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}