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="postgresql://postgres:postgres@calendi-postgres.test:5432/postgres?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
OPENAI_API_KEY="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"

View File

@ -14,21 +14,22 @@
"nelmio/api-doc-bundle": "^5.0",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1",
"symfony/asset": "6.4.*",
"symfony/console": "6.4.*",
"symfony/dotenv": "6.4.*",
"prinsfrank/standards": "^3.12",
"symfony/asset": "7.2.*",
"symfony/console": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "6.4.*",
"symfony/http-client": "6.4.*",
"symfony/framework-bundle": "7.2.*",
"symfony/http-client": "7.2.*",
"symfony/monolog-bundle": "^3.10",
"symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*",
"symfony/runtime": "6.4.*",
"symfony/serializer": "6.4.*",
"symfony/twig-bundle": "6.4.*",
"symfony/uid": "6.4.*",
"symfony/validator": "6.4.*",
"symfony/yaml": "6.4.*"
"symfony/property-access": "7.2.*",
"symfony/property-info": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/serializer": "7.2.*",
"symfony/twig-bundle": "7.2.*",
"symfony/uid": "7.2.*",
"symfony/validator": "7.2.*",
"symfony/yaml": "7.2.*"
},
"config": {
"allow-plugins": {
@ -77,7 +78,7 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "6.4.*"
"require": "7.2.*"
}
},
"require-dev": {
@ -85,7 +86,7 @@
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-symfony": "^2.0",
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "6.4.*",
"symfony/web-profiler-bundle": "6.4.*"
"symfony/stopwatch": "7.2.*",
"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
{
$events = $this->readEventsHandler->handle(new ReadEvents((int)$id));
$events = $this->readEventsHandler->handle(new ReadEvents($id));
if (count($events) === 0) {
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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