inital
This commit is contained in:
parent
b870378fae
commit
926a6d7bc1
@ -7,3 +7,8 @@ alwaysApply: true
|
||||
# React components
|
||||
1. You will always provide a maintainable folder structure when adding or updating react components.
|
||||
2. You will reuse common components
|
||||
|
||||
# Folders
|
||||
- components: general reusable components
|
||||
- pages: specific components related to the domain and the application
|
||||
- lib: plain typescript for building the domain logic, services etc. you will never put react code in here
|
||||
8
.cursor/rules/symfony.mdc
Normal file
8
.cursor/rules/symfony.mdc
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# Updating / Creating Entities
|
||||
When updating or creating entities, always update and the related DTO classes.
|
||||
DTO Classes are a representation of a JSON Schema
|
||||
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"phpstan.binPath": "backend/vendor/bin/phpstan",
|
||||
"phpstan.configFile": "backend/phpstan.dist.neon",
|
||||
"phpstan.checkValidity": true,
|
||||
"phpstan.showTypeOnHover": false,
|
||||
"phpstan.showProgress": true
|
||||
}
|
||||
10
backend/.env
10
backend/.env
@ -18,3 +18,13 @@
|
||||
APP_ENV=dev
|
||||
APP_SECRET=71bf50bfb778d456b3a376ff60d5dcd8
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> doctrine/doctrine-bundle ###
|
||||
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
|
||||
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
|
||||
#
|
||||
# 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@postgres:5432/postgres?serverVersion=16&charset=utf8"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
4
backend/.gitignore
vendored
4
backend/.gitignore
vendored
@ -8,3 +8,7 @@
|
||||
/var/
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> phpstan/phpstan ###
|
||||
phpstan.neon
|
||||
###< phpstan/phpstan ###
|
||||
|
||||
38
backend/README.md
Normal file
38
backend/README.md
Normal file
@ -0,0 +1,38 @@
|
||||
# Domain-Driven Design Structure
|
||||
|
||||
This project follows Domain-Driven Design (DDD) principles with the following structure:
|
||||
|
||||
## Application Layer
|
||||
Contains application-specific logic and serves as the entry point for external requests.
|
||||
|
||||
- **Controller**: HTTP controllers that handle web requests
|
||||
- **DTO**: Data Transfer Objects for API request/response
|
||||
|
||||
## Domain Layer
|
||||
Contains the core business logic and domain models.
|
||||
|
||||
- **Model**: Domain entities representing the core business concepts
|
||||
|
||||
## Infrastructure Layer
|
||||
Provides technical capabilities that support the higher layers.
|
||||
|
||||
- **Repository**: Data access logic for persisting and retrieving domain objects
|
||||
- **DataFixtures**: Test data fixtures for development and testing
|
||||
|
||||
## Shared
|
||||
Contains cross-cutting concerns and utilities used across all layers.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── Application/
|
||||
│ ├── Controller/
|
||||
│ └── DTO/
|
||||
├── Domain/
|
||||
│ └── Model/
|
||||
├── Infrastructure/
|
||||
│ ├── Repository/
|
||||
│ └── DataFixtures/
|
||||
└── Shared/
|
||||
```
|
||||
@ -7,14 +7,25 @@
|
||||
"php": ">=8.1",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"doctrine/annotations": "^2.0",
|
||||
"doctrine/doctrine-bundle": "^2.14",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||
"doctrine/orm": "^3.3",
|
||||
"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.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "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.*"
|
||||
},
|
||||
"config": {
|
||||
@ -54,7 +65,9 @@
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
]
|
||||
],
|
||||
"phpstan": "phpstan analyse --ansi",
|
||||
"rebuild-db": "php bin/console doctrine:schema:drop --force && php bin/console doctrine:schema:update --force && php bin/console doctrine:fixtures:load --no-interaction"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/symfony": "*"
|
||||
@ -64,5 +77,11 @@
|
||||
"allow-contrib": false,
|
||||
"require": "6.4.*"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.1",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/phpstan-symfony": "^2.0",
|
||||
"symfony/maker-bundle": "^1.62"
|
||||
}
|
||||
}
|
||||
|
||||
2275
backend/composer.lock
generated
2275
backend/composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -4,4 +4,8 @@ return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
];
|
||||
|
||||
51
backend/config/packages/doctrine.yaml
Normal file
51
backend/config/packages/doctrine.yaml
Normal file
@ -0,0 +1,51 @@
|
||||
doctrine:
|
||||
dbal:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
# either here or in the DATABASE_URL env var (see .env file)
|
||||
#server_version: '16'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
use_savepoints: true
|
||||
driver: 'pdo_pgsql'
|
||||
orm:
|
||||
auto_generate_proxy_classes: true
|
||||
enable_lazy_ghost_objects: true
|
||||
report_fields_where_declared: true
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
auto_mapping: true
|
||||
mappings:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Domain/Model'
|
||||
prefix: 'App\Domain\Model'
|
||||
alias: App
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
orm:
|
||||
auto_generate_proxy_classes: false
|
||||
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
||||
query_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.system_cache_pool
|
||||
result_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.result_cache_pool
|
||||
|
||||
framework:
|
||||
cache:
|
||||
pools:
|
||||
doctrine.result_cache_pool:
|
||||
adapter: cache.app
|
||||
doctrine.system_cache_pool:
|
||||
adapter: cache.system
|
||||
6
backend/config/packages/doctrine_migrations.yaml
Normal file
6
backend/config/packages/doctrine_migrations.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
doctrine_migrations:
|
||||
migrations_paths:
|
||||
# namespace is arbitrary but should be different from App\Migrations
|
||||
# as migrations classes should NOT be autoloaded
|
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||
enable_profiler: false
|
||||
@ -4,6 +4,10 @@ nelmio_api_doc:
|
||||
title: Calendi API
|
||||
description: API Documentation for Calendi
|
||||
version: 1.0.0
|
||||
components:
|
||||
schemas:
|
||||
directory: "%kernel.project_dir%/public/schema"
|
||||
type: json
|
||||
areas:
|
||||
path_patterns:
|
||||
- ^/api(?!/doc$)
|
||||
|
||||
4
backend/config/packages/uid.yaml
Normal file
4
backend/config/packages/uid.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
framework:
|
||||
uid:
|
||||
default_uuid_version: 7
|
||||
time_based_uuid_version: 7
|
||||
13
backend/config/packages/validator.yaml
Normal file
13
backend/config/packages/validator.yaml
Normal file
@ -0,0 +1,13 @@
|
||||
framework:
|
||||
validation:
|
||||
email_validation_mode: html5
|
||||
|
||||
# Enables validator auto-mapping support.
|
||||
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
|
||||
#auto_mapping:
|
||||
# App\Entity\: []
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
validation:
|
||||
not_compromised_password: false
|
||||
@ -1,5 +1,5 @@
|
||||
controllers:
|
||||
resource:
|
||||
path: ../src/Controller/
|
||||
namespace: App\Controller
|
||||
path: ../src/Application/Controller/
|
||||
namespace: App\Application\Controller
|
||||
type: attribute
|
||||
|
||||
10
backend/phpstan.dist.neon
Normal file
10
backend/phpstan.dist.neon
Normal file
@ -0,0 +1,10 @@
|
||||
parameters:
|
||||
level: 10
|
||||
paths:
|
||||
- src
|
||||
symfony:
|
||||
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
|
||||
|
||||
includes:
|
||||
- vendor/phpstan/phpstan-symfony/extension.neon
|
||||
- vendor/phpstan/phpstan-symfony/rules.neon
|
||||
53
backend/src/Application/Controller/CalendarController.php
Normal file
53
backend/src/Application/Controller/CalendarController.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Application\Controller;
|
||||
|
||||
use App\Infrastructure\Repository\EventRepository;
|
||||
use App\Application\DTO\CalendarDTO;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Nelmio\ApiDocBundle\Attribute\Model;
|
||||
|
||||
|
||||
#[Route('/api/calendar', name: 'api_calendar_')]
|
||||
class CalendarController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventRepository $eventRepository
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'get', methods: ['GET'])]
|
||||
#[OA\Tag(name: 'Calendar')]
|
||||
#[OA\Response(
|
||||
response: 200,
|
||||
description: 'Returns calendar data with events',
|
||||
content: new OA\JsonContent(
|
||||
ref: new Model(type: CalendarDTO::class)
|
||||
)
|
||||
)]
|
||||
public function getCalendar(Request $request): JsonResponse
|
||||
{
|
||||
$start = $request->query->get('start');
|
||||
$end = $request->query->get('end');
|
||||
|
||||
if (!$start || !$end) {
|
||||
// Default to current month if not specified
|
||||
$now = new \DateTimeImmutable();
|
||||
$start = $now->modify('first day of this month')->setTime(0, 0);
|
||||
$end = $now->modify('last day of this month')->setTime(23, 59, 59);
|
||||
} else {
|
||||
$start = new \DateTimeImmutable($start);
|
||||
$end = new \DateTimeImmutable($end);
|
||||
}
|
||||
|
||||
$events = $this->eventRepository->findByDateRange($start, $end);
|
||||
|
||||
return $this->json([
|
||||
'events' => $events,
|
||||
]);
|
||||
}
|
||||
}
|
||||
243
backend/src/Application/Controller/EventController.php
Normal file
243
backend/src/Application/Controller/EventController.php
Normal file
@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace App\Application\Controller;
|
||||
|
||||
use App\Domain\Model\Event;
|
||||
use App\Infrastructure\Repository\EventRepository;
|
||||
use App\Application\DTO\EventDTO;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
use Nelmio\ApiDocBundle\Attribute\Model;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[Route('/api/events', name: 'api_events_')]
|
||||
class EventController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ValidatorInterface $validator,
|
||||
private readonly EventRepository $eventRepository
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'list', methods: ['GET'])]
|
||||
#[OA\Tag(name: 'Events')]
|
||||
#[OA\Response(
|
||||
response: 200,
|
||||
description: 'Returns list of events',
|
||||
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
|
||||
)]
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
/** @var Event[] $events */
|
||||
$events = $this->eventRepository->findAll();
|
||||
$eventDTOs = array_map(static fn (Event $event) => EventDTO::fromEntity($event), $events);
|
||||
|
||||
return $this->json($eventDTOs);
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'get', methods: ['GET'])]
|
||||
#[OA\Tag(name: 'Events')]
|
||||
#[OA\Parameter(
|
||||
name: 'id',
|
||||
description: 'Event ID',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
)]
|
||||
#[OA\Response(
|
||||
response: 200,
|
||||
description: 'Returns event details',
|
||||
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
|
||||
)]
|
||||
#[OA\Response(
|
||||
response: 404,
|
||||
description: 'Event not found'
|
||||
)]
|
||||
public function get(string $id): JsonResponse
|
||||
{
|
||||
$event = $this->eventRepository->find($id);
|
||||
|
||||
if (!$event instanceof Event) {
|
||||
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->json(EventDTO::fromEntity($event));
|
||||
}
|
||||
|
||||
#[Route('', name: 'create', methods: ['POST'])]
|
||||
#[OA\Tag(name: 'Events')]
|
||||
#[OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
|
||||
)]
|
||||
#[OA\Response(
|
||||
response: 201,
|
||||
description: 'Event created',
|
||||
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
|
||||
)]
|
||||
#[OA\Response(
|
||||
response: 400,
|
||||
description: 'Invalid input'
|
||||
)]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
/** @var array<string, mixed>|null $data */
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $this->json(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle((string)$data['title']);
|
||||
|
||||
if (array_key_exists('description', $data)) {
|
||||
$event->setDescription($data['description'] !== null ? (string)$data['description'] : null);
|
||||
}
|
||||
|
||||
try {
|
||||
$event->setStart(new \DateTimeImmutable($data['start'] ?? 'now'));
|
||||
$event->setEnd(new \DateTimeImmutable($data['end'] ?? 'now'));
|
||||
} catch (\Exception) {
|
||||
return $this->json(['error' => 'Invalid date format'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$event->setAllDay(isset($data['allDay']) ? (bool)$data['allDay'] : false);
|
||||
|
||||
$errors = $this->validator->validate($event);
|
||||
if (count($errors) > 0) {
|
||||
$errorMessages = [];
|
||||
foreach ($errors as $error) {
|
||||
$errorMessages[$error->getPropertyPath()] = $error->getMessage();
|
||||
}
|
||||
|
||||
return $this->json(['errors' => $errorMessages], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($event);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json(EventDTO::fromEntity($event), Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'update', methods: ['PUT'])]
|
||||
#[OA\Tag(name: 'Events')]
|
||||
#[OA\Parameter(
|
||||
name: 'id',
|
||||
description: 'Event ID',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
)]
|
||||
#[OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
|
||||
)]
|
||||
#[OA\Response(
|
||||
response: 200,
|
||||
description: 'Event updated',
|
||||
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
|
||||
)]
|
||||
#[OA\Response(
|
||||
response: 400,
|
||||
description: 'Invalid input'
|
||||
)]
|
||||
#[OA\Response(
|
||||
response: 404,
|
||||
description: 'Event not found'
|
||||
)]
|
||||
public function update(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$event = $this->eventRepository->find($id);
|
||||
|
||||
if (!$event instanceof Event) {
|
||||
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
/** @var array<string, mixed>|null $data */
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $this->json(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (isset($data['title'])) {
|
||||
$event->setTitle((string)$data['title']);
|
||||
}
|
||||
|
||||
if (array_key_exists('description', $data)) {
|
||||
$event->setDescription($data['description'] !== null ? (string)$data['description'] : null);
|
||||
}
|
||||
|
||||
if (isset($data['start'])) {
|
||||
try {
|
||||
$event->setStart(new \DateTimeImmutable((string)$data['start']));
|
||||
} catch (\Exception) {
|
||||
return $this->json(['error' => 'Invalid start date format'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['end'])) {
|
||||
try {
|
||||
$event->setEnd(new \DateTimeImmutable((string)$data['end']));
|
||||
} catch (\Exception) {
|
||||
return $this->json(['error' => 'Invalid end date format'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['allDay'])) {
|
||||
$event->setAllDay((bool)$data['allDay']);
|
||||
}
|
||||
|
||||
$errors = $this->validator->validate($event);
|
||||
if (count($errors) > 0) {
|
||||
$errorMessages = [];
|
||||
foreach ($errors as $error) {
|
||||
$errorMessages[$error->getPropertyPath()] = $error->getMessage();
|
||||
}
|
||||
|
||||
return $this->json(['errors' => $errorMessages], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json(EventDTO::fromEntity($event));
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'delete', methods: ['DELETE'])]
|
||||
#[OA\Tag(name: 'Events')]
|
||||
#[OA\Parameter(
|
||||
name: 'id',
|
||||
description: 'Event ID',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
)]
|
||||
#[OA\Response(
|
||||
response: 204,
|
||||
description: 'Event deleted'
|
||||
)]
|
||||
#[OA\Response(
|
||||
response: 404,
|
||||
description: 'Event not found'
|
||||
)]
|
||||
public function delete(string $id): JsonResponse
|
||||
{
|
||||
$event = $this->eventRepository->find($id);
|
||||
|
||||
if (!$event instanceof Event) {
|
||||
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$this->entityManager->remove($event);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
namespace App\Application\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
41
backend/src/Application/Controller/UserController.php
Normal file
41
backend/src/Application/Controller/UserController.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Application\Controller;
|
||||
|
||||
use App\Application\DTO\UserDTO;
|
||||
use App\Infrastructure\Repository\UserRepository;
|
||||
use Nelmio\ApiDocBundle\Attribute\Model;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[Route('/api/user', name: 'api_user_')]
|
||||
class UserController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'user_current', methods: ['GET'])]
|
||||
#[OA\Tag(name: 'User')]
|
||||
#[OA\Response(
|
||||
response: 200,
|
||||
description: 'Returns the current user',
|
||||
content: new OA\JsonContent(
|
||||
ref: new Model(type: UserDTO::class)
|
||||
)
|
||||
)]
|
||||
public function getCurrentUser(): JsonResponse
|
||||
{
|
||||
$user = $this->userRepository->findOneBy([]);
|
||||
|
||||
if (!$user) {
|
||||
throw new NotFoundHttpException('No user found');
|
||||
}
|
||||
|
||||
return $this->json(UserDTO::fromEntity($user));
|
||||
}
|
||||
}
|
||||
28
backend/src/Application/DTO/CalendarDTO.php
Normal file
28
backend/src/Application/DTO/CalendarDTO.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\DTO;
|
||||
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema]
|
||||
final readonly class CalendarDTO
|
||||
{
|
||||
/**
|
||||
* @param array<EventDTO> $events
|
||||
*/
|
||||
public function __construct(
|
||||
#[OA\Property(type: 'array', items: new OA\Items(ref: "#/components/schemas/EventDTO"))]
|
||||
public array $events = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function addEvent(EventDTO $event): self
|
||||
{
|
||||
$events = $this->events;
|
||||
$events[] = $event;
|
||||
|
||||
return new self($events);
|
||||
}
|
||||
}
|
||||
40
backend/src/Application/DTO/EventDTO.php
Normal file
40
backend/src/Application/DTO/EventDTO.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\DTO;
|
||||
|
||||
use App\Domain\Model\Event;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema]
|
||||
final readonly class EventDTO
|
||||
{
|
||||
public function __construct(
|
||||
#[OA\Property(type: 'string')]
|
||||
public string $id,
|
||||
#[OA\Property(type: 'string')]
|
||||
public string $title,
|
||||
#[OA\Property(type: 'string')]
|
||||
public string $description,
|
||||
#[OA\Property(type: 'string')]
|
||||
public string $start,
|
||||
#[OA\Property(type: 'string')]
|
||||
public string $end,
|
||||
#[OA\Property(type: 'boolean')]
|
||||
public bool $allDay
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromEntity(Event $event): self
|
||||
{
|
||||
return new self(
|
||||
$event->getId(),
|
||||
$event->getTitle(),
|
||||
$event->getDescription() ?? '',
|
||||
$event->getFrom()->format('Y-m-d H:i:s'),
|
||||
$event->getTo()->format('Y-m-d H:i:s'),
|
||||
$event->isAllDay()
|
||||
);
|
||||
}
|
||||
}
|
||||
37
backend/src/Application/DTO/UserDTO.php
Normal file
37
backend/src/Application/DTO/UserDTO.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\DTO;
|
||||
|
||||
use App\Domain\Model\User;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema]
|
||||
final readonly class UserDTO
|
||||
{
|
||||
public function __construct(
|
||||
#[OA\Property(type: 'string')]
|
||||
public string $id,
|
||||
#[OA\Property(type: 'string')]
|
||||
public string $email,
|
||||
#[OA\Property(type: 'string')]
|
||||
public string $firstName,
|
||||
#[OA\Property(type: 'string')]
|
||||
public string $lastName,
|
||||
#[OA\Property(type: 'string')]
|
||||
public string $fullName
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromEntity(User $user): self
|
||||
{
|
||||
return new self(
|
||||
$user->getId(),
|
||||
$user->getEmail(),
|
||||
$user->getFirstName(),
|
||||
$user->getLastName(),
|
||||
$user->getFullName()
|
||||
);
|
||||
}
|
||||
}
|
||||
131
backend/src/Domain/Model/Event.php
Normal file
131
backend/src/Domain/Model/Event.php
Normal file
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model;
|
||||
|
||||
use App\Infrastructure\Repository\EventRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: EventRepository::class)]
|
||||
#[ORM\Table(name: 'app_event')]
|
||||
class Event
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: 'string')]
|
||||
private string $id;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
#[Assert\NotBlank]
|
||||
private string $title;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'event_from')]
|
||||
#[Assert\NotNull]
|
||||
private \DateTimeImmutable $from;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'event_to')]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\GreaterThanOrEqual(propertyPath: 'from')]
|
||||
private \DateTimeImmutable $to;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $allDay = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->id = Uuid::v4()->toRfc4122();
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(string $title): self
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): self
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStart(): \DateTimeImmutable
|
||||
{
|
||||
return $this->from;
|
||||
}
|
||||
|
||||
public function setStart(\DateTimeImmutable $start): self
|
||||
{
|
||||
$this->from = $start;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFrom(): \DateTimeImmutable
|
||||
{
|
||||
return $this->from;
|
||||
}
|
||||
|
||||
public function setFrom(\DateTimeImmutable $from): self
|
||||
{
|
||||
$this->from = $from;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEnd(): \DateTimeImmutable
|
||||
{
|
||||
return $this->to;
|
||||
}
|
||||
|
||||
public function setEnd(\DateTimeImmutable $end): self
|
||||
{
|
||||
$this->to = $end;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTo(): \DateTimeImmutable
|
||||
{
|
||||
return $this->to;
|
||||
}
|
||||
|
||||
public function setTo(\DateTimeImmutable $to): self
|
||||
{
|
||||
$this->to = $to;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isAllDay(): bool
|
||||
{
|
||||
return $this->allDay;
|
||||
}
|
||||
|
||||
public function setAllDay(bool $allDay): self
|
||||
{
|
||||
$this->allDay = $allDay;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
81
backend/src/Domain/Model/User.php
Normal file
81
backend/src/Domain/Model/User.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model;
|
||||
|
||||
use App\Infrastructure\Repository\UserRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Table(name: 'app_user')]
|
||||
class User
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: 'string')]
|
||||
private string $id;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, unique: true)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Email]
|
||||
private string $email;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
#[Assert\NotBlank]
|
||||
private string $firstName;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
#[Assert\NotBlank]
|
||||
private string $lastName;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->id = Uuid::v4()->toRfc4122();
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(string $email): self
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFirstName(): string
|
||||
{
|
||||
return $this->firstName;
|
||||
}
|
||||
|
||||
public function setFirstName(string $firstName): self
|
||||
{
|
||||
$this->firstName = $firstName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastName(): string
|
||||
{
|
||||
return $this->lastName;
|
||||
}
|
||||
|
||||
public function setLastName(string $lastName): self
|
||||
{
|
||||
$this->lastName = $lastName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFullName(): string
|
||||
{
|
||||
return "$this->firstName $this->lastName";
|
||||
}
|
||||
}
|
||||
26
backend/src/Infrastructure/DataFixtures/AppFixtures.php
Normal file
26
backend/src/Infrastructure/DataFixtures/AppFixtures.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\DataFixtures;
|
||||
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
class AppFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// $product = new Product();
|
||||
// $manager->persist($product);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [
|
||||
UserFixtures::class,
|
||||
EventFixtures::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
266
backend/src/Infrastructure/DataFixtures/EventFixtures.php
Normal file
266
backend/src/Infrastructure/DataFixtures/EventFixtures.php
Normal file
@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Domain\Model\Event;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
final class EventFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Health and wellness
|
||||
$this->createDoctorAppointment($manager);
|
||||
$this->createDentistAppointment($manager);
|
||||
$this->createYogaClass($manager);
|
||||
$this->createGymSession($manager);
|
||||
|
||||
// Social events
|
||||
$this->createDinnerWithFriends($manager);
|
||||
$this->createMovieNight($manager);
|
||||
$this->createBirthdayParty($manager);
|
||||
|
||||
// Travel and trips
|
||||
$this->createWeekendGetaway($manager);
|
||||
$this->createVacation($manager);
|
||||
|
||||
// Hobbies and interests
|
||||
$this->createCookingClass($manager);
|
||||
$this->createBookClub($manager);
|
||||
$this->createHiking($manager);
|
||||
|
||||
// Recurring events
|
||||
$this->createWeeklyGroceryShopping($manager);
|
||||
$this->createMonthlyCleaning($manager);
|
||||
|
||||
// Special occasions
|
||||
$this->createAnniversary($manager);
|
||||
$this->createConcert($manager);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
|
||||
private function createDoctorAppointment(ObjectManager $manager): void
|
||||
{
|
||||
$event = new Event();
|
||||
$event->setTitle('Doctor Appointment')
|
||||
->setDescription('Annual checkup with Dr. Smith')
|
||||
->setFrom(new DateTimeImmutable('next monday 09:30'))
|
||||
->setTo(new DateTimeImmutable('next monday 10:30'))
|
||||
->setAllDay(false);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createDentistAppointment(ObjectManager $manager): void
|
||||
{
|
||||
$event = new Event();
|
||||
$event->setTitle('Dentist Appointment')
|
||||
->setDescription('Teeth cleaning with Dr. Johnson')
|
||||
->setFrom(new DateTimeImmutable('next friday 14:00'))
|
||||
->setTo(new DateTimeImmutable('next friday 15:00'))
|
||||
->setAllDay(false);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createYogaClass(ObjectManager $manager): void
|
||||
{
|
||||
$tomorrow = new DateTimeImmutable('tomorrow');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Yoga Class')
|
||||
->setDescription('Vinyasa flow at Peaceful Mind Studio')
|
||||
->setFrom($tomorrow->setTime(18, 0))
|
||||
->setTo($tomorrow->setTime(19, 0))
|
||||
->setAllDay(false);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createGymSession(ObjectManager $manager): void
|
||||
{
|
||||
$dayAfterTomorrow = new DateTimeImmutable('today +2 days');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Gym Workout')
|
||||
->setDescription('Strength training at Fitness Center')
|
||||
->setFrom($dayAfterTomorrow->setTime(7, 0))
|
||||
->setTo($dayAfterTomorrow->setTime(8, 30))
|
||||
->setAllDay(false);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createDinnerWithFriends(ObjectManager $manager): void
|
||||
{
|
||||
$thisWeekend = new DateTimeImmutable('next saturday');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Dinner with Friends')
|
||||
->setDescription('Dinner at Italiano Restaurant with Alex and Jamie')
|
||||
->setFrom($thisWeekend->setTime(19, 0))
|
||||
->setTo($thisWeekend->setTime(22, 0))
|
||||
->setAllDay(false);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createMovieNight(ObjectManager $manager): void
|
||||
{
|
||||
$nextFriday = new DateTimeImmutable('next friday');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Movie Night')
|
||||
->setDescription('Watching new Marvel movie at Cinema City')
|
||||
->setFrom($nextFriday->setTime(20, 0))
|
||||
->setTo($nextFriday->setTime(23, 0))
|
||||
->setAllDay(false);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createBirthdayParty(ObjectManager $manager): void
|
||||
{
|
||||
$twoWeeksLater = new DateTimeImmutable('today +14 days');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Sarah\'s Birthday Party')
|
||||
->setDescription('Birthday celebration at Rooftop Bar')
|
||||
->setFrom($twoWeeksLater->setTime(18, 0))
|
||||
->setTo($twoWeeksLater->setTime(23, 0))
|
||||
->setAllDay(false);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createWeekendGetaway(ObjectManager $manager): void
|
||||
{
|
||||
$nextWeekend = new DateTimeImmutable('next saturday');
|
||||
$nextWeekendEnd = new DateTimeImmutable('next sunday');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Weekend Getaway')
|
||||
->setDescription('Short trip to the mountains')
|
||||
->setFrom($nextWeekend)
|
||||
->setTo($nextWeekendEnd)
|
||||
->setAllDay(true);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createVacation(ObjectManager $manager): void
|
||||
{
|
||||
$vacationStart = new DateTimeImmutable('next month first day');
|
||||
$vacationEnd = new DateTimeImmutable('next month first day +6 days');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Summer Vacation')
|
||||
->setDescription('Beach vacation in Hawaii')
|
||||
->setFrom($vacationStart)
|
||||
->setTo($vacationEnd)
|
||||
->setAllDay(true);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createCookingClass(ObjectManager $manager): void
|
||||
{
|
||||
$nextSaturday = new DateTimeImmutable('next saturday');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Italian Cooking Class')
|
||||
->setDescription('Learn to make pasta from scratch at Culinary Center')
|
||||
->setFrom($nextSaturday->setTime(14, 0))
|
||||
->setTo($nextSaturday->setTime(17, 0))
|
||||
->setAllDay(false);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createBookClub(ObjectManager $manager): void
|
||||
{
|
||||
$nextThursday = new DateTimeImmutable('next thursday');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Book Club Meeting')
|
||||
->setDescription('Discussing "The Midnight Library" at Local Cafe')
|
||||
->setFrom($nextThursday->setTime(19, 0))
|
||||
->setTo($nextThursday->setTime(21, 0))
|
||||
->setAllDay(false);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createHiking(ObjectManager $manager): void
|
||||
{
|
||||
$inTwoWeeks = new DateTimeImmutable('today +14 days');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Hiking Trip')
|
||||
->setDescription('Explore Blue Mountain Trail')
|
||||
->setFrom($inTwoWeeks->setTime(8, 0))
|
||||
->setTo($inTwoWeeks->setTime(16, 0))
|
||||
->setAllDay(false);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createWeeklyGroceryShopping(ObjectManager $manager): void
|
||||
{
|
||||
$nextSunday = new DateTimeImmutable('next sunday');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Grocery Shopping')
|
||||
->setDescription('Weekly grocery run at Farmer\'s Market')
|
||||
->setFrom($nextSunday->setTime(10, 0))
|
||||
->setTo($nextSunday->setTime(11, 30))
|
||||
->setAllDay(false);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createMonthlyCleaning(ObjectManager $manager): void
|
||||
{
|
||||
$firstOfNextMonth = new DateTimeImmutable('first day of next month');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Monthly Deep Cleaning')
|
||||
->setDescription('House deep cleaning day')
|
||||
->setFrom($firstOfNextMonth)
|
||||
->setTo($firstOfNextMonth)
|
||||
->setAllDay(true);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createAnniversary(ObjectManager $manager): void
|
||||
{
|
||||
$twoMonthsFromNow = new DateTimeImmutable('today +2 months');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Relationship Anniversary')
|
||||
->setDescription('Dinner reservation at Sunset Restaurant')
|
||||
->setFrom($twoMonthsFromNow->setTime(19, 0))
|
||||
->setTo($twoMonthsFromNow->setTime(22, 0))
|
||||
->setAllDay(false);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
|
||||
private function createConcert(ObjectManager $manager): void
|
||||
{
|
||||
$nextMonth = new DateTimeImmutable('next month');
|
||||
|
||||
$event = new Event();
|
||||
$event->setTitle('Live Concert')
|
||||
->setDescription('Favorite band performing at Central Arena')
|
||||
->setFrom($nextMonth->setTime(20, 0))
|
||||
->setTo($nextMonth->setTime(23, 0))
|
||||
->setAllDay(false);
|
||||
|
||||
$manager->persist($event);
|
||||
}
|
||||
}
|
||||
25
backend/src/Infrastructure/DataFixtures/UserFixtures.php
Normal file
25
backend/src/Infrastructure/DataFixtures/UserFixtures.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Domain\Model\User;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
final class UserFixtures extends Fixture
|
||||
{
|
||||
public const DEFAULT_USER_REFERENCE = 'default-user';
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setEmail('user@example.com')
|
||||
->setFirstName('John')
|
||||
->setLastName('Doe');
|
||||
|
||||
$manager->persist($user);
|
||||
$manager->flush();
|
||||
|
||||
$this->addReference(self::DEFAULT_USER_REFERENCE, $user);
|
||||
}
|
||||
}
|
||||
37
backend/src/Infrastructure/Repository/EventRepository.php
Normal file
37
backend/src/Infrastructure/Repository/EventRepository.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Repository;
|
||||
|
||||
use App\Domain\Model\Event;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Event>
|
||||
*/
|
||||
class EventRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Event::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Event>
|
||||
*/
|
||||
public function findByDateRange(\DateTimeInterface $start, \DateTimeInterface $end): array
|
||||
{
|
||||
/** @var array<Event> $result */
|
||||
$result = $this->createQueryBuilder('e')
|
||||
->andWhere('e.from >= :start AND e.from <= :end')
|
||||
->orWhere('e.to >= :start AND e.to <= :end')
|
||||
->orWhere('e.from <= :start AND e.to >= :end')
|
||||
->setParameter('start', $start)
|
||||
->setParameter('end', $end)
|
||||
->orderBy('e.from', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
30
backend/src/Infrastructure/Repository/UserRepository.php
Normal file
30
backend/src/Infrastructure/Repository/UserRepository.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Repository;
|
||||
|
||||
use App\Domain\Model\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<User>
|
||||
*/
|
||||
class UserRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, User::class);
|
||||
}
|
||||
|
||||
public function save(User $user): void
|
||||
{
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(User $user): void
|
||||
{
|
||||
$this->getEntityManager()->remove($user);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,52 @@
|
||||
{
|
||||
"doctrine/annotations": {
|
||||
"version": "2.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.10",
|
||||
"ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05"
|
||||
}
|
||||
},
|
||||
"doctrine/doctrine-bundle": {
|
||||
"version": "2.14",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.10",
|
||||
"ref": "c170ded8fc587d6bd670550c43dafcf093762245"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine.yaml",
|
||||
"src/Entity/.gitignore",
|
||||
"src/Repository/.gitignore"
|
||||
]
|
||||
},
|
||||
"doctrine/doctrine-fixtures-bundle": {
|
||||
"version": "4.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.0",
|
||||
"ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
|
||||
},
|
||||
"files": [
|
||||
"src/DataFixtures/AppFixtures.php"
|
||||
]
|
||||
},
|
||||
"doctrine/doctrine-migrations-bundle": {
|
||||
"version": "3.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.1",
|
||||
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine_migrations.yaml",
|
||||
"migrations/.gitignore"
|
||||
]
|
||||
},
|
||||
"nelmio/api-doc-bundle": {
|
||||
"version": "5.0",
|
||||
"recipe": {
|
||||
@ -8,6 +56,18 @@
|
||||
"ref": "c8e0c38e1a280ab9e37587a8fa32b251d5bc1c94"
|
||||
}
|
||||
},
|
||||
"phpstan/phpstan": {
|
||||
"version": "2.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
|
||||
},
|
||||
"files": [
|
||||
"phpstan.dist.neon"
|
||||
]
|
||||
},
|
||||
"symfony/console": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
@ -52,6 +112,15 @@
|
||||
"src/Kernel.php"
|
||||
]
|
||||
},
|
||||
"symfony/maker-bundle": {
|
||||
"version": "1.62",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
|
||||
}
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
@ -77,5 +146,29 @@
|
||||
"config/packages/twig.yaml",
|
||||
"templates/base.html.twig"
|
||||
]
|
||||
},
|
||||
"symfony/uid": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.2",
|
||||
"ref": "d294ad4add3e15d7eb1bae0221588ca89b38e558"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/uid.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/validator": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.3",
|
||||
"ref": "c32cfd98f714894c4f128bb99aa2530c1227603c"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/validator.yaml"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,8 @@ RUN apt-get update && apt-get install -y \
|
||||
php8.4-bcmath \
|
||||
php8.4-intl \
|
||||
php8.4-gd \
|
||||
php8.4-fpm
|
||||
php8.4-fpm \
|
||||
php8.4-pgsql
|
||||
|
||||
# Install Composer
|
||||
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import './App.css';
|
||||
import { TabView } from './components/navigation/TabView';
|
||||
import Home from './pages/home/Home';
|
||||
import Profile from './pages/profile/Profile';
|
||||
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faHome } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'home', label: 'Home', component: Home, icon: faHome },
|
||||
{ id: 'calendar', label: 'Calendar', component: Home, icon: faCalendar },
|
||||
{ id: 'profile', label: 'Profile', component: Home, icon: faUser }
|
||||
{ id: 'profile', label: 'Profile', component: Profile, icon: faUser }
|
||||
];
|
||||
|
||||
function App() {
|
||||
|
||||
@ -3,10 +3,13 @@
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #f8fafc;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
@ -60,13 +63,16 @@
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: -20px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
200
frontend/src/components/ui/Calendar/Calendar.module.css
Normal file
200
frontend/src/components/ui/Calendar/Calendar.module.css
Normal file
@ -0,0 +1,200 @@
|
||||
.calendar {
|
||||
background-color: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.calendarContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header styles */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.navButton {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.navButton:hover {
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.navButton:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3);
|
||||
}
|
||||
|
||||
/* Calendar grid styles */
|
||||
.calendarGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
padding: 0 1rem 1rem;
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.weekDay {
|
||||
padding: 12px 0;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #777;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.day, .emptyDay {
|
||||
aspect-ratio: 1/1;
|
||||
padding: 4px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.day:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.dayNumber {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #444;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.today .dayNumber {
|
||||
background-color: #4285f4;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.selectedDay {
|
||||
background-color: rgba(66, 133, 244, 0.08);
|
||||
}
|
||||
|
||||
.selectedDay .dayNumber {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.eventIndicators {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.eventIndicator {
|
||||
width: 80%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.moreEvents {
|
||||
font-size: 10px;
|
||||
color: #777;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Events list styles */
|
||||
.eventsList {
|
||||
padding: 16px;
|
||||
border-left: 1px solid #f0f0f0;
|
||||
flex: 1;
|
||||
max-height: 460px;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
border-left: none;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.eventsListTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.eventsListEmpty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #777;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.eventsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.event {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #4285f4;
|
||||
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);
|
||||
}
|
||||
|
||||
.eventTime {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.eventTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
49
frontend/src/components/ui/Calendar/CalendarDemo.tsx
Normal file
49
frontend/src/components/ui/Calendar/CalendarDemo.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Calendar, CalendarEvent } from './index';
|
||||
import { useApi } from '../../../lib/api/useApi';
|
||||
import { getEvents } from '../../../lib/api/endpoints';
|
||||
|
||||
export const CalendarDemo = () => {
|
||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
||||
const [fetchEvents, { data: events, loading, error }] = useApi(getEvents, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
|
||||
}, [fetchEvents]);
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error}</div>;
|
||||
}
|
||||
|
||||
const calendarEvents: CalendarEvent[] = events ? events.map(event => ({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
date: new Date(event.start),
|
||||
color: event.allDay ? '#4285F4' : '#34A853'
|
||||
})) : [];
|
||||
|
||||
console.log(calendarEvents);
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
console.log('Date clicked:', date);
|
||||
};
|
||||
|
||||
const handleEventClick = (event: CalendarEvent) => {
|
||||
setSelectedEvent(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="calendarDemo">
|
||||
<Calendar
|
||||
events={calendarEvents}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
58
frontend/src/components/ui/Calendar/CalendarEventsList.tsx
Normal file
58
frontend/src/components/ui/Calendar/CalendarEventsList.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { CalendarEvent } from './index';
|
||||
import styles from './Calendar.module.css';
|
||||
|
||||
export type CalendarEventsListProps = {
|
||||
events: CalendarEvent[];
|
||||
date: Date;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
};
|
||||
|
||||
export const CalendarEventsList = ({
|
||||
events,
|
||||
date,
|
||||
onEventClick
|
||||
}: CalendarEventsListProps) => {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className={styles.eventsListEmpty}>
|
||||
<p>No events scheduled for this day</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleEventClick = (event: CalendarEvent) => {
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.eventsList}>
|
||||
<h3 className={styles.eventsListTitle}>
|
||||
Events for {date.toLocaleDateString([], { month: 'long', day: 'numeric' })}
|
||||
</h3>
|
||||
|
||||
<div className={styles.eventsContainer}>
|
||||
{events.map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={styles.event}
|
||||
onClick={() => handleEventClick(event)}
|
||||
style={{ borderLeftColor: event.color || '#4285f4' }}
|
||||
>
|
||||
<div className={styles.eventTime}>
|
||||
{formatTime(event.date)}
|
||||
</div>
|
||||
<div className={styles.eventTitle}>
|
||||
{event.title}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
97
frontend/src/components/ui/Calendar/CalendarGrid.tsx
Normal file
97
frontend/src/components/ui/Calendar/CalendarGrid.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { formatDate } from '../../../lib/date';
|
||||
import { CalendarEvent } from './index';
|
||||
import styles from './Calendar.module.css';
|
||||
|
||||
export type CalendarGridProps = {
|
||||
daysInMonth: number[];
|
||||
firstDay: number;
|
||||
currentDate: Date;
|
||||
selectedDate: Date | null;
|
||||
events: CalendarEvent[];
|
||||
onDateClick: (day: number) => void;
|
||||
};
|
||||
|
||||
export const CalendarGrid = ({
|
||||
daysInMonth,
|
||||
firstDay,
|
||||
currentDate,
|
||||
selectedDate,
|
||||
events,
|
||||
onDateClick
|
||||
}: CalendarGridProps) => {
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const today = new Date();
|
||||
const currentMonth = currentDate.getMonth();
|
||||
const currentYear = currentDate.getFullYear();
|
||||
|
||||
const isCurrentMonth =
|
||||
today.getMonth() === currentMonth &&
|
||||
today.getFullYear() === currentYear;
|
||||
|
||||
const todayDate = today.getDate();
|
||||
|
||||
const getEventsByDay = (day: number) => {
|
||||
const date = new Date(currentYear, currentMonth, day);
|
||||
const formattedDate = formatDate(date);
|
||||
|
||||
return events.filter(event => formatDate(event.date) === formattedDate);
|
||||
};
|
||||
|
||||
const renderDay = (day: number) => {
|
||||
const date = new Date(currentYear, currentMonth, day);
|
||||
const isSelected = selectedDate && formatDate(date) === formatDate(selectedDate);
|
||||
const isToday = isCurrentMonth && day === todayDate;
|
||||
const dayEvents = getEventsByDay(day);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className={`
|
||||
${styles.day}
|
||||
${isSelected ? styles.selectedDay : ''}
|
||||
${isToday ? styles.today : ''}
|
||||
`}
|
||||
onClick={() => onDateClick(day)}
|
||||
>
|
||||
<span className={styles.dayNumber}>{day}</span>
|
||||
|
||||
{dayEvents.length > 0 && (
|
||||
<div className={styles.eventIndicators}>
|
||||
{dayEvents.slice(0, 3).map((event, index) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={styles.eventIndicator}
|
||||
style={{ backgroundColor: event.color || '#4285f4' }}
|
||||
title={event.title}
|
||||
/>
|
||||
))}
|
||||
|
||||
{dayEvents.length > 3 && (
|
||||
<div className={styles.moreEvents}>
|
||||
+{dayEvents.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmptyDay = (index: number) => (
|
||||
<div key={`empty-${index}`} className={styles.emptyDay} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.calendarGrid}>
|
||||
{weekDays.map(day => (
|
||||
<div key={day} className={styles.weekDay}>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Array(firstDay).fill(null).map((_, index) => renderEmptyDay(index))}
|
||||
|
||||
{daysInMonth.map(day => renderDay(day))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
47
frontend/src/components/ui/Calendar/CalendarHeader.tsx
Normal file
47
frontend/src/components/ui/Calendar/CalendarHeader.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import styles from './Calendar.module.css';
|
||||
|
||||
export type CalendarHeaderProps = {
|
||||
currentDate: Date;
|
||||
onPrevMonth: () => void;
|
||||
onNextMonth: () => void;
|
||||
};
|
||||
|
||||
export const CalendarHeader = ({
|
||||
currentDate,
|
||||
onPrevMonth,
|
||||
onNextMonth
|
||||
}: CalendarHeaderProps) => {
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const month = monthNames[currentDate.getMonth()];
|
||||
const year = currentDate.getFullYear();
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<button
|
||||
className={styles.navButton}
|
||||
onClick={onPrevMonth}
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
</button>
|
||||
|
||||
<h2 className={styles.headerTitle}>
|
||||
{month} {year}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
className={styles.navButton}
|
||||
onClick={onNextMonth}
|
||||
aria-label="Next month"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
99
frontend/src/components/ui/Calendar/index.tsx
Normal file
99
frontend/src/components/ui/Calendar/index.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CalendarHeader } from './CalendarHeader';
|
||||
import { CalendarGrid } from './CalendarGrid';
|
||||
import { CalendarEventsList } from './CalendarEventsList';
|
||||
import { getDaysInMonth, getFirstDayOfMonth, formatDate } from '../../../lib/date';
|
||||
import styles from './Calendar.module.css';
|
||||
|
||||
export type CalendarEvent = {
|
||||
id: string;
|
||||
title: string;
|
||||
date: Date;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export type CalendarProps = {
|
||||
events?: CalendarEvent[];
|
||||
onDateClick?: (date: Date) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
initialDate?: Date;
|
||||
};
|
||||
|
||||
export const Calendar = ({
|
||||
events = [],
|
||||
onDateClick,
|
||||
onEventClick,
|
||||
initialDate = new Date()
|
||||
}: CalendarProps) => {
|
||||
const [currentDate, setCurrentDate] = useState<Date>(initialDate);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [daysInMonth, setDaysInMonth] = useState<number[]>([]);
|
||||
const [firstDay, setFirstDay] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const days = getDaysInMonth(currentDate.getFullYear(), currentDate.getMonth());
|
||||
const firstDayOfMonth = getFirstDayOfMonth(currentDate.getFullYear(), currentDate.getMonth());
|
||||
|
||||
setDaysInMonth(Array.from({ length: days }, (_, i) => i + 1));
|
||||
setFirstDay(firstDayOfMonth);
|
||||
}, [currentDate]);
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
setCurrentDate(prevDate => {
|
||||
const newDate = new Date(prevDate);
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
return newDate;
|
||||
});
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
setCurrentDate(prevDate => {
|
||||
const newDate = new Date(prevDate);
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
return newDate;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDateClick = (day: number) => {
|
||||
const newDate = new Date(currentDate);
|
||||
newDate.setDate(day);
|
||||
setSelectedDate(newDate);
|
||||
|
||||
if (onDateClick) {
|
||||
onDateClick(newDate);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEvents = selectedDate
|
||||
? events.filter(event => formatDate(event.date) === formatDate(selectedDate))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={styles.calendar}>
|
||||
<CalendarHeader
|
||||
currentDate={currentDate}
|
||||
onPrevMonth={handlePrevMonth}
|
||||
onNextMonth={handleNextMonth}
|
||||
/>
|
||||
|
||||
<div className={styles.calendarContainer}>
|
||||
<CalendarGrid
|
||||
daysInMonth={daysInMonth}
|
||||
firstDay={firstDay}
|
||||
currentDate={currentDate}
|
||||
selectedDate={selectedDate}
|
||||
events={events}
|
||||
onDateClick={handleDateClick}
|
||||
/>
|
||||
|
||||
{selectedDate && (
|
||||
<CalendarEventsList
|
||||
events={filteredEvents}
|
||||
date={selectedDate}
|
||||
onEventClick={onEventClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,3 +1,13 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
|
||||
@ -4,13 +4,16 @@ import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import './components/ui/FontAwesomeIcons';
|
||||
import { UserProvider } from './lib/context';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<UserProvider>
|
||||
<App />
|
||||
</UserProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
101
frontend/src/lib/api/client.ts
Normal file
101
frontend/src/lib/api/client.ts
Normal file
@ -0,0 +1,101 @@
|
||||
export type ApiResponse<T> = {
|
||||
data: T;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type RequestOptions = {
|
||||
params?: Record<string, string | number | boolean>;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:9010';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function get<T>(endpoint: string, options?: RequestOptions): Promise<T> {
|
||||
const url = buildUrl(endpoint, options?.params);
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
return handleResponse<T>(response);
|
||||
}
|
||||
|
||||
export async function post<T>(endpoint: string, data: unknown, options?: RequestOptions): Promise<T> {
|
||||
const url = buildUrl(endpoint, options?.params);
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
return handleResponse<T>(response);
|
||||
}
|
||||
|
||||
export async function put<T>(endpoint: string, data: unknown, options?: RequestOptions): Promise<T> {
|
||||
const url = buildUrl(endpoint, options?.params);
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
return handleResponse<T>(response);
|
||||
}
|
||||
|
||||
export async function del<T>(endpoint: string, options?: RequestOptions): Promise<T> {
|
||||
const url = buildUrl(endpoint, options?.params);
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
return handleResponse<T>(response);
|
||||
}
|
||||
|
||||
function buildUrl(endpoint: string, params?: Record<string, string | number | boolean>): string {
|
||||
const url = new URL(`${API_BASE_URL}${endpoint}`);
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new ApiError(errorText || `Request failed with status ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json() as T;
|
||||
} catch (error) {
|
||||
throw new ApiError('Failed to parse response', 500);
|
||||
}
|
||||
}
|
||||
65
frontend/src/lib/api/endpoints.ts
Normal file
65
frontend/src/lib/api/endpoints.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { get, post, put, del } from './client';
|
||||
|
||||
// Define your data types here
|
||||
export type User = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type CreateUserRequest = {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
// User endpoints
|
||||
export const getUser = () => get<User>('/api/user');
|
||||
|
||||
// Event types
|
||||
export type Event = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
};
|
||||
|
||||
export type CreateEventRequest = {
|
||||
title: string;
|
||||
description?: string;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay?: boolean;
|
||||
};
|
||||
|
||||
// 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 deleteEvent = (id: string) => del<void>(`/api/events/${id}`);
|
||||
|
||||
// Calendar types
|
||||
export type Calendar = {
|
||||
events: Event[];
|
||||
};
|
||||
|
||||
// Calendar endpoints
|
||||
export const getCalendar = (start?: string, end?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (start) params.append('start', start);
|
||||
if (end) params.append('end', end);
|
||||
|
||||
const queryString = params.toString();
|
||||
return get<Calendar>(`/api/calendar${queryString ? `?${queryString}` : ''}`);
|
||||
};
|
||||
|
||||
// Health check endpoint
|
||||
export type PingResponse = {
|
||||
status: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export const ping = () => get<PingResponse>('/api/ping');
|
||||
3
frontend/src/lib/api/index.ts
Normal file
3
frontend/src/lib/api/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './client';
|
||||
export * from './endpoints';
|
||||
export * from './useApi';
|
||||
67
frontend/src/lib/api/useApi.ts
Normal file
67
frontend/src/lib/api/useApi.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { ApiError } from './client';
|
||||
|
||||
export type ApiState<T> = {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type ApiHook<T, P extends unknown[]> = [
|
||||
(...args: P) => Promise<void>,
|
||||
ApiState<T>
|
||||
];
|
||||
|
||||
export function useApi<T, P extends unknown[]>(
|
||||
apiFunction: (...args: P) => Promise<T>,
|
||||
initialData: T | null = null
|
||||
): ApiHook<T, P> {
|
||||
const [state, setState] = useState<ApiState<T>>({
|
||||
data: initialData,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const execute = useCallback(
|
||||
async (...args: P) => {
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const data = await apiFunction(...args);
|
||||
setState({ data, loading: false, error: null });
|
||||
} catch (err) {
|
||||
const error = err instanceof ApiError
|
||||
? err.message
|
||||
: 'An unexpected error occurred';
|
||||
|
||||
console.error(err);
|
||||
|
||||
setState((prev) => ({ ...prev, loading: false, error }));
|
||||
}
|
||||
},
|
||||
[apiFunction]
|
||||
);
|
||||
|
||||
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>
|
||||
// );
|
||||
// }
|
||||
140
frontend/src/lib/calendar/CalendarService.ts
Normal file
140
frontend/src/lib/calendar/CalendarService.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { CalendarEvent, RecurrenceRule } from '../models/CalendarEvent';
|
||||
|
||||
export class CalendarService {
|
||||
static getEventsForMonth(events: CalendarEvent[], year: number, month: number): CalendarEvent[] {
|
||||
const startOfMonth = new Date(year, month, 1);
|
||||
const endOfMonth = new Date(year, month + 1, 0);
|
||||
|
||||
return events.filter(event =>
|
||||
(event.start <= endOfMonth && event.end >= startOfMonth) ||
|
||||
this.isRecurringInRange(event, startOfMonth, endOfMonth)
|
||||
);
|
||||
}
|
||||
|
||||
static getEventsForDay(events: CalendarEvent[], date: Date): CalendarEvent[] {
|
||||
const dayStart = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const dayEnd = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59);
|
||||
|
||||
return events.filter(event =>
|
||||
(event.start <= dayEnd && event.end >= dayStart) ||
|
||||
this.isRecurringInRange(event, dayStart, dayEnd)
|
||||
);
|
||||
}
|
||||
|
||||
static getEventsForWeek(events: CalendarEvent[], date: Date): CalendarEvent[] {
|
||||
const dayOfWeek = date.getDay();
|
||||
const weekStart = new Date(date);
|
||||
weekStart.setDate(date.getDate() - dayOfWeek);
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
weekEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
return events.filter(event =>
|
||||
(event.start <= weekEnd && event.end >= weekStart) ||
|
||||
this.isRecurringInRange(event, weekStart, weekEnd)
|
||||
);
|
||||
}
|
||||
|
||||
static isRecurringInRange(event: CalendarEvent, rangeStart: Date, rangeEnd: Date): boolean {
|
||||
if (!event.recurrence) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.recurrence.until && event.recurrence.until < rangeStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Simplified recurrence check - a complete implementation would be more complex
|
||||
switch (event.recurrence.frequency) {
|
||||
case 'daily':
|
||||
return this.checkDailyRecurrence(event, rangeStart, rangeEnd);
|
||||
case 'weekly':
|
||||
return this.checkWeeklyRecurrence(event, rangeStart, rangeEnd);
|
||||
case 'monthly':
|
||||
return this.checkMonthlyRecurrence(event, rangeStart, rangeEnd);
|
||||
case 'yearly':
|
||||
return this.checkYearlyRecurrence(event, rangeStart, rangeEnd);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static checkDailyRecurrence(event: CalendarEvent, rangeStart: Date, rangeEnd: Date): boolean {
|
||||
const interval = event.recurrence?.interval || 1;
|
||||
const eventDuration = event.end.getTime() - event.start.getTime();
|
||||
|
||||
// Check if any occurrence falls within range
|
||||
let currentDate = new Date(event.start);
|
||||
const rangeEndTime = rangeEnd.getTime();
|
||||
|
||||
while (currentDate.getTime() <= rangeEndTime) {
|
||||
if (currentDate.getTime() + eventDuration >= rangeStart.getTime()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + interval);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static checkWeeklyRecurrence(event: CalendarEvent, rangeStart: Date, rangeEnd: Date): boolean {
|
||||
const interval = event.recurrence?.interval || 1;
|
||||
const eventDuration = event.end.getTime() - event.start.getTime();
|
||||
|
||||
// Check if any occurrence falls within range
|
||||
let currentDate = new Date(event.start);
|
||||
const rangeEndTime = rangeEnd.getTime();
|
||||
|
||||
while (currentDate.getTime() <= rangeEndTime) {
|
||||
if (currentDate.getTime() + eventDuration >= rangeStart.getTime()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + (interval * 7));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static checkMonthlyRecurrence(event: CalendarEvent, rangeStart: Date, rangeEnd: Date): boolean {
|
||||
const interval = event.recurrence?.interval || 1;
|
||||
const eventDay = event.start.getDate();
|
||||
|
||||
// Simple check - a complete implementation would handle more complex patterns
|
||||
for (let year = rangeStart.getFullYear(); year <= rangeEnd.getFullYear(); year++) {
|
||||
const startMonth = year === rangeStart.getFullYear() ? rangeStart.getMonth() : 0;
|
||||
const endMonth = year === rangeEnd.getFullYear() ? rangeEnd.getMonth() : 11;
|
||||
|
||||
for (let month = startMonth; month <= endMonth; month++) {
|
||||
if ((month - event.start.getMonth()) % interval === 0) {
|
||||
const checkDate = new Date(year, month, eventDay);
|
||||
if (checkDate >= rangeStart && checkDate <= rangeEnd) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static checkYearlyRecurrence(event: CalendarEvent, rangeStart: Date, rangeEnd: Date): boolean {
|
||||
const interval = event.recurrence?.interval || 1;
|
||||
const eventMonth = event.start.getMonth();
|
||||
const eventDay = event.start.getDate();
|
||||
|
||||
for (let year = rangeStart.getFullYear(); year <= rangeEnd.getFullYear(); year++) {
|
||||
if ((year - event.start.getFullYear()) % interval === 0) {
|
||||
const checkDate = new Date(year, eventMonth, eventDay);
|
||||
if (checkDate >= rangeStart && checkDate <= rangeEnd) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
57
frontend/src/lib/context/UserContext.tsx
Normal file
57
frontend/src/lib/context/UserContext.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { User, getUser } from '../api/endpoints';
|
||||
|
||||
type UserContextType = {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
};
|
||||
|
||||
const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||
|
||||
export const UserProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const userData = await getUser();
|
||||
setUser(userData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch user'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UserContext.Provider
|
||||
value={{
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchUser
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUser = (): UserContextType => {
|
||||
const context = useContext(UserContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useUser must be used within a UserProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
1
frontend/src/lib/context/index.ts
Normal file
1
frontend/src/lib/context/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './UserContext';
|
||||
69
frontend/src/lib/date.ts
Normal file
69
frontend/src/lib/date.ts
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Get the number of days in a month
|
||||
*/
|
||||
export const getDaysInMonth = (year: number, month: number): number => {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the day of the week for the first day of a month (0 = Sunday, 6 = Saturday)
|
||||
*/
|
||||
export const getFirstDayOfMonth = (year: number, month: number): number => {
|
||||
return new Date(year, month, 1).getDay();
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a date as YYYY-MM-DD for comparison
|
||||
*/
|
||||
export const formatDate = (date: Date): string => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an array of dates for the current week containing the given date
|
||||
*/
|
||||
export const getDatesForWeek = (date: Date): Date[] => {
|
||||
const day = date.getDay();
|
||||
const diff = date.getDate() - day;
|
||||
|
||||
return Array(7)
|
||||
.fill(null)
|
||||
.map((_, index) => {
|
||||
const newDate = new Date(date);
|
||||
newDate.setDate(diff + index);
|
||||
return newDate;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the list of dates for a given month
|
||||
*/
|
||||
export const getDatesForMonth = (year: number, month: number): Date[] => {
|
||||
const daysInMonth = getDaysInMonth(year, month);
|
||||
|
||||
return Array(daysInMonth)
|
||||
.fill(null)
|
||||
.map((_, index) => new Date(year, month, index + 1));
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if two dates are the same day
|
||||
*/
|
||||
export const isSameDay = (date1: Date, date2: Date): boolean => {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a date is today
|
||||
*/
|
||||
export const isToday = (date: Date): boolean => {
|
||||
return isSameDay(date, new Date());
|
||||
};
|
||||
11
frontend/src/lib/index.ts
Normal file
11
frontend/src/lib/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// Models
|
||||
export * from './models/CalendarEvent';
|
||||
|
||||
// Calendar utilities
|
||||
export * from './calendar/CalendarService';
|
||||
|
||||
// Date utilities
|
||||
export * from './utils/DateUtils';
|
||||
|
||||
// API client
|
||||
export * as api from './api';
|
||||
37
frontend/src/lib/models/CalendarEvent.ts
Normal file
37
frontend/src/lib/models/CalendarEvent.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay?: boolean;
|
||||
color?: string;
|
||||
location?: string;
|
||||
organizer?: string;
|
||||
attendees?: Attendee[];
|
||||
recurrence?: RecurrenceRule;
|
||||
}
|
||||
|
||||
export interface Attendee {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
status: 'accepted' | 'declined' | 'tentative' | 'pending';
|
||||
}
|
||||
|
||||
export interface RecurrenceRule {
|
||||
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
interval?: number;
|
||||
count?: number;
|
||||
until?: Date;
|
||||
byDay?: string[];
|
||||
byMonth?: number[];
|
||||
byMonthDay?: number[];
|
||||
exceptions?: Date[];
|
||||
}
|
||||
|
||||
export enum EventStatus {
|
||||
CONFIRMED = 'confirmed',
|
||||
TENTATIVE = 'tentative',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
89
frontend/src/lib/utils/DateUtils.ts
Normal file
89
frontend/src/lib/utils/DateUtils.ts
Normal file
@ -0,0 +1,89 @@
|
||||
export class DateUtils {
|
||||
static getWeekNumber(date: Date): number {
|
||||
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
|
||||
const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000;
|
||||
return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
|
||||
}
|
||||
|
||||
static getMonthName(month: number): string {
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
return months[month];
|
||||
}
|
||||
|
||||
static getShortMonthName(month: number): string {
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
];
|
||||
return months[month];
|
||||
}
|
||||
|
||||
static getDayName(day: number): string {
|
||||
const days = [
|
||||
'Sunday', 'Monday', 'Tuesday', 'Wednesday',
|
||||
'Thursday', 'Friday', 'Saturday'
|
||||
];
|
||||
return days[day];
|
||||
}
|
||||
|
||||
static getShortDayName(day: number): string {
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return days[day];
|
||||
}
|
||||
|
||||
static areDatesEqual(date1: Date, date2: Date): boolean {
|
||||
return date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate();
|
||||
}
|
||||
|
||||
static areDateTimesEqual(date1: Date, date2: Date): boolean {
|
||||
return date1.getTime() === date2.getTime();
|
||||
}
|
||||
|
||||
static formatDate(date: Date, format: string = 'YYYY-MM-DD'): string {
|
||||
const year = date.getFullYear().toString();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0');
|
||||
|
||||
return format
|
||||
.replace('YYYY', year)
|
||||
.replace('MM', month)
|
||||
.replace('DD', day)
|
||||
.replace('HH', hours)
|
||||
.replace('mm', minutes)
|
||||
.replace('ss', seconds);
|
||||
}
|
||||
|
||||
static getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
static getFirstDayOfMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 1).getDay();
|
||||
}
|
||||
|
||||
static addDays(date: Date, days: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
static addMonths(date: Date, months: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setMonth(result.getMonth() + months);
|
||||
return result;
|
||||
}
|
||||
|
||||
static addYears(date: Date, years: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setFullYear(result.getFullYear() + years);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
162
frontend/src/pages/home/Home.css
Normal file
162
frontend/src/pages/home/Home.css
Normal file
@ -0,0 +1,162 @@
|
||||
.home-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem;
|
||||
background-color: #ffffff;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.875rem;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.events-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tomorrow-events {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tomorrow-events .event-item {
|
||||
min-width: 200px;
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.week-events {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
background-color: #ec6a5e;
|
||||
border-radius: 1rem;
|
||||
padding: 1.125rem 1.25rem;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(236, 106, 94, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.event-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(236, 106, 94, 0.4);
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.no-events {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
font-size: 1.125rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ec6a5e;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.greeting {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.events-section {
|
||||
margin-bottom: 2rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tomorrow-events {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tomorrow-events .event-item {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.home-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.tomorrow-events .event-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.week-events {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure scrolling works properly within the TabView */
|
||||
@media (max-height: 700px) {
|
||||
.home-container {
|
||||
padding-bottom: 6.25rem;
|
||||
}
|
||||
}
|
||||
@ -1,53 +1,126 @@
|
||||
import React from 'react';
|
||||
import { FontAwesomeIcon } from '../../components/ui/FontAwesomeIcons';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getEvents, Event } from '../../lib/api/endpoints';
|
||||
import './Home.css';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const [todayEvents, setTodayEvents] = useState<Event[]>([]);
|
||||
const [tomorrowEvents, setTomorrowEvents] = useState<Event[]>([]);
|
||||
const [weekEvents, setWeekEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userName, setUserName] = useState('John');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getEvents();
|
||||
|
||||
// Get today, tomorrow, and this week's date ranges
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const weekStart = new Date(today);
|
||||
const weekEnd = new Date(today);
|
||||
weekEnd.setDate(weekEnd.getDate() + 7);
|
||||
|
||||
// Filter events for each time period
|
||||
const todayEvts = response.filter(event => {
|
||||
const eventDate = new Date(event.start);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
return eventDate.getTime() === today.getTime();
|
||||
});
|
||||
|
||||
const tomorrowEvts = response.filter(event => {
|
||||
const eventDate = new Date(event.start);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
return eventDate.getTime() === tomorrow.getTime();
|
||||
});
|
||||
|
||||
const weekEvts = response.filter(event => {
|
||||
const eventDate = new Date(event.start);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
return eventDate > today && eventDate <= weekEnd &&
|
||||
eventDate.getTime() !== today.getTime() &&
|
||||
eventDate.getTime() !== tomorrow.getTime();
|
||||
});
|
||||
|
||||
setTodayEvents(todayEvts);
|
||||
setTomorrowEvents(tomorrowEvts);
|
||||
setWeekEvents(weekEvts);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError('Failed to load events');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
const EventItem = ({ event }: { event: Event }) => (
|
||||
<div className="event-item">
|
||||
<div className="event-title">{event.title}</div>
|
||||
<div className="event-time">
|
||||
{new Date(event.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{!event.allDay && event.end && ` - ${new Date(event.end).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
||||
{event.allDay && ' (All day)'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading events...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="error">{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
<header className="home-header">
|
||||
<h1>Welcome to Our Application</h1>
|
||||
<p>Your centralized dashboard for all features</p>
|
||||
</header>
|
||||
<h1 className="greeting">Hallo {userName}!</h1>
|
||||
|
||||
<section className="features-section">
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<FontAwesomeIcon icon={['fas', 'plus']} />
|
||||
</div>
|
||||
<h2>Create New</h2>
|
||||
<p>Start a new project or task</p>
|
||||
</div>
|
||||
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<FontAwesomeIcon icon={['far', 'calendar']} />
|
||||
</div>
|
||||
<h2>Schedule</h2>
|
||||
<p>Manage your upcoming events</p>
|
||||
</div>
|
||||
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<FontAwesomeIcon icon={['fas', 'edit']} />
|
||||
</div>
|
||||
<h2>Edit</h2>
|
||||
<p>Modify your existing content</p>
|
||||
<section className="events-section">
|
||||
<h2 className="section-title">Heute</h2>
|
||||
<div className="events-list">
|
||||
{todayEvents.length > 0 ? (
|
||||
todayEvents.map(event => (
|
||||
<EventItem key={event.id} event={event} />
|
||||
))
|
||||
) : (
|
||||
<div className="no-events">Keine Termine für heute</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="quick-links">
|
||||
<h2>Quick Links</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#dashboard">Dashboard</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#profile">Profile Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help">Help & Support</a>
|
||||
</li>
|
||||
</ul>
|
||||
<section className="events-section">
|
||||
<h2 className="section-title">Morgen</h2>
|
||||
<div className="events-list tomorrow-events">
|
||||
{tomorrowEvents.length > 0 ? (
|
||||
tomorrowEvents.map(event => (
|
||||
<EventItem key={event.id} event={event} />
|
||||
))
|
||||
) : (
|
||||
<div className="no-events">Keine Termine für morgen</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="events-section">
|
||||
<h2 className="section-title">Diese Woche</h2>
|
||||
<div className="events-list week-events">
|
||||
{weekEvents.length > 0 ? (
|
||||
weekEvents.map(event => (
|
||||
<EventItem key={event.id} event={event} />
|
||||
))
|
||||
) : (
|
||||
<div className="no-events">Keine Termine für diese Woche</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
41
frontend/src/pages/profile/Profile.tsx
Normal file
41
frontend/src/pages/profile/Profile.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { useUser } from '../../lib/context';
|
||||
|
||||
const Profile = () => {
|
||||
const { user, loading, error, refetch } = useUser();
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading user data...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<p>Error loading user data: {error.message}</p>
|
||||
<button onClick={refetch}>Try Again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div>
|
||||
<p>No user data available</p>
|
||||
<button onClick={refetch}>Refresh</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Profile</h1>
|
||||
<div>
|
||||
<strong>Name:</strong> {user.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Email:</strong> {user.email}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
Loading…
x
Reference in New Issue
Block a user