Add details page

This commit is contained in:
Tim Lappe 2025-04-30 18:43:27 +02:00
parent ac995b1b83
commit a41b9a9227
29 changed files with 1478 additions and 778 deletions

View File

@ -28,3 +28,5 @@ APP_SECRET=71bf50bfb778d456b3a376ff60d5dcd8
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://postgres:postgres@calendi-postgres.test:5432/postgres?serverVersion=16&charset=utf8" DATABASE_URL="postgresql://postgres:postgres@calendi-postgres.test:5432/postgres?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
OPENAI_API_KEY="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"

View File

@ -14,21 +14,22 @@
"nelmio/api-doc-bundle": "^5.0", "nelmio/api-doc-bundle": "^5.0",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1", "phpstan/phpdoc-parser": "^2.1",
"symfony/asset": "6.4.*", "prinsfrank/standards": "^3.12",
"symfony/console": "6.4.*", "symfony/asset": "7.2.*",
"symfony/dotenv": "6.4.*", "symfony/console": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "6.4.*", "symfony/framework-bundle": "7.2.*",
"symfony/http-client": "6.4.*", "symfony/http-client": "7.2.*",
"symfony/monolog-bundle": "^3.10", "symfony/monolog-bundle": "^3.10",
"symfony/property-access": "6.4.*", "symfony/property-access": "7.2.*",
"symfony/property-info": "6.4.*", "symfony/property-info": "7.2.*",
"symfony/runtime": "6.4.*", "symfony/runtime": "7.2.*",
"symfony/serializer": "6.4.*", "symfony/serializer": "7.2.*",
"symfony/twig-bundle": "6.4.*", "symfony/twig-bundle": "7.2.*",
"symfony/uid": "6.4.*", "symfony/uid": "7.2.*",
"symfony/validator": "6.4.*", "symfony/validator": "7.2.*",
"symfony/yaml": "6.4.*" "symfony/yaml": "7.2.*"
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {
@ -77,7 +78,7 @@
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": false,
"require": "6.4.*" "require": "7.2.*"
} }
}, },
"require-dev": { "require-dev": {
@ -85,7 +86,7 @@
"phpstan/phpstan": "^2.1", "phpstan/phpstan": "^2.1",
"phpstan/phpstan-symfony": "^2.0", "phpstan/phpstan-symfony": "^2.0",
"symfony/maker-bundle": "^1.62", "symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "6.4.*", "symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "6.4.*" "symfony/web-profiler-bundle": "7.2.*"
} }
} }

1397
backend/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -57,7 +57,7 @@ class GetEventsController extends AbstractController
)] )]
public function get(string $id): JsonResponse public function get(string $id): JsonResponse
{ {
$events = $this->readEventsHandler->handle(new ReadEvents((int)$id)); $events = $this->readEventsHandler->handle(new ReadEvents($id));
if (count($events) === 0) { if (count($events) === 0) {
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND); return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ services:
- proxy - proxy
postgres: postgres:
hostname: calendi-postgres hostname: calendi-postgres.test
image: postgres:15 image: postgres:15
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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