├── .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 | ![demo](https://github.com/Eidenz/CalView/assets/27901016/e4f2a2bb-9545-440d-9fd2-7d3a96b8386d) 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 |