├── .env.example
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── conf.d
└── default.conf
├── docker-compose.yml
├── nginx.conf
├── package-lock.json
├── package.json
├── public
├── calendar.svg
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
└── src
├── App.css
├── App.js
├── App.test.js
├── caldavService.js
├── components
├── Calendar.css
├── Calendar.js
├── EditEventModal.js
├── EventDetails.js
├── EventList.js
└── EventModal.js
├── data
└── events.js
├── index.css
├── index.js
├── logo.svg
├── reportWebVitals.js
└── setupTests.js
/.env.example:
--------------------------------------------------------------------------------
1 | REACT_APP_RADICALE_USERNAME=your-username
2 | REACT_APP_RADICALE_PASSWORD=your-password
3 | REACT_APP_RADICALE_URL=radicale-calendar-url
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use an official Node.js runtime as a parent image
2 | FROM node:14 as build
3 |
4 | # Set the working directory
5 | WORKDIR /app
6 |
7 | # Copy package.json and package-lock.json
8 | COPY package*.json ./
9 |
10 | # Install dependencies
11 | RUN npm install
12 |
13 | # Copy the rest of the application code
14 | COPY . .
15 |
16 | # Pass build-time variables
17 | ARG REACT_APP_RADICALE_USERNAME
18 | ARG REACT_APP_RADICALE_PASSWORD
19 | ARG REACT_APP_RADICALE_URL
20 |
21 | # Build the React app
22 | RUN REACT_APP_RADICALE_USERNAME=$REACT_APP_RADICALE_USERNAME \
23 | REACT_APP_RADICALE_PASSWORD=$REACT_APP_RADICALE_PASSWORD \
24 | REACT_APP_RADICALE_URL=$REACT_APP_RADICALE_URL \
25 | npm run build
26 |
27 | # Use an official Nginx image to serve the static files
28 | FROM nginx:alpine
29 |
30 | # Copy the build output to the Nginx html directory
31 | COPY --from=build /app/build /usr/share/nginx/html
32 |
33 | # Copy the Nginx configuration file
34 | COPY nginx.conf /etc/nginx/nginx.conf
35 | COPY conf.d/default.conf /etc/nginx/conf.d/default.conf
36 |
37 | # Expose port 80
38 | EXPOSE 80
39 |
40 | # Start Nginx
41 | CMD ["nginx", "-g", "daemon off;"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Eidenz
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CalView (old)
2 | CalView is a React-based calendar that integrates with a Radicale CalDAV server. It allows users to view, create, edit, and delete events, all synchronized with a Radicale backend.
3 | 
4 |
5 |
6 | ## Features
7 | - View calendar events
8 | - Supports multi-day events
9 | - Create new events
10 | - Edit existing events
11 | - Delete events
12 | - Synchronization with Radicale CalDAV server
13 |
14 | ## Prerequisites
15 | - Docker and Docker Compose
16 | - A running Radicale CalDAV server with CORS headers configured
17 |
18 | ## Installation
19 | - Clone the repository:
20 |
21 | `git clone https://github.com/Eidenz/CalView.git`
22 |
23 | `cd CalView`
24 |
25 | - Create a .env file in the root directory with the following content:
26 | ```
27 | REACT_APP_RADICALE_USERNAME=your_username
28 | REACT_APP_RADICALE_PASSWORD=your_password
29 | REACT_APP_RADICALE_URL=http://your_radicale_server_url:5232/your_calendar_path/
30 | ```
31 |
32 | - Build and run the Docker container:
33 |
34 | `docker-compose build`
35 |
36 | `docker-compose up`
37 |
38 | The application should now be running at http://localhost/ (or whatever port you've specified in your Docker configuration).
39 |
40 | ## Usage
41 | - Click on a day to view events for that day
42 | - Double-click on a day to create a new event
43 | - Click on an event in the list to view its details
44 | - Use the edit and delete buttons in the event details panel to modify or remove events
45 |
46 | ## Development
47 | To run the application in development mode:
48 | Install dependencies:
49 | `npm install`
50 |
51 | Start the development server:
52 | `npm start`
53 |
54 | The application will be available at http://localhost:3000
55 |
56 | ## Building for Production
57 | To build the application for production:
58 | Run the build command:
59 | `npm run build`
60 |
61 | The production-ready files will be in the build directory
62 |
63 | ## Docker Deployment
64 | The application includes a Dockerfile and docker-compose.yml for easy deployment. To deploy:
65 | Ensure Docker and Docker Compose are installed on your system
66 | Run `docker compose up --build`
67 |
68 | ## Contributing
69 | Contributions are welcome! Please feel free to submit a Pull Request.
70 |
71 | ## License
72 | This project is licensed under the MIT License.
73 |
--------------------------------------------------------------------------------
/conf.d/default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 |
4 | add_header 'Access-Control-Allow-Origin' '*' always;
5 | add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
6 | add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
7 | add_header 'Access-Control-Allow-Credentials' 'true' always;
8 |
9 | add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, max-age=0' always;
10 | add_header 'Pragma' 'no-cache' always;
11 | add_header 'Expires' '0' always;
12 |
13 | location / {
14 | root /usr/share/nginx/html;
15 | index index.html;
16 | try_files $uri $uri/ /index.html;
17 | }
18 | }
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | app:
4 | build:
5 | context: .
6 | args:
7 | - REACT_APP_RADICALE_USERNAME=${REACT_APP_RADICALE_USERNAME}
8 | - REACT_APP_RADICALE_PASSWORD=${REACT_APP_RADICALE_PASSWORD}
9 | - REACT_APP_RADICALE_URL=${REACT_APP_RADICALE_URL}
10 | ports:
11 | - "80:80"
--------------------------------------------------------------------------------
/nginx.conf:
--------------------------------------------------------------------------------
1 | user nginx;
2 | worker_processes auto;
3 |
4 | error_log /var/log/nginx/error.log warn;
5 | pid /var/run/nginx.pid;
6 |
7 | events {
8 | worker_connections 1024;
9 | }
10 |
11 | http {
12 | include /etc/nginx/mime.types;
13 | default_type application/octet-stream;
14 |
15 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
16 | '$status $body_bytes_sent "$http_referer" '
17 | '"$http_user_agent" "$http_x_forwarded_for"';
18 |
19 | access_log /var/log/nginx/access.log main;
20 |
21 | sendfile on;
22 | #tcp_nopush on;
23 |
24 | keepalive_timeout 65;
25 |
26 | include /etc/nginx/conf.d/*.conf;
27 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "calview",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@testing-library/jest-dom": "^5.17.0",
7 | "@testing-library/react": "^13.4.0",
8 | "@testing-library/user-event": "^13.5.0",
9 | "date-fns": "^3.6.0",
10 | "dav": "^1.8.0",
11 | "ical-expander": "^3.1.0",
12 | "ical.js": "^2.0.1",
13 | "react": "^18.3.1",
14 | "react-calendar": "^5.0.0",
15 | "react-dom": "^18.3.1",
16 | "react-scripts": "5.0.1",
17 | "react-toastify": "^10.0.5",
18 | "styled-components": "^6.1.11",
19 | "web-vitals": "^2.1.4"
20 | },
21 | "scripts": {
22 | "start": "react-scripts start",
23 | "build": "react-scripts build",
24 | "test": "react-scripts test",
25 | "eject": "react-scripts eject"
26 | },
27 | "eslintConfig": {
28 | "extends": [
29 | "react-app",
30 | "react-app/jest"
31 | ]
32 | },
33 | "browserslist": {
34 | "production": [
35 | ">0.2%",
36 | "not dead",
37 | "not op_mini all"
38 | ],
39 | "development": [
40 | "last 1 chrome version",
41 | "last 1 firefox version",
42 | "last 1 safari version"
43 | ]
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/public/calendar.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eidenz/CalView/ca146b29b806584dc534b0d429239725c5249162/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 | CalView
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eidenz/CalView/ca146b29b806584dc534b0d429239725c5249162/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eidenz/CalView/ca146b29b806584dc534b0d429239725c5249162/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | background-color: #282c34;
18 | min-height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | font-size: calc(10px + 2vmin);
24 | color: white;
25 | }
26 |
27 | .App-link {
28 | color: #61dafb;
29 | }
30 |
31 | @keyframes App-logo-spin {
32 | from {
33 | transform: rotate(0deg);
34 | }
35 | to {
36 | transform: rotate(360deg);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | // src/App.js
2 | import React, { useState, useEffect, useRef } from 'react';
3 | import Calendar from './components/Calendar';
4 | import EventList from './components/EventList';
5 | import EventDetails from './components/EventDetails';
6 | import EventModal from './components/EventModal';
7 | import EditEventModal from './components/EditEventModal';
8 | import styled, { keyframes } from 'styled-components';
9 | import { fetchEvents, saveEventToRadicale, deleteEventFromRadicale, updateEventInRadicale } from './caldavService';
10 | import { ToastContainer, toast } from 'react-toastify';
11 | import 'react-toastify/dist/ReactToastify.css';
12 |
13 | const AppContainer = styled.div`
14 | display: flex;
15 | justify-content: center;
16 | align-items: center;
17 | height: 100vh;
18 | background-color: #121212;
19 | color: white;
20 | position: relative;
21 | `;
22 |
23 | const CalendarAndEventsContainer = styled.div`
24 | display: flex;
25 | align-items: stretch;
26 | width: 90%; /* Adjusted width to make the calendar appear bigger */
27 | background-color: #1e1e1e;
28 | border-radius: 8px;
29 | overflow: hidden; /* Ensure no overflow */
30 | `;
31 |
32 | const CalendarContainer = styled.div`
33 | flex: 1;
34 | padding: 20px;
35 | `;
36 |
37 | const EventListContainer = styled.div`
38 | width: 25%; /* Adjusted width */
39 | padding: 20px;
40 | background-color: #1e1e1e;
41 | border-left: 1px solid #333;
42 | border-radius: 0 8px 8px 0; /* Rounded corners on the right side */
43 | `;
44 |
45 | const EventDetailsContainer = styled.div`
46 | width: 25%; /* Adjusted width */
47 | padding: 20px 20px 5px 20px;
48 | background-color: #1e1e1e;
49 | border-right: 1px solid #333;
50 | border-radius: 8px 0 0 8px; /* Rounded corners on the left side */
51 | `;
52 |
53 | const Overlay = styled.div`
54 | position: absolute;
55 | top: 0;
56 | left: 0;
57 | width: 100%;
58 | height: 100%;
59 | background: rgba(0, 0, 0, 0.5);
60 | backdrop-filter: blur(5px);
61 | display: flex;
62 | justify-content: center;
63 | align-items: center;
64 | z-index: 10;
65 | `;
66 |
67 | const spin = keyframes`
68 | 0% { transform: rotate(0deg); }
69 | 100% { transform: rotate(360deg); }
70 | `;
71 |
72 | const Spinner = styled.div`
73 | border: 8px solid #f3f3f3;
74 | border-top: 8px solid #3498db;
75 | border-radius: 50%;
76 | width: 60px;
77 | height: 60px;
78 | animation: ${spin} 2s linear infinite;
79 | `;
80 |
81 | function App() {
82 | const [events, setEvents] = useState([]);
83 | const [selectedEvents, setSelectedEvents] = useState([]);
84 | const [activeStartDate, setActiveStartDate] = useState(new Date());
85 | const [selectedDate, setSelectedDate] = useState(new Date());
86 | const [loading, setLoading] = useState(true);
87 | const [resetPage, setResetPage] = useState(false);
88 | const [isModalOpen, setIsModalOpen] = useState(false);
89 | const [modalDate, setModalDate] = useState(null);
90 | const [isEditModalOpen, setIsEditModalOpen] = useState(false);
91 | const [eventToEdit, setEventToEdit] = useState(null);
92 | const calendarRef = useRef(null);
93 | const eventDetailsRef = useRef(null);
94 |
95 | const selectToday = (eventsData) => {
96 | const today = new Date();
97 | const todayEvents = eventsData.filter(
98 | (event) =>
99 | new Date(event.startDate).toDateString() === today.toDateString() ||
100 | isEventOngoing(event, today)
101 | );
102 | setSelectedEvents(todayEvents);
103 | setActiveStartDate(today);
104 | setSelectedDate(today);
105 | setResetPage((prev) => !prev);
106 | if (calendarRef.current && calendarRef.current.getApi) {
107 | calendarRef.current.getApi().gotoDate(today);
108 | }
109 | };
110 |
111 | useEffect(() => {
112 | const fetchAndSetEvents = async () => {
113 | setLoading(true);
114 | try {
115 | const calendarEvents = await fetchEvents();
116 | setEvents(calendarEvents);
117 | selectToday(calendarEvents); // Call selectToday with fetched events
118 | } catch (error) {
119 | console.error('Failed to fetch events:', error);
120 | toast.error('Failed to fetch events. Please try again.');
121 | } finally {
122 | setLoading(false);
123 | }
124 | };
125 |
126 | fetchAndSetEvents();
127 | }, []);
128 |
129 | useEffect(() => {
130 | const handleClickOutside = (event) => {
131 | if (
132 | calendarRef.current &&
133 | !calendarRef.current.contains(event.target) &&
134 | eventDetailsRef.current &&
135 | !eventDetailsRef.current.contains(event.target)
136 | ) {
137 | selectToday(events);
138 | }
139 | };
140 |
141 | document.addEventListener('mousedown', handleClickOutside);
142 | return () => {
143 | document.removeEventListener('mousedown', handleClickOutside);
144 | };
145 | }, [events]);
146 |
147 | const handleSelectEvent = (event) => {
148 | const eventDate = new Date(event.startDate);
149 | const relevantEvents = events.filter(
150 | (e) =>
151 | new Date(e.startDate).toDateString() === eventDate.toDateString() ||
152 | isEventOngoing(e, eventDate)
153 | );
154 | setSelectedEvents(relevantEvents);
155 | setActiveStartDate(eventDate);
156 | setSelectedDate(eventDate);
157 | setResetPage((prev) => !prev);
158 | };
159 |
160 | const isEventOngoing = (event, date) => {
161 | const eventStart = new Date(event.startDate);
162 | const eventEnd = new Date(event.endDate);
163 | return date >= eventStart && date <= eventEnd;
164 | };
165 |
166 | const handleActiveStartDateChange = ({ activeStartDate }) => {
167 | setActiveStartDate(activeStartDate);
168 | };
169 |
170 | const handleDoubleClickDay = (date) => {
171 | setModalDate(new Date(date));
172 | setIsModalOpen(true);
173 | };
174 |
175 | const handleSaveEvent = async (newEvent) => {
176 | try {
177 | const eventToSave = {
178 | ...newEvent,
179 | startDate: new Date(newEvent.startDate).toISOString(),
180 | endDate: new Date(newEvent.endDate).toISOString(),
181 | };
182 | const filename = await saveEventToRadicale(eventToSave);
183 | const updatedEvent = { ...eventToSave, id: filename };
184 |
185 | setEvents(prevEvents => [...prevEvents, updatedEvent]);
186 |
187 | const eventDate = new Date(updatedEvent.startDate);
188 | if (eventDate.toDateString() === selectedDate.toDateString()) {
189 | setSelectedEvents(prevSelectedEvents => [...prevSelectedEvents, updatedEvent]);
190 | }
191 |
192 | toast.success('Event created successfully!');
193 | } catch (error) {
194 | console.error('Failed to save event:', error);
195 | toast.error('Failed to create event. Please try again.');
196 | }
197 | };
198 |
199 | const handleDeleteEvent = async (eventToDelete) => {
200 | try {
201 | await deleteEventFromRadicale(eventToDelete.id);
202 | toast.success('Event deleted successfully!');
203 | } catch (error) {
204 | console.error('Failed to delete event from server:', error);
205 | toast.warning('It seems event is already deleted on server, deleting locally.');
206 | } finally {
207 | // Remove the event from local state regardless of server success
208 | setEvents(prevEvents => prevEvents.filter(event => event.id !== eventToDelete.id));
209 | setSelectedEvents(prevSelectedEvents => prevSelectedEvents.filter(event => event.id !== eventToDelete.id));
210 | }
211 | };
212 |
213 | const handleDateClick = (date) => {
214 | const clickedDate = new Date(date);
215 | const relevantEvents = events.filter(
216 | (event) =>
217 | new Date(event.startDate).toDateString() === clickedDate.toDateString() ||
218 | isEventOngoing(event, clickedDate)
219 | );
220 | setSelectedEvents(relevantEvents);
221 | setActiveStartDate(clickedDate);
222 | setSelectedDate(clickedDate);
223 | setResetPage((prev) => !prev);
224 | };
225 |
226 | const handleEditEvent = (event) => {
227 | setEventToEdit(event);
228 | setIsEditModalOpen(true);
229 | };
230 |
231 | const handleSaveEditedEvent = async (updatedEvent) => {
232 | try {
233 | // Delete the existing event
234 | await deleteEventFromRadicale(updatedEvent.id);
235 |
236 | // Create a new event with the updated details
237 | const newEvent = {
238 | ...updatedEvent,
239 | id: undefined, // Ensure a new ID is generated
240 | startDate: new Date(updatedEvent.startDate).toISOString(),
241 | endDate: new Date(updatedEvent.endDate).toISOString(),
242 | };
243 | const newEventId = await saveEventToRadicale(newEvent);
244 | const updatedEventWithNewId = { ...newEvent, id: newEventId };
245 |
246 | // Update the events list
247 | setEvents(prevEvents => prevEvents.map(event =>
248 | event.id === updatedEvent.id ? updatedEventWithNewId : event
249 | ));
250 | setSelectedEvents(prevSelectedEvents => prevSelectedEvents.map(event =>
251 | event.id === updatedEvent.id ? updatedEventWithNewId : event
252 | ));
253 | toast.success('Event updated successfully!');
254 | setIsEditModalOpen(false);
255 | } catch (error) {
256 | console.error('Failed to update event:', error);
257 | toast.error('Failed to update event. Please try again.');
258 | }
259 | };
260 |
261 | return (
262 |
263 | {loading && (
264 |
265 |
266 |
267 | )}
268 |
269 |
270 |
276 |
277 |
278 |
288 |
289 |
290 |
291 |
292 |
293 | setIsModalOpen(false)}
296 | onSave={handleSaveEvent}
297 | initialDate={modalDate}
298 | />
299 | setIsEditModalOpen(false)}
302 | onSave={handleSaveEditedEvent}
303 | event={eventToEdit}
304 | />
305 |
316 |
317 | );
318 | }
319 |
320 | export default App;
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import App from './App';
3 |
4 | test('renders learn react link', () => {
5 | render();
6 | const linkElement = screen.getByText(/learn react/i);
7 | expect(linkElement).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/src/caldavService.js:
--------------------------------------------------------------------------------
1 | // src/caldavService.js
2 | import IcalExpander from 'ical-expander';
3 |
4 | const username = process.env.REACT_APP_RADICALE_USERNAME;
5 | const password = process.env.REACT_APP_RADICALE_PASSWORD;
6 | const serverUrl = process.env.REACT_APP_RADICALE_URL;
7 |
8 | if (!username || !password || !serverUrl) {
9 | throw new Error("Missing environment variables for Radicale server credentials");
10 | }
11 |
12 | const fetchEvents = async () => {
13 | try {
14 | const response = await fetch(serverUrl, {
15 | headers: {
16 | 'Authorization': 'Basic ' + btoa(username+':'+password),
17 | }
18 | });
19 |
20 | if (!response.ok) {
21 | throw new Error('Network response was not ok');
22 | }
23 |
24 | const icsData = await response.text();
25 | const icalExpander = new IcalExpander({ ics: icsData, maxIterations: 1000 });
26 | const events = icalExpander.all();
27 |
28 | const calendarEvents = events.events.map(event => ({
29 | id: event.uid,
30 | title: event.summary,
31 | startDate: event.startDate.toJSDate().toISOString(),
32 | endDate: event.endDate.toJSDate().toISOString(),
33 | description: event.description || '',
34 | etag: event.etag || '', // Include the ETag if available
35 | }));
36 |
37 | return calendarEvents;
38 | } catch (error) {
39 | console.error('Error fetching events:', error);
40 | throw error;
41 | }
42 | };
43 |
44 | const generateUUID = () => {
45 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
46 | const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
47 | return v.toString(16);
48 | });
49 | };
50 |
51 | const saveEventToRadicale = async (event) => {
52 | const eventId = event.id || generateUUID();
53 | const filename = `${eventId}.ics`;
54 | const url = `${serverUrl}${filename}`;
55 |
56 | const icalString = `BEGIN:VCALENDAR
57 | VERSION:2.0
58 | PRODID:-//hacksw/handcal//NONSGML v1.0//EN
59 | BEGIN:VEVENT
60 | UID:${eventId}
61 | DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'}
62 | DTSTART:${new Date(event.startDate).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'}
63 | DTEND:${new Date(event.endDate).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'}
64 | SUMMARY:${event.title || ''}
65 | DESCRIPTION:${event.description || ''}
66 | END:VEVENT
67 | END:VCALENDAR`;
68 |
69 | try {
70 | const response = await fetch(url, {
71 | method: 'PUT',
72 | headers: {
73 | 'Content-Type': 'text/calendar; charset=utf-8',
74 | 'Authorization': 'Basic ' + btoa(username + ':' + password)
75 | },
76 | body: icalString
77 | });
78 |
79 | if (!response.ok) {
80 | throw new Error(`HTTP error! status: ${response.status}`);
81 | }
82 |
83 | console.log('Event created successfully');
84 | return filename; // Return the full filename
85 | } catch (error) {
86 | console.error('Error creating event:', error);
87 | throw error;
88 | }
89 | };
90 |
91 | const deleteEventFromRadicale = async (eventId) => {
92 | const url = `${serverUrl}${eventId}.ics`;
93 |
94 | try {
95 | const response = await fetch(url, {
96 | method: 'DELETE',
97 | headers: {
98 | 'Authorization': 'Basic ' + btoa(`${username}:${password}`),
99 | },
100 | });
101 |
102 | if (!response.ok) {
103 | throw new Error(`HTTP error! status: ${response.status}`);
104 | }
105 |
106 | console.log('Event deleted successfully from server');
107 | } catch (error) {
108 | console.error('Error deleting event from server:', error);
109 | throw error;
110 | }
111 | };
112 |
113 | export { fetchEvents, saveEventToRadicale, deleteEventFromRadicale };
--------------------------------------------------------------------------------
/src/components/Calendar.css:
--------------------------------------------------------------------------------
1 | /* src/components/Calendar.css */
2 | .react-calendar {
3 | width: 100%;
4 | max-width: 800px;
5 | background-color: #1a1a1a;
6 | border: none;
7 | color: white;
8 | font-family: Arial, sans-serif;
9 | padding: 1rem;
10 | border-radius: 8px;
11 | }
12 |
13 | .react-calendar__navigation {
14 | display: flex;
15 | justify-content: space-between;
16 | align-items: center;
17 | padding: 1rem;
18 | background-color: #1a1a1a;
19 | color: white;
20 | }
21 |
22 | .react-calendar__month-view__weekdays {
23 | text-align: center;
24 | font-weight: bold;
25 | color: #ccc;
26 | }
27 |
28 | .react-calendar__month-view__days__day {
29 | text-align: center;
30 | padding: 1rem;
31 | border-radius: 8px;
32 | cursor: pointer;
33 | aspect-ratio: 1 / 1;
34 | color: white;
35 | }
36 |
37 | .react-calendar__tile {
38 | position: relative;
39 | display: flex;
40 | flex-direction: column;
41 | justify-content: center;
42 | align-items: center;
43 | height: 100%;
44 | }
45 |
46 | .react-calendar__tile > abbr {
47 | position: relative;
48 | z-index: 1;
49 | }
50 |
51 | .react-calendar__tile--now {
52 | background: #333;
53 | color: white;
54 | }
55 |
56 | .react-calendar__tile--active {
57 | background: #3a3a3a;
58 | color: white;
59 | }
60 |
61 | .react-calendar__tile--hover {
62 | background: #444;
63 | }
64 |
65 | .react-calendar__month-view__days__day--weekend {
66 | color: white;
67 | }
68 |
69 | .event-dot {
70 | position: absolute;
71 | top: calc(50% + 12px); /* Position it 12px below the center */
72 | left: 50%;
73 | transform: translateX(-50%);
74 | width: 6px;
75 | height: 6px;
76 | background-color: red;
77 | border-radius: 50%;
78 | }
79 |
80 | .event-start,
81 | .event-middle,
82 | .event-end {
83 | position: absolute;
84 | top: calc(50% + 14px); /* Position it 12px below the center */
85 | height: 6px;
86 | height: 2px; /* Make the line thinner */
87 | background-color: red;
88 | border-radius: 50%;
89 | }
90 |
91 | .event-start {
92 | left: 50%;
93 | right: 0;
94 | }
95 |
96 | .event-middle {
97 | left: 0;
98 | right: 0;
99 | }
100 |
101 | .event-end {
102 | left: 0;
103 | right: 50%;
104 | }
105 |
106 | /* Ensure the layout doesn't change with window size */
107 | @media screen and (max-width: 600px) {
108 | .event-dot,
109 | .event-start,
110 | .event-middle,
111 | .event-end {
112 | top: calc(50% + 8px);
113 | }
114 | }
115 |
116 | .react-calendar__navigation button {
117 | min-width: 44px;
118 | background: none;
119 | border: none;
120 | color: inherit;
121 | font-size: 16px;
122 | margin-top: 8px;
123 | cursor: pointer;
124 | }
125 |
126 | .react-calendar__navigation button:disabled {
127 | background-color: #f0f0f0;
128 | }
--------------------------------------------------------------------------------
/src/components/Calendar.js:
--------------------------------------------------------------------------------
1 | // src/components/Calendar.js
2 | import React, { useRef, useEffect } from 'react';
3 | import Calendar from 'react-calendar';
4 | import 'react-calendar/dist/Calendar.css';
5 | import styled from 'styled-components';
6 | import './Calendar.css';
7 |
8 | const CalendarWrapper = styled.div`
9 | display: flex;
10 | justify-content: center;
11 | align-items: center;
12 | width: 100%;
13 | `;
14 |
15 | function CustomCalendar({ onSelectEvent, onDoubleClickDay, activeStartDate, selectedDate, setSelectedDate, events, onDateClick, onActiveStartDateChange }) {
16 | const calendarRef = useRef(null);
17 |
18 | const handleDateChange = (date) => {
19 | setSelectedDate(date);
20 | const event = events.find(event => new Date(event.date).toDateString() === date.toDateString());
21 | onSelectEvent(event || { title: 'No event', date: date, description: '' });
22 | };
23 |
24 | const handleDoubleClick = (date) => {
25 | onDoubleClickDay(date);
26 | };
27 |
28 | const handleTileDoubleClick = (dateString, event) => {
29 | event.preventDefault();
30 |
31 | // Parse the date string and set it to noon in the local timezone
32 | const [month, day, year] = dateString.split(' ');
33 | const date = new Date(year, getMonthIndex(month), parseInt(day), 12, 0, 0);
34 |
35 | handleDoubleClick(date);
36 | };
37 |
38 | // Helper function to get month index
39 | const getMonthIndex = (monthName) => {
40 | const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
41 | return months.indexOf(monthName);
42 | };
43 |
44 | const tileContent = ({ date, view }) => {
45 | if (view === 'month') {
46 | const event = events.find(event => {
47 | const eventStartDate = new Date(event.startDate);
48 | const eventEndDate = new Date(event.endDate);
49 |
50 | // Strip the time component by setting hours, minutes, seconds, and milliseconds to zero
51 | eventStartDate.setHours(0, 0, 0, 0);
52 | eventEndDate.setHours(0, 0, 0, 0);
53 | date.setHours(0, 0, 0, 0);
54 |
55 | return date >= eventStartDate && date <= eventEndDate;
56 | });
57 |
58 | if (event) {
59 | const eventStartDate = new Date(event.startDate);
60 | const eventEndDate = new Date(event.endDate);
61 |
62 | eventStartDate.setHours(0, 0, 0, 0);
63 | eventEndDate.setHours(0, 0, 0, 0);
64 |
65 | if (eventStartDate.getTime() === eventEndDate.getTime()) {
66 | // Single-day event
67 | return ;
68 | } else if (date.getTime() === eventStartDate.getTime()) {
69 | return ;
70 | } else if (date.getTime() === eventEndDate.getTime()) {
71 | return ;
72 | } else {
73 | return ;
74 | }
75 | }
76 | }
77 | return null;
78 | };
79 |
80 | useEffect(() => {
81 | const tiles = calendarRef.current.querySelectorAll('.react-calendar__tile');
82 | tiles.forEach(tile => {
83 | const dateString = tile.querySelector('abbr').getAttribute('aria-label');
84 | tile.addEventListener('dblclick', (event) => handleTileDoubleClick(dateString, event));
85 | });
86 |
87 | return () => {
88 | tiles.forEach(tile => {
89 | const dateString = tile.querySelector('abbr').getAttribute('aria-label');
90 | tile.removeEventListener('dblclick', (event) => handleTileDoubleClick(dateString, event));
91 | });
92 | };
93 | }, [selectedDate, activeStartDate, events]);
94 |
95 | return (
96 |
97 | {
99 | handleDateChange(date);
100 | onDateClick(date);
101 | }}
102 | tileContent={tileContent}
103 | activeStartDate={activeStartDate}
104 | onActiveStartDateChange={onActiveStartDateChange}
105 | value={selectedDate}
106 | />
107 |
108 | );
109 | }
110 |
111 | export default CustomCalendar;
--------------------------------------------------------------------------------
/src/components/EditEventModal.js:
--------------------------------------------------------------------------------
1 | // src/components/EditEventModal.js
2 | import React, { useState, useEffect, useRef } from 'react';
3 | import styled from 'styled-components';
4 |
5 | // Define styled components (same as in EventModal)
6 | const ModalOverlay = styled.div`
7 | position: fixed;
8 | top: 0;
9 | left: 0;
10 | width: 100%;
11 | height: 100%;
12 | background: rgba(0, 0, 0, 0.5);
13 | display: flex;
14 | justify-content: center;
15 | align-items: center;
16 | z-index: 1000;
17 | `;
18 |
19 | const ModalContent = styled.div`
20 | background: #1e1e1e;
21 | padding: 20px;
22 | border-radius: 8px;
23 | width: 400px;
24 | max-width: 90%;
25 | color: white;
26 | `;
27 |
28 | const ModalHeader = styled.div`
29 | display: flex;
30 | justify-content: space-between;
31 | align-items: center;
32 | margin-bottom: 20px;
33 | `;
34 |
35 | const ModalTitle = styled.h2`
36 | margin: 0;
37 | `;
38 |
39 | const CloseButton = styled.button`
40 | background: none;
41 | border: none;
42 | color: white;
43 | font-size: 1.5rem;
44 | cursor: pointer;
45 | `;
46 |
47 | const ModalBody = styled.div`
48 | display: flex;
49 | flex-direction: column;
50 | `;
51 |
52 | const Input = styled.input`
53 | padding: 10px;
54 | margin-bottom: 10px;
55 | border: 1px solid #333;
56 | border-radius: 4px;
57 | background: #2e2e2e;
58 | color: white;
59 | `;
60 |
61 | const TextArea = styled.textarea`
62 | padding: 10px;
63 | margin-bottom: 10px;
64 | border: 1px solid #333;
65 | border-radius: 4px;
66 | background: #2e2e2e;
67 | color: white;
68 | resize: vertical; /* Allow resizing in height but not in width */
69 | `;
70 |
71 | const Button = styled.button`
72 | padding: 10px;
73 | border: none;
74 | border-radius: 4px;
75 | background: #3498db;
76 | color: white;
77 | cursor: pointer;
78 |
79 | &:hover {
80 | background: #2980b9;
81 | }
82 | `;
83 |
84 | function EditEventModal({ isOpen, onClose, onSave, event }) {
85 | const [title, setTitle] = useState('');
86 | const [description, setDescription] = useState('');
87 | const [startDate, setStartDate] = useState('');
88 | const [startTime, setStartTime] = useState('');
89 | const [endDate, setEndDate] = useState('');
90 | const [endTime, setEndTime] = useState('');
91 |
92 | useEffect(() => {
93 | if (event && isOpen) {
94 | setTitle(event.title);
95 | setDescription(event.description);
96 | const start = new Date(event.startDate);
97 | const end = new Date(event.endDate);
98 | setStartDate(start.toISOString().split('T')[0]);
99 | setStartTime(start.toTimeString().slice(0, 5));
100 | setEndDate(end.toISOString().split('T')[0]);
101 | setEndTime(end.toTimeString().slice(0, 5));
102 | }
103 | }, [event, isOpen]);
104 |
105 | const handleSave = () => {
106 | const updatedEvent = {
107 | ...event,
108 | title,
109 | description,
110 | startDate: new Date(`${startDate}T${startTime}`).toISOString(),
111 | endDate: new Date(`${endDate}T${endTime}`).toISOString(),
112 | };
113 | onSave(updatedEvent);
114 | onClose();
115 | };
116 |
117 | if (!isOpen) return null;
118 |
119 | return (
120 |
121 |
122 |
123 | Edit Event
124 | ×
125 |
126 |
127 | setTitle(e.target.value)}
132 | />
133 | setStartDate(e.target.value)}
137 | />
138 | setStartTime(e.target.value)}
142 | />
143 | setEndDate(e.target.value)}
147 | />
148 | setEndTime(e.target.value)}
152 | />
153 |
160 |
161 |
162 | );
163 | }
164 |
165 | export default EditEventModal;
--------------------------------------------------------------------------------
/src/components/EventDetails.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import styled from 'styled-components';
3 | import { format } from 'date-fns';
4 |
5 | const DetailsContainer = styled.div`
6 | background-color: #1e1e1e;
7 | padding: 20px 20px 0px 20px; /* Bottom 0 */
8 | border-radius: 8px 0 0 8px; /* Rounded corners on the left side */
9 | height: 100%; /* Match the height of the calendar */
10 | display: flex;
11 | flex-direction: column;
12 | justify-content: ${({ isEmpty }) => (isEmpty ? 'center' : 'flex-start')};
13 | align-items: ${({ isEmpty }) => (isEmpty ? 'center' : 'flex-start')};
14 | text-align: ${({ isEmpty }) => (isEmpty ? 'center' : 'left')};
15 | word-wrap: break-word; /* Ensure the content wraps if too long */
16 | white-space: normal; /* Ensure the content wraps if too long */
17 | overflow-wrap: break-word; /* Ensure the content wraps if too long */
18 | box-sizing: border-box; /* Ensure padding and border are included in the element's total width and height */
19 | max-width: 100%; /* Ensure the container does not exceed the parent width */
20 | display: flex;
21 | flex-direction: column;
22 | height: 100%;
23 | `;
24 |
25 | const EventTitle = styled.div`
26 | font-size: 1.5rem;
27 | font-weight: bold;
28 | margin-bottom: 10px;
29 | word-wrap: break-word; /* Ensure the title wraps if too long */
30 | white-space: normal; /* Ensure the title wraps if too long */
31 | overflow-wrap: break-word; /* Ensure the title wraps if too long */
32 | max-width: 100%; /* Ensure the title does not exceed the parent width */
33 | `;
34 |
35 | const EventDate = styled.div`
36 | font-size: 1.2rem;
37 | color: #ccc;
38 | margin-bottom: 10px;
39 | `;
40 |
41 | const DescriptionItem = styled.div`
42 | margin-bottom: 10px;
43 | word-wrap: break-word; /* Ensure the description wraps if too long */
44 | white-space: normal; /* Ensure the description wraps if too long */
45 | overflow-wrap: break-word; /* Ensure the description wraps if too long */
46 | max-width: 100%; /* Ensure the description does not exceed the parent width */
47 | `;
48 |
49 | const DescriptionLabel = styled.span`
50 | font-weight: bold;
51 | color: #fff;
52 | `;
53 |
54 | const DescriptionValue = styled.span`
55 | color: #ccc;
56 | `;
57 |
58 | const DescriptionLink = styled.a`
59 | color: #3498db;
60 | text-decoration: none;
61 | word-wrap: break-word; /* Ensure the link wraps if too long */
62 | white-space: normal; /* Ensure the link wraps if too long */
63 | overflow-wrap: break-word; /* Ensure the link wraps if too long */
64 | max-width: 100%; /* Ensure the link does not exceed the parent width */
65 |
66 | &:hover {
67 | text-decoration: underline;
68 | }
69 | `;
70 |
71 | const Divider = styled.hr`
72 | width: 100%;
73 | border: 0;
74 | border-top: 1px solid #333;
75 | margin: 10px 0;
76 | box-sizing: border-box; /* Ensure padding and border are included in the element's total width and height */
77 | `;
78 |
79 | const BottomContainer = styled.div`
80 | display: flex;
81 | flex-direction: column;
82 | width: 100%;
83 | bottom: 0;
84 | `;
85 |
86 | const PaginationContainer = styled.div`
87 | display: flex;
88 | justify-content: space-between;
89 | align-items: center;
90 | width: 100%;
91 | padding: 5px 0; // Reduce padding to lower the position
92 | `;
93 |
94 | const PageCounter = styled.span`
95 | background-color: #444;
96 | color: white;
97 | padding: 4px 12px;
98 | border-radius: 12px;
99 | font-size: 1rem;
100 | font-weight: bold;
101 | `;
102 |
103 | const PaginationButton = styled.button`
104 | background: none;
105 | color: white;
106 | border: none;
107 | padding: 5px;
108 | cursor: pointer;
109 | font-size: 1.5rem;
110 |
111 | &:disabled {
112 | color: #555;
113 | cursor: not-allowed;
114 | }
115 |
116 | &:hover:not(:disabled) {
117 | color: #3498db;
118 | }
119 | `;
120 |
121 | const EventContent = styled.div`
122 | flex-grow: 1;
123 | overflow-y: auto;
124 | margin-bottom: 10px; // Add some space between content and bottom controls
125 | width: 100%;
126 | `;
127 |
128 | const BoldTime = styled.span`
129 | font-weight: bold;
130 | `;
131 |
132 | const IconContainer = styled.div`
133 | display: flex;
134 | justify-content: center;
135 | gap: 10px;
136 | `;
137 |
138 | const IconButton = styled.button`
139 | background: none;
140 | border: none;
141 | color: #3498db;
142 | cursor: pointer;
143 | font-size: 1.2rem;
144 | padding: 5px;
145 |
146 | &:hover {
147 | color: #2980b9;
148 | }
149 | `;
150 |
151 | function parseDescription(description) {
152 | const lines = description.split('\n').filter(line => line.trim() !== '');
153 | const parsedDescription = {};
154 | let currentLabel = ' '; // Default label
155 |
156 | lines.forEach((line) => {
157 | const colonIndex = line.indexOf(':');
158 | if (colonIndex !== -1) {
159 | const [label, ...value] = line.split(':');
160 | currentLabel = label.trim();
161 | if(currentLabel.includes('http')){
162 | currentLabel = ' ';
163 | parsedDescription[currentLabel] = line.trim();
164 | }
165 | else{
166 | let linevalue = value.join(':').trim();
167 | if (linevalue == 'null' || linevalue == 'undefined'){
168 | linevalue = '-';
169 | }
170 | parsedDescription[currentLabel] = linevalue;
171 | }
172 | } else if (currentLabel) {
173 | // If there's no colon, append to the current label's value
174 | let linevalue = line.trim();
175 | if (linevalue == 'null' || linevalue == 'undefined'){
176 | linevalue = '-';
177 | }
178 | parsedDescription[currentLabel] = (parsedDescription[currentLabel] || '') + ' ' + linevalue;
179 | }
180 | });
181 |
182 | return parsedDescription;
183 | }
184 |
185 | function renderDescriptionValue(value) {
186 | const urlRegex = /(https?:\/\/[^\s]+)/g;
187 | const parts = value.split(urlRegex);
188 |
189 | return parts.map((part, index) => {
190 | if (urlRegex.test(part)) {
191 | return (
192 |
193 | {part}
194 |
195 | );
196 | }
197 | return part;
198 | });
199 | }
200 |
201 | function EventDetails({ events = [], resetPage, onDeleteEvent, onEditEvent }) {
202 | const [currentPage, setCurrentPage] = useState(1);
203 | const ITEMS_PER_PAGE = 1;
204 |
205 | useEffect(() => {
206 | setCurrentPage(1); // Reset to page 1 when events change
207 | }, [resetPage, events]);
208 |
209 | const sortedEvents = events
210 | .filter(event => event.date || event.startDate) // Filter out events without any date
211 | .sort((a, b) => new Date(a.date || a.startDate) - new Date(b.date || b.startDate));
212 |
213 | const totalPages = Math.ceil(sortedEvents.length / ITEMS_PER_PAGE);
214 |
215 | const handlePreviousPage = () => {
216 | setCurrentPage((prevPage) => Math.max(prevPage - 1, 1));
217 | };
218 |
219 | const handleNextPage = () => {
220 | setCurrentPage((prevPage) => Math.min(prevPage + 1, totalPages));
221 | };
222 |
223 | const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
224 | const currentEvents = sortedEvents.slice(startIndex, startIndex + ITEMS_PER_PAGE);
225 |
226 | const isEmpty = sortedEvents.length === 0;
227 |
228 | if (isEmpty) {
229 | return (
230 |
231 | No Event
232 |
233 | );
234 | }
235 |
236 | const formatEventDate = (event) => {
237 | const startDate = event.startDate || event.date;
238 | const endDate = event.endDate;
239 |
240 | if (!startDate) return 'Date not available';
241 |
242 | const formattedStartDate = format(new Date(startDate), 'dd/MM/yyyy');
243 | const formattedStartTime = format(new Date(startDate), 'HH:mm');
244 |
245 | if (!endDate) {
246 | return (
247 | <>
248 | {formattedStartDate} {formattedStartTime}
249 | >
250 | );
251 | }
252 |
253 | const formattedEndDate = format(new Date(endDate), 'dd/MM/yyyy');
254 | const formattedEndTime = format(new Date(endDate), 'HH:mm');
255 |
256 | if (formattedStartDate === formattedEndDate) {
257 | return (
258 | <>
259 | {formattedStartDate} {formattedStartTime} - {formattedEndTime}
260 | >
261 | );
262 | }
263 |
264 | return (
265 | <>
266 | {formattedStartDate} {formattedStartTime} - {formattedEndDate} {formattedEndTime}
267 | >
268 | );
269 | };
270 |
271 | return (
272 |
273 |
274 | {currentEvents.map((event, index) => (
275 |
276 |
{event.title}
277 |
{formatEventDate(event)}
278 |
279 | {Object.entries(parseDescription(event.description)).map(([label, value]) => (
280 |
281 | {renderDescriptionValue(label)} {renderDescriptionValue(value)}
282 |
283 | ))}
284 |
285 | ))}
286 |
287 |
288 | {currentEvents.length > 0 && (
289 |
290 | onEditEvent(currentEvents[currentPage - 1])}>✏️
291 | onDeleteEvent(currentEvents[currentPage - 1])}>🗑️
292 |
293 | )}
294 |
295 |
296 | ←
297 |
298 | {currentPage}/{totalPages}
299 |
300 | →
301 |
302 |
303 |
304 |
305 | );
306 | }
307 |
308 | export default EventDetails;
--------------------------------------------------------------------------------
/src/components/EventList.js:
--------------------------------------------------------------------------------
1 | // src/components/EventList.js
2 | import React, { useState, useEffect, useRef } from 'react';
3 | import styled from 'styled-components';
4 | import { format, isSameDay } from 'date-fns';
5 |
6 | const ListContainer = styled.div`
7 | background-color: #1e1e1e;
8 | padding: 10px;
9 | height: 100%; /* Match the height of the calendar */
10 | display: flex;
11 | flex-direction: column;
12 | justify-content: space-between;
13 | `;
14 |
15 | const EventsWrapper = styled.div`
16 | overflow-y: auto; /* Allow scrolling if the list is too long */
17 | `;
18 |
19 | const EventItem = styled.div`
20 | padding: 10px;
21 | border-bottom: 1px solid #333;
22 | cursor: pointer;
23 |
24 | &:hover {
25 | background-color: #333;
26 | }
27 | `;
28 |
29 | const EventTitle = styled.div`
30 | font-size: 1.2rem;
31 | font-weight: bold;
32 | `;
33 |
34 | const EventDate = styled.div`
35 | font-size: 0.9rem;
36 | color: #ccc;
37 | `;
38 |
39 | const PaginationContainer = styled.div`
40 | display: flex;
41 | justify-content: space-between;
42 | align-items: center;
43 | margin-top: 10px;
44 | `;
45 |
46 | const PaginationButton = styled.button`
47 | background: none;
48 | color: white;
49 | border: none;
50 | padding: 5px;
51 | cursor: pointer;
52 | font-size: 1.5rem;
53 |
54 | &:disabled {
55 | color: #555;
56 | cursor: not-allowed;
57 | }
58 |
59 | &:hover:not(:disabled) {
60 | color: #3498db;
61 | }
62 | `;
63 |
64 | const PageCounter = styled.span`
65 | background-color: #444;
66 | color: white;
67 | padding: 4px 12px; /* Make the pill bigger */
68 | border-radius: 12px;
69 | font-size: 1rem;
70 | font-weight: bold; /* Make the text bold */
71 | `;
72 |
73 | const ITEMS_PER_PAGE = 5;
74 |
75 | function EventList({ onSelectEvent, events }) {
76 | const [currentPage, setCurrentPage] = useState(1);
77 | const [itemsPerPage, setItemsPerPage] = useState(ITEMS_PER_PAGE);
78 | const listContainerRef = useRef(null);
79 |
80 | useEffect(() => {
81 | const calculateItemsPerPage = () => {
82 | if (listContainerRef.current) {
83 | const containerHeight = listContainerRef.current.clientHeight;
84 | const itemHeight = 60; // Approximate height of each event item
85 | const paginationHeight = 40; // Approximate height of pagination controls
86 | const availableHeight = containerHeight - paginationHeight;
87 | const items = Math.floor(availableHeight / itemHeight) - 2;
88 | setItemsPerPage(items);
89 | }
90 | };
91 |
92 | calculateItemsPerPage();
93 | window.addEventListener('resize', calculateItemsPerPage);
94 |
95 | return () => {
96 | window.removeEventListener('resize', calculateItemsPerPage);
97 | };
98 | }, []);
99 |
100 | // Filter out past events
101 | const currentAndFutureEvents = events.filter(event => {
102 | const eventEndDate = new Date(event.endDate || event.startDate);
103 | return eventEndDate >= new Date();
104 | });
105 |
106 | const sortedEvents = currentAndFutureEvents.sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
107 | const totalPages = Math.ceil(sortedEvents.length / itemsPerPage);
108 |
109 | const handlePreviousPage = () => {
110 | setCurrentPage((prevPage) => Math.max(prevPage - 1, 1));
111 | };
112 |
113 | const handleNextPage = () => {
114 | setCurrentPage((prevPage) => Math.min(prevPage + 1, totalPages));
115 | };
116 |
117 | const startIndex = (currentPage - 1) * itemsPerPage;
118 | const currentEvents = sortedEvents.slice(startIndex, startIndex + itemsPerPage);
119 |
120 | return (
121 |
122 |
123 | {currentEvents.map((event) => (
124 | onSelectEvent(event)}>
125 | {event.title}
126 |
127 | {format(new Date(event.startDate), 'dd/MM/yyyy HH:mm')}
128 | {!isSameDay(new Date(event.startDate), new Date(event.endDate)) &&
129 | ` - ${format(new Date(event.endDate), 'dd/MM/yyyy HH:mm')}`}
130 | {isSameDay(new Date(event.startDate), new Date(event.endDate)) &&
131 | `-${format(new Date(event.endDate), 'HH:mm')}`}
132 |
133 |
134 | ))}
135 |
136 | { currentEvents.length === 0 ?
137 |
138 | No upcoming events
139 | :
140 |
141 |
142 | ←
143 |
144 | {currentPage}/{totalPages}
145 |
146 | →
147 |
148 |
149 | }
150 |
151 | );
152 | }
153 |
154 | export default EventList;
--------------------------------------------------------------------------------
/src/components/EventModal.js:
--------------------------------------------------------------------------------
1 | // src/components/EventModal.js
2 | import React, { useState, useEffect, useRef } from 'react';
3 | import styled from 'styled-components';
4 |
5 | const ModalOverlay = styled.div`
6 | position: fixed;
7 | top: 0;
8 | left: 0;
9 | width: 100%;
10 | height: 100%;
11 | background: rgba(0, 0, 0, 0.5);
12 | display: flex;
13 | justify-content: center;
14 | align-items: center;
15 | z-index: 1000;
16 | `;
17 |
18 | const ModalContent = styled.div`
19 | background: #1e1e1e;
20 | padding: 20px;
21 | border-radius: 8px;
22 | width: 400px;
23 | max-width: 90%;
24 | color: white;
25 | `;
26 |
27 | const ModalHeader = styled.div`
28 | display: flex;
29 | justify-content: space-between;
30 | align-items: center;
31 | margin-bottom: 20px;
32 | `;
33 |
34 | const ModalTitle = styled.h2`
35 | margin: 0;
36 | `;
37 |
38 | const CloseButton = styled.button`
39 | background: none;
40 | border: none;
41 | color: white;
42 | font-size: 1.5rem;
43 | cursor: pointer;
44 | `;
45 |
46 | const ModalBody = styled.div`
47 | display: flex;
48 | flex-direction: column;
49 | `;
50 |
51 | const Input = styled.input`
52 | padding: 10px;
53 | margin-bottom: 10px;
54 | border: 1px solid #333;
55 | border-radius: 4px;
56 | background: #2e2e2e;
57 | color: white;
58 | `;
59 |
60 | const TextArea = styled.textarea`
61 | padding: 10px;
62 | margin-bottom: 10px;
63 | border: 1px solid #333;
64 | border-radius: 4px;
65 | background: #2e2e2e;
66 | color: white;
67 | resize: vertical; /* Allow resizing in height but not in width */
68 | `;
69 |
70 | const Button = styled.button`
71 | padding: 10px;
72 | border: none;
73 | border-radius: 4px;
74 | background: #3498db;
75 | color: white;
76 | cursor: pointer;
77 |
78 | &:hover {
79 | background: #2980b9;
80 | }
81 | `;
82 |
83 | function EventModal({ isOpen, onClose, onSave, initialDate }) {
84 | const [title, setTitle] = useState('');
85 | const [description, setDescription] = useState('');
86 | const [startDate, setStartDate] = useState('');
87 | const [startTime, setStartTime] = useState('');
88 | const [endDate, setEndDate] = useState('');
89 | const [endTime, setEndTime] = useState('');
90 | const modalRef = useRef(null);
91 |
92 | useEffect(() => {
93 | if (initialDate && isOpen) {
94 | const formattedDate = initialDate.toISOString().split('T')[0];
95 | setStartDate(formattedDate);
96 | setEndDate(formattedDate);
97 |
98 | const hours = initialDate.getHours().toString().padStart(2, '0');
99 | const minutes = initialDate.getMinutes().toString().padStart(2, '0');
100 | setStartTime(`${hours}:${minutes}`);
101 |
102 | // Set end time to 1 hour later
103 | const endTime = new Date(initialDate.getTime() + 60 * 60 * 1000);
104 | const endHours = endTime.getHours().toString().padStart(2, '0');
105 | const endMinutes = endTime.getMinutes().toString().padStart(2, '0');
106 | setEndTime(`${endHours}:${endMinutes}`);
107 | }
108 | }, [initialDate, isOpen]);
109 |
110 | const handleSave = () => {
111 | try {
112 | const startDateTime = new Date(`${startDate}T${startTime}`);
113 | const endDateTime = new Date(`${endDate}T${endTime}`);
114 | if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) {
115 | throw new Error('Invalid date or time');
116 | }
117 | onSave({ title, description, startDate: startDateTime, endDate: endDateTime });
118 | onClose();
119 | } catch (error) {
120 | console.error('Error saving event:', error);
121 | alert('Invalid date or time. Please check your inputs.');
122 | }
123 | };
124 |
125 | const handleClickOutside = (event) => {
126 | if (modalRef.current && !modalRef.current.contains(event.target)) {
127 | onClose();
128 | }
129 | };
130 |
131 | useEffect(() => {
132 | if (isOpen) {
133 | document.addEventListener('mousedown', handleClickOutside);
134 | } else {
135 | document.removeEventListener('mousedown', handleClickOutside);
136 | }
137 |
138 | return () => {
139 | document.removeEventListener('mousedown', handleClickOutside);
140 | };
141 | }, [isOpen]);
142 |
143 | if (!isOpen) return null;
144 |
145 | return (
146 |
147 |
148 |
149 | Create Event
150 | ×
151 |
152 |
153 | setTitle(e.target.value)}
158 | />
159 | setStartDate(e.target.value)}
163 | />
164 | setStartTime(e.target.value)}
168 | />
169 | setEndDate(e.target.value)}
173 | />
174 | setEndTime(e.target.value)}
178 | />
179 |
186 |
187 |
188 | );
189 | }
190 |
191 | export default EventModal;
--------------------------------------------------------------------------------
/src/data/events.js:
--------------------------------------------------------------------------------
1 | // src/data/events.js
2 | export const events = [];
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | /* src/index.css */
2 | body {
3 | margin: 0;
4 | font-family: Arial, sans-serif;
5 | background-color: #121212;
6 | color: white;
7 | }
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import './index.css';
4 | import App from './App';
5 | import reportWebVitals from './reportWebVitals';
6 |
7 | const root = ReactDOM.createRoot(document.getElementById('root'));
8 | root.render(
9 |
10 |
11 |
12 | );
13 |
14 | // If you want to start measuring performance in your app, pass a function
15 | // to log results (for example: reportWebVitals(console.log))
16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17 | reportWebVitals();
18 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------