From bda6fd6579eeebd04993326ad05712338cc5df01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Eriksson?= Date: Sun, 8 Sep 2024 04:32:19 +0200 Subject: [PATCH] [project] Repeatable Events Implementation * Fully works with ICS * Added new options for the form, also got rid of preview. --- backend/init_db.py | 2 +- backend/models/content/calendar.py | 2 + backend/models/content/event.py | 37 ++- backend/routes/calendar_routes.py | 30 ++- backend/routes/event_routes.py | 17 +- backend/services/content/__init__.py | 5 +- backend/services/content/event.py | 122 +++++++++ backend/services/content/public/calendar.py | 53 ++-- .../src/app/[language]/bulletin/events.tsx | 42 ++-- .../src/app/i18n/locales/en/bulletin.json | 10 +- .../src/app/i18n/locales/sv/bulletin.json | 10 +- frontend/src/components/calendar/Calendar.tsx | 66 ++--- .../src/components/dialogs/EventUpload.tsx | 236 +++++------------- .../src/components/dialogs/event/preview.tsx | 80 ++++++ .../components/dialogs/event/repeating.tsx | 147 +++++++++++ .../components/dialogs/event/translations.tsx | 84 +++++++ frontend/src/models/Items.ts | 2 +- 17 files changed, 675 insertions(+), 270 deletions(-) create mode 100644 backend/services/content/event.py create mode 100644 frontend/src/components/dialogs/event/preview.tsx create mode 100644 frontend/src/components/dialogs/event/repeating.tsx create mode 100644 frontend/src/components/dialogs/event/translations.tsx diff --git a/backend/init_db.py b/backend/init_db.py index 05edd645..ad42e6ed 100644 --- a/backend/init_db.py +++ b/backend/init_db.py @@ -31,7 +31,7 @@ News, # noqa: F401 NewsTranslation, # noqa: F401 Item, # noqa: F401 - RepeatableEvents, # noqa: F401 + RepeatableEvent, # noqa: F401 Tag, # noqa: F401 TagTranslation, # noqa: F401 ) diff --git a/backend/models/content/calendar.py b/backend/models/content/calendar.py index 4f03fd0c..f17f354d 100644 --- a/backend/models/content/calendar.py +++ b/backend/models/content/calendar.py @@ -149,6 +149,8 @@ def to_ics(self, events: List[Event], language: str): calendar = dedent(f"""\ BEGIN:VCALENDAR VERSION:2.0 + X-WR-CALNAME:{self.name} + X-WR-TIMEZONE:Europe/Stockholm PRODID:-//medieteknik//Calendar 1.0//{language[0:2].upper()} CALSCALE:GREGORIAN""") diff --git a/backend/models/content/event.py b/backend/models/content/event.py index 01796c15..cfec3e22 100644 --- a/backend/models/content/event.py +++ b/backend/models/content/event.py @@ -1,3 +1,4 @@ +from datetime import timedelta import enum from textwrap import dedent from typing import List @@ -5,7 +6,9 @@ from sqlalchemy import ( Boolean, Column, + Enum, ForeignKey, + Integer, String, inspect, ) @@ -16,6 +19,13 @@ from models.content.base import Item +class Frequency(enum.Enum): + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + YEARLY = "yearly" + + class Event(Item): """ Event model which inherits from the base Item model. @@ -30,8 +40,8 @@ class Event(Item): event_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - start_date = Column(TIMESTAMP) - end_date = Column(TIMESTAMP) + start_date = Column(TIMESTAMP, nullable=False) + duration = Column(Integer, nullable=False) # Duration in minutes location = Column(String(255)) is_inherited = Column(Boolean, default=False, nullable=False) background_color = Column(String(7)) @@ -62,7 +72,7 @@ class Event(Item): item = db.relationship("Item", back_populates="event", passive_deletes=True) calendar = db.relationship("Calendar", back_populates="events") repeatable_event = db.relationship( - "RepeatableEvents", back_populates="event", uselist=False + "RepeatableEvent", back_populates="event", uselist=False ) translations = db.relationship("EventTranslation", back_populates="event") @@ -70,7 +80,10 @@ class Event(Item): __mapper_args__ = {"polymorphic_identity": "event"} def to_dict( - self, provided_languages: List[str] = AVAILABLE_LANGUAGES, is_public_route=True + self, + provided_languages: List[str] = AVAILABLE_LANGUAGES, + is_public_route=True, + custom_start_date: str = None, ): data = super().to_dict( provided_languages=provided_languages, is_public_route=is_public_route @@ -115,6 +128,8 @@ def to_dict( del data["parent_event_id"] del data["type"] del data["published_status"] + if custom_start_date: + data["start_date"] = custom_start_date return data @@ -133,6 +148,7 @@ def to_ics(self, language: str): SUMMARY:{translation.title} DESCRIPTION:{translation.description} LOCATION:{self.location} + LAST-MODIFIED:{self.last_updated.strftime("%Y%m%dT%H%M%S" + "Z")} DTSTART:{self.start_date.strftime("%Y%m%dT%H%M%S" + "Z")} DTEND:{self.end_date.strftime("%Y%m%dT%H%M%S") + "Z"} END:VEVENT @@ -178,16 +194,21 @@ def to_dict(self): return data -class RepeatableEvents(db.Model): - __tablename__ = "repeatable_events" +class RepeatableEvent(db.Model): + __tablename__ = "repeatable_event" repeatable_event_id = Column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) # Metadata - reapeting_interval = Column(String(255)) - day = Column(TIMESTAMP) + frequency = Column( + Enum(Frequency), default=Frequency.WEEKLY, nullable=False + ) # Daily, Weekly, Monthly, Yearly + interval = Column(Integer, default=1) # Every x days, weeks, months, years + end_date = Column(TIMESTAMP) # End of the repeatable + max_occurrences = Column(Integer) # Number of times to repeat + repeat_forever = Column(Boolean, default=False) # Repeat forever # Foreign keys event_id = Column(UUID(as_uuid=True), ForeignKey("event.event_id"), unique=True) diff --git a/backend/routes/calendar_routes.py b/backend/routes/calendar_routes.py index e8d570c0..dd67ac2a 100644 --- a/backend/routes/calendar_routes.py +++ b/backend/routes/calendar_routes.py @@ -12,6 +12,7 @@ from models.content.event import Event from models.core.student import Student +from services.content.event import generate_events, generate_ics from services.content.public.calendar import get_main_calendar from utility.translation import retrieve_languages @@ -38,21 +39,34 @@ def get_calendar_ics(): date = datetime.now() start_date = (date - timedelta(days=1)).replace(day=1) - _, next_month_end_day = monthrange(date.year, date.month + 1) - end_date = date.replace(day=next_month_end_day) # Make end_date inclusive + + # Handle year and month transition for the next month + if date.month == 12: + next_month = 1 + next_month_year = date.year + 1 + else: + next_month = date.month + 1 + next_month_year = date.year + + _, next_month_end_day = monthrange(next_month_year, next_month) + end_date = date.replace( + year=next_month_year, month=next_month, day=next_month_end_day + ) # Make end_date inclusive # Adjusted filter conditions for overlapping events and inclusivity - events: List[Event] = Event.query.filter( + events = Event.query.filter( Event.calendar_id == main_calendar.calendar_id, or_( - Event.start_date.between(start_date, end_date), # Starts within range - Event.end_date.between(start_date, end_date), # Ends within range - (Event.start_date < start_date) - & (Event.end_date > end_date), # Spans the range + Event.start_date <= end_date, # Starts before or on the end date + Event.start_date >= start_date, # Starts after or on the start date ), ).all() return Response( - get_main_calendar().to_ics(events, provided_langauges[0]), + response=generate_ics( + calendar=main_calendar, + events=events, + language=provided_langauges[0], + ), mimetype="text/calendar", ) diff --git a/backend/routes/event_routes.py b/backend/routes/event_routes.py index ed00ab7a..329243e6 100644 --- a/backend/routes/event_routes.py +++ b/backend/routes/event_routes.py @@ -9,7 +9,7 @@ from flask_jwt_extended import jwt_required from models.committees.committee import Committee from models.committees.committee_position import CommitteePosition -from models.content.event import Event, RepeatableEvents +from models.content.event import Event, RepeatableEvent from models.core.student import Student from services.content.item import ( create_item, @@ -63,12 +63,19 @@ def create_event(): public=True, ) - repeatable = data.get("repeatable") + repeatable = data.get("repeats") if repeatable: - repeatable_event = RepeatableEvents( - event_id=id, - reapeting_interval="weekly", + event: Event = Event.query.filter(Event.item_id == id).first_or_404() + end_date = data.get("end_date") + max_occurrences = data.get("max_occurrences") + repeatable_event = RepeatableEvent( + event_id=event.event_id, + frequency=data.get("frequency"), + interval=data.get("interval"), + end_date=end_date, + max_occurrences=max_occurrences, + repeat_forever=end_date is None and max_occurrences is None, ) db.session.add(repeatable_event) diff --git a/backend/services/content/__init__.py b/backend/services/content/__init__.py index b043a3e2..0d2b626f 100644 --- a/backend/services/content/__init__.py +++ b/backend/services/content/__init__.py @@ -3,6 +3,7 @@ """ from .author import get_author_from_email +from .event import generate_ics, generate_events from .item import ( get_items, get_items_from_author, @@ -16,6 +17,9 @@ __all__ = [ + "get_author_from_email", + "generate_ics", + "generate_events", "get_items", "get_items_from_author", "get_item_by_url", @@ -24,5 +28,4 @@ "delete_item", "publish", "create_item", - "get_author_from_email", ] diff --git a/backend/services/content/event.py b/backend/services/content/event.py new file mode 100644 index 00000000..86b49085 --- /dev/null +++ b/backend/services/content/event.py @@ -0,0 +1,122 @@ +from calendar import Calendar +from datetime import timedelta +from textwrap import dedent +from typing import Any, Dict, List +from models.content.event import Event, EventTranslation, Frequency +from utility.translation import get_translation + + +def generate_ics(calendar: Calendar, events: List[Event], language: str) -> str: + ics = dedent(f"""\ + BEGIN:VCALENDAR + VERSION:2.0 + X-WR-CALNAME:{calendar.name} + X-WR-TIMEZONE:Europe/Stockholm + PRODID:-//medieteknik//Calendar 1.0//{language[0:2].upper()} + CALSCALE:GREGORIAN""") + + for event in events: + if not event: + continue + translation = get_translation( + EventTranslation, ["event_id"], {"event_id": event.event_id}, language + ) + + if not isinstance(translation, EventTranslation): + return None + + rrule = "" + if event.repeatable_event: + rrule = f"FREQ={event.repeatable_event.frequency.value};".upper() + + if event.repeatable_event.interval: + rrule += f"INTERVAL={event.repeatable_event.interval};" + + if event.repeatable_event.end_date: + rrule += f"UNTIL={event.repeatable_event.end_date.strftime('%Y%m%dT%H%M%S')};" + + if event.repeatable_event.max_occurrences: + rrule += f"COUNT={event.repeatable_event.max_occurrences};" + + ics += dedent(f""" + BEGIN:VEVENT + UID:{str(event.event_id) + '@medieteknik.com'} + DTSTAMP:{event.created_at.strftime("%Y%m%dT%H%M%S" + "Z")} + SUMMARY:{translation.title} + LOCATION:{event.location} + LAST-MODIFIED:{event.last_updated.strftime("%Y%m%dT%H%M%S" + "Z")} + DTSTART:{event.start_date.strftime("%Y%m%dT%H%M%S") + "Z"} + DTEND:{(event.start_date + timedelta(minutes=event.duration)).strftime("%Y%m%dT%H%M%S") + "Z"}""") + if translation.description or rrule: + ics += "\n" + if translation.description: + ics += f"DESCRIPTION:{translation.description}" + + if translation.description and rrule: + ics += "\n" + + if rrule: + ics += f"RRULE:{rrule}" + + ics += "\nEND:VEVENT" + + ics += "\nEND:VCALENDAR" + ics = dedent(ics) + + return ics + + +def generate_events(event: Event, start_date, end_date) -> List: + """ + Generate a list of occurrences for a repeatable event. + + Args: + event: The event to generate occurrences for + start_date: The start date of the range + end_date: The end date of the range + + Returns: + List: A list of occurrences for the event + """ + occurrences = [] + current_occurrence = event.start_date + occurrence_count = 0 + + if not event.repeatable_event: + return occurrences + + repeat = event.repeatable_event + + while current_occurrence < end_date: + event_end_time = current_occurrence + timedelta(minutes=event.duration) + + if (repeat.end_date and current_occurrence > repeat.end_date) or ( + repeat.max_occurrences and occurrence_count >= repeat.max_occurrences + ): + break + + if current_occurrence >= start_date: + occurrences.append( + { + "event": event, + "start_date": current_occurrence, + "end_date": event_end_time, + } + ) + occurrence_count += 1 + + match event.repeatable_event.frequency: + case Frequency.DAILY: + current_occurrence += timedelta(days=event.repeatable_event.interval) + case Frequency.WEEKLY: + current_occurrence += timedelta(weeks=event.repeatable_event.interval) + case Frequency.MONTHLY: + current_occurrence = current_occurrence.replace( + month=current_occurrence.month + event.repeatable_event.interval + ) + case Frequency.YEARLY: + current_occurrence = current_occurrence.replace( + year=current_occurrence.year + event.repeatable_event.interval + ) + + return occurrences diff --git a/backend/services/content/public/calendar.py b/backend/services/content/public/calendar.py index 10a02286..b652c0ed 100644 --- a/backend/services/content/public/calendar.py +++ b/backend/services/content/public/calendar.py @@ -1,9 +1,10 @@ from calendar import monthrange from datetime import datetime, timedelta from typing import List -from sqlalchemy import and_, or_ +from sqlalchemy import and_, func, or_ from models.content import Calendar, Event -from models.content.event import RepeatableEvents +from models.content.event import RepeatableEvent +from services.content.event import generate_events from utility.constants import AVAILABLE_LANGUAGES from utility.database import db @@ -48,39 +49,39 @@ def get_events_monthly( ) # Make end_date inclusive # Adjusted filter conditions for overlapping events and inclusivity - events: List[Event] = Event.query.filter( + events = Event.query.filter( Event.calendar_id == main_calendar.calendar_id, or_( - Event.start_date.between(start_date, end_date), # Starts within range - Event.end_date.between(start_date, end_date), # Ends within range - (Event.start_date < start_date) - & (Event.end_date > end_date), # Spans the range + Event.start_date <= end_date, # Starts before or on the end date + Event.start_date >= start_date, # Starts after or on the start date ), ).all() - #repeatable_events: List[RepeatableEvents] = RepeatableEvents.query.all() - - event_dict = [ - event_dict - for event in events - if ( - event_dict := event.to_dict( - is_public_route=True, provided_languages=provided_languages - ) - ) - is not None - ] - - #for repeatable_event in repeatable_events: - # referenced_event = Event.query.get(repeatable_event.event_id) - + all_event_occurrences = [] + for event in events: + if event.repeatable_event: + occurrences = generate_events(event, start_date, end_date) + all_event_occurrences.extend(occurrences) + + else: + if event.start_date >= start_date and event.start_date <= end_date: + all_event_occurrences.append( + { + "event": event, + "start_date": event.start_date, + "end_date": ( + event.start_date + timedelta(minutes=event.duration) + ), + } + ) return [ event_dict - for event in events + for event_occurrence in all_event_occurrences if ( - event_dict := event.to_dict( - is_public_route=True, provided_languages=provided_languages + event_dict := event_occurrence["event"].to_dict( + provided_languages=provided_languages, + custom_start_date=event_occurrence["start_date"].strftime("%Y-%m-%d"), ) ) is not None diff --git a/frontend/src/app/[language]/bulletin/events.tsx b/frontend/src/app/[language]/bulletin/events.tsx index 67f44008..bec3e4d3 100644 --- a/frontend/src/app/[language]/bulletin/events.tsx +++ b/frontend/src/app/[language]/bulletin/events.tsx @@ -189,13 +189,15 @@ export default function Events({ - setIsOpen(false)} - addEvent={addEvent} - /> + {isOpen && ( + setIsOpen(false)} + addEvent={addEvent} + /> + )} ) : null} @@ -249,13 +251,13 @@ export default function Events({ className='h-4 mx-2' />

- {new Date(event.end_date).toLocaleTimeString( - language, - { - hour: 'numeric', - minute: 'numeric', - } - )} + {new Date( + new Date(event.start_date).getTime() + + event.duration * 60000 + ).toLocaleTimeString(language, { + hour: 'numeric', + minute: 'numeric', + })}

@@ -336,18 +338,22 @@ export default function Events({
new Date() && + : new Date(event.start_date + event.duration) > + new Date() && new Date(event.start_date) < new Date() ? 'bg-green-500' : 'bg-yellow-500' }`} />

- {new Date(event.end_date) < new Date() + {new Date(event.start_date + event.duration) < + new Date() ? t('event.ended') - : new Date(event.end_date) > new Date() && + : new Date(event.start_date + event.duration) > + new Date() && new Date(event.start_date) < new Date() ? t('event.ongoing') : t('event.upcoming')} diff --git a/frontend/src/app/i18n/locales/en/bulletin.json b/frontend/src/app/i18n/locales/en/bulletin.json index 09213718..752045d3 100644 --- a/frontend/src/app/i18n/locales/en/bulletin.json +++ b/frontend/src/app/i18n/locales/en/bulletin.json @@ -11,8 +11,16 @@ "event.form.language": "Language", "event.form.date": "Date", "event.form.start_time": "Start Time", - "event.form.end_time": "End Time", + "event.form.duration": "Duration (minutes)", "event.form.repeats": "Repeats", + "event.form.daily": "Daily", + "event.form.weekly": "Weekly", + "event.form.monthly": "Monthly", + "event.form.yearly": "Yearly", + "event.form.frequency": "Frequency", + "event.form.select_frequency": "Select Frequency", + "event.form.end_date": "End Date", + "event.form.max_occurrences": "Max Occurrences", "event.form.location": "Location", "event.form.bg_color": "Background Colour", "event.form.preset_colors": "Preset Colours", diff --git a/frontend/src/app/i18n/locales/sv/bulletin.json b/frontend/src/app/i18n/locales/sv/bulletin.json index 5dc3f107..d7d1232a 100644 --- a/frontend/src/app/i18n/locales/sv/bulletin.json +++ b/frontend/src/app/i18n/locales/sv/bulletin.json @@ -11,8 +11,16 @@ "event.form.language": "Språk", "event.form.date": "Datum", "event.form.start_time": "Start Tid", - "event.form.end_time": "Avslutnings Tid", + "event.form.duration": "Varaktighet (minuter)", "event.form.repeats": "Upprepas", + "event.form.daily": "Dagligen", + "event.form.weekly": "Veckovis", + "event.form.monthly": "Månadsvis", + "event.form.yearly": "Årligen", + "event.form.frequency": "Frekvens", + "event.form.select_frequency": "Välj Frekvens", + "event.form.end_date": "Avslutnings Datum", + "event.form.max_occurrences": "Max Förekomster", "event.form.location": "Plats", "event.form.bg_color": "Bakgrundsfärg", "event.form.preset_colors": "Utvalda Färger", diff --git a/frontend/src/components/calendar/Calendar.tsx b/frontend/src/components/calendar/Calendar.tsx index ae409f87..3d9b1201 100644 --- a/frontend/src/components/calendar/Calendar.tsx +++ b/frontend/src/components/calendar/Calendar.tsx @@ -1,9 +1,5 @@ import { Event } from '@/models/Items' import { - startOfMonth, - subWeeks, - startOfWeek, - addDays, getDaysInMonth, setDate, addMonths, @@ -13,7 +9,7 @@ import { } from 'date-fns' import { useCalendar } from '@/providers/CalendarProvider' import './calendar.css' -import { useEffect, useMemo } from 'react' +import { useMemo } from 'react' import EventComponent from './EventComponent' interface CalendarProps { @@ -28,20 +24,29 @@ interface CalendarProps { * @param {Date} currentDate - The current date. * @return {Date[]} An array of dates representing the last week of the previous month adjusted to include only dates that are before the start of the current month. */ -function getPreviousMonthsLastWeekAdjusted(currentDate: Date): Date[] { - const startOfCurrentMonth = startOfMonth(currentDate) +function getPreviousMonthLastWeekToCurrent(date: Date): Date[] { + const firstDayOfCurrentMonth = new Date( + date.getFullYear(), + date.getMonth(), + 1 + ) + const lastDayOfPreviousMonth = new Date(firstDayOfCurrentMonth) + lastDayOfPreviousMonth.setDate(0) + + const result: Date[] = [] + let currentDay = new Date(lastDayOfPreviousMonth) - const lastWeekOfPreviousMonthEnd = subWeeks(startOfCurrentMonth, 0) - const lastWeekOfPreviousMonthStart = startOfWeek(lastWeekOfPreviousMonthEnd) + // Go back to the Monday (or first day of the week) of the last week + const daysToSubtract = (currentDay.getDay() + 6) % 7 + currentDay.setDate(currentDay.getDate() - daysToSubtract) - let lastWeekAdjusted = [] - for (let i = 1; i <= 5; i++) { - const date = addDays(lastWeekOfPreviousMonthStart, i) - if (date < startOfCurrentMonth) { - lastWeekAdjusted.push(date) - } + // Add days until we reach the first day of the current month + while (currentDay < firstDayOfCurrentMonth) { + result.push(new Date(currentDay)) + currentDay.setDate(currentDay.getDate() + 1) } - return lastWeekAdjusted + + return result } /** @@ -64,11 +69,9 @@ const sortEvents = (events: Event[]): Event[] => * @return {Event[]} The filtered list of events for the given date. */ const filterEventsForDate = (events: Event[], date: Date): Event[] => - events.filter( - (event) => - isSameMonth(new Date(event.start_date), date) && - isSameDay(new Date(event.start_date), date) - ) + events.filter((event) => { + return isSameDay(new Date(event.start_date), date) + }) function displayEvents( events: Event[], @@ -90,14 +93,14 @@ function displayEvents( } export default function Calendar({ - onDateClickCallback = () => {}, - onEventClickCallback = () => {}, + onDateClickCallback = (date: Date) => {}, + onEventClickCallback = (event: Event) => {}, children, }: CalendarProps) { const { date, selectedDate, setSelectedDate, events } = useCalendar() const totalDays = useMemo(() => getDaysInMonth(new Date(date)), [date]) const previousMonthLastWeek = useMemo( - () => getPreviousMonthsLastWeekAdjusted(new Date(date)), + () => getPreviousMonthLastWeekToCurrent(date), [date] ) @@ -163,8 +166,12 @@ export default function Calendar({ isSameMonth(new Date(), date) ? 'text-red-400' : 'text-neutral-400' - } - `} + }`} + onClick={(e) => { + e.stopPropagation() + setSelectedDate(new Date(setDate(date, index + 1))) + onDateClickCallback(new Date(setDate(date, index + 1))) + }} >

{ - e.stopPropagation() - setSelectedDate(new Date(setDate(date, index + 1))) - onDateClickCallback(new Date(setDate(date, index + 1))) - }} />

{displayEvents( @@ -212,7 +214,7 @@ export default function Calendar({
{displayEvents( events, - addMonths(new Date(date), 1), + addMonths(setDate(new Date(date), index + 1), 1), onEventClickCallback )}
diff --git a/frontend/src/components/dialogs/EventUpload.tsx b/frontend/src/components/dialogs/EventUpload.tsx index c7d33702..d5faaf06 100644 --- a/frontend/src/components/dialogs/EventUpload.tsx +++ b/frontend/src/components/dialogs/EventUpload.tsx @@ -17,14 +17,12 @@ import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { API_BASE_URL, LANGUAGES } from '@/utility/Constants' import { useState } from 'react' -import { CardFooter } from '@/components/ui/card' import { EyeDropperIcon, MapPinIcon } from '@heroicons/react/24/outline' import { Label } from '@/components/ui/label' import { useAuthentication } from '@/providers/AuthenticationProvider' import { supportedLanguages } from '@/app/i18n/settings' import '/node_modules/flag-icons/css/flag-icons.min.css' import { useTranslation } from '@/app/i18n/client' -import { TFunction } from 'next-i18next' import { DialogContent, DialogDescription, @@ -33,12 +31,8 @@ import { } from '@/components/ui/dialog' import { Author, Event } from '@/models/Items' import { LanguageCode } from '@/models/Language' - -interface TranslatedInputProps { - index: number - language: string - t: TFunction -} +import RepeatingForm from './event/repeating' +import TranslatedInputs from './event/translations' interface EventFormProps { language: string @@ -48,75 +42,6 @@ interface EventFormProps { addEvent?: (event: Event) => void } -/** - * @name TranslatedInputs - * @description Inputs for translated fields - * - * @param {TranslatedInputProps} props - The props for the component - * @param {number} props.index - The index of the input - * @param {string} props.language - The language of the input - * @param {TFunction} props.t - The translation function - * @returns {JSX.Element} The translated inputs - */ -function TranslatedInputs({ - index, - language, - t, -}: TranslatedInputProps): JSX.Element { - return ( - <> - ( - - - - )} - /> - ( - - - {t('event.form.title')}{' '} - - [{language}] - - - - - - - - )} - /> - - ( - - - {t('event.form.description')}{' '} - - [{language}] - - - - - - - - )} - /> - - ) -} - /** * @name EventUpload * @description Upload an event @@ -138,43 +63,37 @@ export default function EventUpload({ }: EventFormProps): JSX.Element { const { student } = useAuthentication() const { t } = useTranslation(language, 'bulletin') - + const [isRepeating, setIsRepeating] = useState(false) const [errorMessage, setErrorMessage] = useState('') const [showColorPicker, setShowColorPicker] = useState(false) const [currentColor, setCurrentColor] = useState('#FFFFFF') - const tinycolor = require('tinycolor2') const presetColors = ['#FACC15', '#111111', '#22C55E', '#3B82F6', '#EF4444'] - const FormSchema = z - .object({ - date: z.string().date().min(1, 'Date is required'), - start_time: z.string().time(), - end_time: z.string().time(), - repeats: z.boolean().optional().or(z.literal(false)), - location: z.string().min(1, 'Location is required'), - background_color: z.string().refine( - (value) => { - return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(value) - }, - { - message: 'Invalid color', - } - ), - translations: z.array( - z.object({ - language_code: z.string().optional().or(z.literal('')), - title: z.string().min(1, 'Title is required'), - description: z.string().optional().or(z.literal('')), - }) - ), - }) - .refine( - (data) => - data.end_time.valueOf() > data.start_time.valueOf() + 1 * 60 * 1000, + + const FormSchema = z.object({ + date: z.string().date(), + start_time: z.string().time(), + duration: z.number().int().min(1, 'Duration is required'), + repeats: z.boolean().optional().or(z.literal(false)), + frequency: z.enum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']), + end_date: z.string().date().optional().or(z.literal('')), + max_occurrences: z.coerce.number().optional().or(z.literal(0)), + location: z.string().min(1, 'Location is required'), + background_color: z.string().refine( + (value) => { + return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(value) + }, { - message: 'End time must be after start time', - path: ['end_time'], + message: 'Invalid color', } - ) + ), + translations: z.array( + z.object({ + language_code: z.string().optional().or(z.literal('')), + title: z.string().min(1, 'Title is required'), + description: z.string().optional().or(z.literal('')), + }) + ), + }) const eventForm = useForm>({ resolver: zodResolver(FormSchema), @@ -188,7 +107,7 @@ export default function EventUpload({ }), date: selectedDate.toISOString().split('T')[0], start_time: '11:00:00', - end_time: '12:00:00', + duration: 60, repeats: false, location: '', background_color: currentColor, @@ -208,11 +127,14 @@ export default function EventUpload({ const publish = async (data: z.infer) => { const start_date = new Date(data.date + ' ' + data.start_time) - const end_date = new Date(data.date + ' ' + data.end_time) const json_data = { start_date: start_date.toISOString(), - end_date: end_date.toISOString(), + duration: data.duration, + repeats: data.repeats, + frequency: data.repeats ? data.frequency : null, + end_date: data.repeats ? data.end_date : null, + max_occurrences: data.repeats ? data.max_occurrences : null, background_color: data.background_color, location: data.location, author: author, @@ -235,9 +157,7 @@ export default function EventUpload({ start_date: start_date.toLocaleString(language, { timeZone: 'Europe/Stockholm', }), - end_date: end_date.toLocaleString(language, { - timeZone: 'Europe/Stockholm', - }), + duration: data.duration, background_color: data.background_color, location: data.location, created_at: new Date().toLocaleDateString(), @@ -314,12 +234,12 @@ export default function EventUpload({ /> ( - {t('event.form.end_time')} + {t('event.form.duration')} - + @@ -329,11 +249,22 @@ export default function EventUpload({ ( - + { + console.log(e.currentTarget.value) + setIsRepeating( + e.currentTarget.value === 'on' && !isRepeating + ) + setValue( + 'repeats', + e.currentTarget.value === 'on' && !isRepeating + ) + }} + /> {t('event.form.repeats')} @@ -342,6 +273,17 @@ export default function EventUpload({ )} /> + {isRepeating && ( + { + setValue( + 'frequency', + value as 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' + ) + }} + /> + )} - + {/* -

- {t('event.form.preview')} -

-
-

- 1 -

-
- {getValues('translations').map((translation, index) => ( -
{ - e.stopPropagation() - const bg = tinycolor(currentColor) - if (bg.isDark()) { - e.currentTarget.style.backgroundColor = bg - .lighten(10) - .toString() - } else { - e.currentTarget.style.backgroundColor = bg - .darken(10) - .toString() - } - }} - onMouseLeave={(e) => { - e.stopPropagation() - e.currentTarget.style.backgroundColor = currentColor - }} - > -
- -
-

{translation.title}

-
- ))} -
-
-
+ + */} ) } diff --git a/frontend/src/components/dialogs/event/preview.tsx b/frontend/src/components/dialogs/event/preview.tsx new file mode 100644 index 00000000..9af56096 --- /dev/null +++ b/frontend/src/components/dialogs/event/preview.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from '@/app/i18n/client' +import { LanguageCode } from '@/models/Language' +import { LANGUAGES } from '@/utility/Constants' + +interface Props { + language: string + currentColor: string + translations: { language_code?: string; title: string }[] +} + +/** + * @name EventPreview + * @description Preview of the event when creating or editing an event + * + * @param {Props} props - The props for the component + * @param {string} props.language - The language of the component + * @param {string} props.currentColor - The current color of the event + * @param {{ language_code?: string; title: string }[]} props.translations - The translations of the event + * @returns {JSX.Element} The event preview + */ +export default function EventPreview({ + language, + currentColor, + translations, +}: Props): JSX.Element { + const tinycolor = require('tinycolor2') + const { t } = useTranslation(language, 'bulletin') + + return ( + <> +

+ {t('event.form.preview')} +

+
+

+ 1 +

+
+ {translations.map((translation, index) => ( +
{ + e.stopPropagation() + const bg = tinycolor(currentColor) + if (bg.isDark()) { + e.currentTarget.style.backgroundColor = bg + .lighten(10) + .toString() + } else { + e.currentTarget.style.backgroundColor = bg + .darken(10) + .toString() + } + }} + onMouseLeave={(e) => { + e.stopPropagation() + e.currentTarget.style.backgroundColor = currentColor + }} + > +
+ +
+

{translation.title}

+
+ ))} +
+
+ + ) +} diff --git a/frontend/src/components/dialogs/event/repeating.tsx b/frontend/src/components/dialogs/event/repeating.tsx new file mode 100644 index 00000000..5497b9e8 --- /dev/null +++ b/frontend/src/components/dialogs/event/repeating.tsx @@ -0,0 +1,147 @@ +'use client' +import { useTranslation } from '@/app/i18n/client' +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { ChevronUpDownIcon } from '@heroicons/react/24/outline' +import { useState } from 'react' + +interface Props { + language: string + setValue: (value: string) => void +} + +/** + * @name RepeatingForm + * @description Form items for repeating events + * + * @param {Props} props - The props for the component + * @param {string} props.language - The language of the component + * @param {(value: string) => void} props.setValue - The function to set the value + * @returns {JSX.Element} The repeating form + */ +export default function RepeatingForm({ + language, + setValue, +}: Props): JSX.Element { + const { t } = useTranslation(language, 'bulletin') + const [open, setOpen] = useState(false) + + const frequencyOptions = [ + { + value: 'DAILY', + label: t('event.form.daily'), + }, + { + value: 'WEEKLY', + label: t('event.form.weekly'), + }, + { + value: 'MONTHLY', + label: t('event.form.monthly'), + }, + { + value: 'YEARLY', + label: t('event.form.yearly'), + }, + ] + return ( +
+ ( + + +

{t('event.form.frequency')}

+ * +
+ + + + + + + + + + + + + {frequencyOptions.map((option) => ( + { + setValue(value) + setOpen(false) + }} + > + {option.label} + + ))} + + + + + + +
+ )} + /> + + ( + + {t('event.form.end_date')} + + + + + + )} + /> + + ( + + {t('event.form.max_occurrences')} + + + + + + )} + /> +
+ ) +} diff --git a/frontend/src/components/dialogs/event/translations.tsx b/frontend/src/components/dialogs/event/translations.tsx new file mode 100644 index 00000000..15969c3d --- /dev/null +++ b/frontend/src/components/dialogs/event/translations.tsx @@ -0,0 +1,84 @@ +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { TFunction } from 'next-i18next' + +interface Props { + index: number + language: string + t: TFunction +} + +/** + * @name TranslatedInputs + * @description Inputs for translated fields + * + * @param {Props} props - The props for the component + * @param {number} props.index - The index of the input + * @param {string} props.language - The language of the input + * @param {TFunction} props.t - The translation function + * @returns {JSX.Element} The translated inputs + */ +export default function TranslatedInputs({ + index, + language, + t, +}: Props): JSX.Element { + return ( + <> + ( + + + + )} + /> + ( + + + {t('event.form.title')}{' '} + + [{language}] + + + + + + + + )} + /> + + ( + + + {t('event.form.description')}{' '} + + [{language}] + + + + + + + + )} + /> + + ) +} diff --git a/frontend/src/models/Items.ts b/frontend/src/models/Items.ts index b31e582a..f8536b34 100644 --- a/frontend/src/models/Items.ts +++ b/frontend/src/models/Items.ts @@ -65,7 +65,7 @@ export interface NewsTranslation { export interface Event extends Item { location: string start_date: string - end_date: string + duration: number, background_color: string translations: EventTranslation[] }