diff --git a/packages/app/src/components/FeaturedEventAnnouncement/index.jsx b/packages/app/src/components/FeaturedEventAnnouncement/index.jsx index 32c766fc..005bc3ed 100755 --- a/packages/app/src/components/FeaturedEventAnnouncement/index.jsx +++ b/packages/app/src/components/FeaturedEventAnnouncement/index.jsx @@ -3,42 +3,49 @@ import { Icons } from "@components/Icons" import "./index.less" -export default (props) => { - const { backgroundImg, backgroundStyle, logoImg, title, description } = props.data?.announcement ?? {} +const FeaturedEventAnnouncement = (props) => { + const { announcement } = props.data - const onClickEvent = () => { - if (!props.data?._id) { - console.error("No event ID provided") - return false - } + const onClickEvent = () => { + if (!props.data?._id) { + console.error("No event ID provided") + return false + } - app.location.push(`/featured-event/${props.data?._id}`) - } + app.location.push(`/event/${props.data?._id}`) + } - return
-
-
- -
+ if (!announcement) { + return null + } -
-

{title}

-

{description}

-
-
+ return ( +
+
+ +
-
- Featured event -
-
-} \ No newline at end of file +
+

{announcement.title}

+

{announcement.description}

+
+ +
+ Featured event +
+
+ ) +} + +export default FeaturedEventAnnouncement diff --git a/packages/app/src/components/FeaturedEventAnnouncement/index.less b/packages/app/src/components/FeaturedEventAnnouncement/index.less index 73d67efd..de59b445 100755 --- a/packages/app/src/components/FeaturedEventAnnouncement/index.less +++ b/packages/app/src/components/FeaturedEventAnnouncement/index.less @@ -1,103 +1,88 @@ -.featuredEvent { - position: relative; - z-index: 50; +.featured_event { + position: relative; + z-index: 50; - display: flex; - flex-direction: row; + display: flex; + flex-direction: row; - align-items: center; - //justify-content: flex-start; + width: 100%; + min-height: 120px; - min-width: 300px; - max-width: 350px; + border-radius: 12px; - border-radius: 12px; + background-repeat: no-repeat; + background-size: cover; + background-position: center; - padding: 10px 20px; + overflow: hidden; - background-repeat: no-repeat; - background-size: cover; - background-position: center; + cursor: pointer; - cursor: pointer; + .featured_event-logo { + height: 100%; + width: 155px; - .featuredEvent_wrapper { - display: flex; - flex-direction: row; + padding: 20px; - width: 100%; - height: 100%; + img { + height: 100%; + width: 100%; + } + } - align-items: center; - justify-content: space-between; + .featured_event-content { + display: flex; + flex-direction: column; - .logo { - height: 100%; + align-items: flex-start; + justify-content: center; - width: fit-content; + width: 100%; + height: 100%; - max-width: 70px; + h1 { + font-size: 1.5rem; + font-weight: 700; + font-family: "Space Grotesk", sans-serif; - img { - height: 100%; - width: 100%; - } - } + color: inherit; - .content { - display: flex; - flex-direction: column; - align-self: flex-end; + overflow: hidden; - width: 100%; - max-width: calc(90% - 70px); + text-overflow: ellipsis; + white-space: nowrap; + } - overflow: hidden; + h3 { + font-size: 0.8rem; + font-weight: 400; - margin-left: 20px; - margin-bottom: 10px; + color: inherit; + overflow: hidden; - h1 { - font-size: 1.5rem; - font-weight: 700; - font-family: "Space Grotesk", sans-serif; + text-overflow: ellipsis; + white-space: nowrap; + word-break: break-all; + } + } - color: inherit; + .featured_event-indicator { + position: absolute; + z-index: 55; - overflow: hidden; + font-size: 0.6rem; - text-overflow: ellipsis; - white-space: nowrap; - } + bottom: 0; + right: 0; - h3 { - font-size: 0.8rem; - font-weight: 400; + padding: 10px; - color: inherit; + background-color: rgba(0, 0, 0, 0.5); + border-radius: 12px 0 0 0; - overflow: hidden; - - text-overflow: ellipsis; - white-space: nowrap; - word-break: break-all; - } - } - } - - .indicator { - position: absolute; - z-index: 55; - - font-size: 0.6rem; - - bottom: 0; - right: 0; - - padding: 10px; - - svg, span { - color: inherit; - } - } -} \ No newline at end of file + svg, + span { + color: inherit; + } + } +} diff --git a/packages/app/src/components/FeaturedEventsAnnouncements/index.jsx b/packages/app/src/components/FeaturedEventsAnnouncements/index.jsx index dfcd3843..0b6bcd36 100755 --- a/packages/app/src/components/FeaturedEventsAnnouncements/index.jsx +++ b/packages/app/src/components/FeaturedEventsAnnouncements/index.jsx @@ -1,47 +1,29 @@ import React from "react" import Announcement from "../FeaturedEventAnnouncement" +import EventsModel from "@models/events" import "./index.less" -export default React.memo((props) => { - const [featuredEvents, setFeaturedEvents] = React.useState([]) +const FeaturedEventsAnnouncements = React.memo((props) => { + const [ + L_FeaturedEvents, + R_FeaturedEvents, + E_FeaturedEvents, + M_FeaturedEvents, + ] = app.cores.api.useRequest(EventsModel.getFeatured) - const fetchFeaturedEvents = React.useCallback(async () => { - let { data } = await app.cores.api.customRequest({ - url: "/featured_events", - method: "GET" - }).catch((err) => { - console.error(err) - app.message.error(`Failed to fetch featured events`) + if (!Array.isArray(R_FeaturedEvents)) { + return null + } - return { - data: null - } - }) + return ( +
+ {R_FeaturedEvents.map((event, index) => ( + + ))} +
+ ) +}) - if (data) { - // parse announcement data - data = data.map((item) => { - try { - item.announcement = JSON.parse(item.announcement) - } catch (error) { - console.error(error) - app.message.error(`Failed to parse announcement data`) - } - return item - }) - - setFeaturedEvents(data) - } - - }, []) - - React.useEffect(() => { - fetchFeaturedEvents() - }, []) - - return
- {featuredEvents.map((event, index) => )} -
-}) \ No newline at end of file +export default FeaturedEventsAnnouncements diff --git a/packages/app/src/components/FeaturedEventsAnnouncements/index.less b/packages/app/src/components/FeaturedEventsAnnouncements/index.less index 16273c9a..cad11bd1 100755 --- a/packages/app/src/components/FeaturedEventsAnnouncements/index.less +++ b/packages/app/src/components/FeaturedEventsAnnouncements/index.less @@ -1,8 +1,6 @@ .featuredEvents { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; - .featuredEvent { - margin-bottom: 20px; - } -} \ No newline at end of file + width: 100%; +} diff --git a/packages/app/src/pages/event/[id].jsx b/packages/app/src/pages/event/[id].jsx new file mode 100755 index 00000000..1bca629a --- /dev/null +++ b/packages/app/src/pages/event/[id].jsx @@ -0,0 +1,277 @@ +import React, { useState, useEffect } from "react" +import { Skeleton, Button, Tooltip, Popover, Tag } from "antd" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import rehypeRaw from "rehype-raw" +import { DateTime } from "luxon" +import createGoogleCalendarEvent from "@utils/createGoogleCalendarEvent" + +import EventsModel from "@models/events" +import useCenteredContainer from "@hooks/useCenteredContainer" + +import { Icons } from "@components/Icons" + +import ContrastYIQ from "@utils/contrastYIQ" +import ProcessString from "@utils/processString" + +import "./index.less" + +const LocationProcessRegexs = [ + { + regex: /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi, + fn: (key, result) => ( + + {result[1]} + + ), + }, +] + +const EventCountdown = ({ date, prefix }) => { + const [label, setLabel] = useState(null) + + useEffect(() => { + function updateCountdown() { + const nowDate = DateTime.local() + const fromDate = DateTime.fromISO(date) + const diff = fromDate.diff(nowDate, "minutes").values + setLabel(nowDate.plus(diff).toRelative()) + } + + updateCountdown() + const interval = setInterval(updateCountdown, 1000) + return () => clearInterval(interval) + }, [date]) + + return ( +
+
+ +

+ {prefix} {label} +

+
+
+ ) +} + +const EventStartDate = ({ startDate, started }) => ( + + } + > +
+
+ + {startDate.toLocaleString(DateTime.DATE_FULL)} +
+
+ + {startDate.toLocaleString(DateTime.TIME_SIMPLE)} +
+
+
+) + +const EventHeader = ({ pageConfig, event, contrastColor }) => { + if (pageConfig.header) { + return ( +
+ {pageConfig.header.displayLogo && ( +
+ Event Logo +
+ )} + {pageConfig.header.displayTitle && ( +
+

{pageConfig.header.title}

+

{pageConfig.header.description}

+
+ )} +
+ ) + } + + return ( +
+ {event.announcement.logoImg && ( +
+ Event Logo +
+ )} +
+

{event.name}

+

{event.announcement.description}

+
+
+ ) +} + +const EventPage = (props) => { + useCenteredContainer(false) + + const [L_Event, R_Event, E_Event, M_Event] = app.cores.api.useRequest( + EventsModel.data, + props.params["id"], + ) + const [contrastColor, setContrastColor] = useState(null) + const [started, setStarted] = useState(false) + const [ended, setEnded] = useState(false) + + useEffect(() => { + if (!R_Event) return + + // Calculate event status + const now = DateTime.local() + const eventStart = DateTime.fromISO(R_Event.startDate) + const eventEnd = DateTime.fromISO(R_Event.endDate) + + const startDiff = eventStart.diff(now, "minutes").values + const endDiff = eventEnd.diff(now, "minutes").values + + setStarted(startDiff.minutes < 0) + setEnded(endDiff.minutes < 0) + + // Calculate contrast color for header + if (R_Event.pageConfig?.header?.style?.backgroundImage) { + const url = R_Event.pageConfig.header.style.backgroundImage + .replace("url(", "") + .replace(")", "") + .replace(/['"]/gi, "") + + ContrastYIQ.fromUrl(url).then(setContrastColor) + } + }, [R_Event]) + + const handleClickWatchLiveStream = () => { + if (!R_Event?.pageConfig?.livestreamId) return + app.location.push(`/tv/live/${R_Event.pageConfig.livestreamId}`) + } + + const handleClickAddToCalendar = () => { + createGoogleCalendarEvent({ + title: R_Event.name, + startDate: new Date(R_Event.startDate), + endDate: new Date(R_Event.endDate), + description: `${R_Event.shortDescription} - See details at ${location.href}`, + location: R_Event.location, + }) + } + + if (E_Event) { + return null + } + + if (L_Event) { + return + } + + if (!R_Event) { + return null + } + + const eventStartedOrEnded = started || ended + const startDate = DateTime.fromISO(R_Event.startDate) + const endDate = DateTime.fromISO(R_Event.endDate) + const { pageConfig } = R_Event + + return ( +
+ + +
+
+
+ {started && !ended && ( +
+
+
+

Started

+
+
+ +
+
+ )} + + {!started && ( + + )} + +
+
+ + {ProcessString(LocationProcessRegexs)( + R_Event.location, + )} +
+
+
+ + {!eventStartedOrEnded && ( +
+ +
+ )} + + {started && pageConfig.livestreamId && ( +
+ +
+ )} +
+ +
+
+
+ +
+
+
+
+
+ ) +} + +export default EventPage diff --git a/packages/app/src/pages/event/index.less b/packages/app/src/pages/event/index.less new file mode 100755 index 00000000..91c53c00 --- /dev/null +++ b/packages/app/src/pages/event/index.less @@ -0,0 +1,158 @@ +.event { + display: flex; + flex-direction: column; + + align-items: center; + justify-content: flex-end; + + width: 100%; + gap: 20px; + + .header { + display: flex; + flex-direction: row; + + width: 100%; + height: 330px; + + padding: 20px; + + border-radius: 12px; + + background-position: center; + background-size: cover; + background-repeat: no-repeat; + + .logo { + height: 100%; + + width: fit-content; + + max-width: 200px; + + img { + height: 100%; + width: 100%; + } + } + + .title { + display: flex; + flex-direction: column; + + margin-left: 50px; + + padding: 20px 0; + + font-family: "Space Grotesk", sans-serif; + + h1 { + font-size: 2rem; + font-weight: 700; + } + } + + h1, + h2, + h3, + h4, + h5, + h6, + p, + span { + color: inherit; + } + } + + .content { + display: grid; + + width: 100%; + + grid-template-columns: 30% 1fr; + grid-template-rows: 1fr; + grid-column-gap: 20px; + grid-row-gap: 20px; + + .panel { + display: flex; + flex-direction: column; + + height: fit-content; + + gap: 20px; + + .card { + display: flex; + flex-direction: column; + + height: fit-content; + + padding: 20px; + + background-color: var(--background-color-accent); + + border-radius: 12px; + } + } + } + + .page-render { + display: flex; + flex-direction: column; + gap: 10px; + + ul { + margin: 0 30px; + padding: 0; + } + } +} + +.field { + display: inline-flex; + flex-direction: row; + + align-items: center; + justify-content: space-between; + + width: 100%; + gap: 10px; + + .field-label, + .field-value { + display: flex; + flex-direction: row; + + gap: 4px; + + align-items: center; + } +} + +.started_tag { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; +} + +.pulse_circle { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: green; + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + } +} diff --git a/packages/app/src/pages/featured-event/[id].jsx b/packages/app/src/pages/featured-event/[id].jsx deleted file mode 100755 index 283bc627..00000000 --- a/packages/app/src/pages/featured-event/[id].jsx +++ /dev/null @@ -1,166 +0,0 @@ -import React from "react" -import { Skeleton, Button } from "antd" -import ReactMarkdown from "react-markdown" -import remarkGfm from "remark-gfm" - -import { Icons } from "@components/Icons" - -import ContrastYIQ from "@utils/contrastYIQ" -import ProcessString from "@utils/processString" - -import "./index.less" - -const LocationProcessRegexs = [ - { - regex: /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi, - fn: (key, result) => { - return {result[1]} - } - } -] - -export default (props) => { - const eventId = props.params["id"] - - const [eventData, setEventData] = React.useState(null) - - const fetchEventData = async () => { - const { data } = await app.cores.api.customRequest({ - method: "GET", - url: `/featured_event/${eventId}` - }).catch((err) => { - console.error(err) - app.message.error("Failed to fetch event data") - - return { - data: null - } - }) - - if (data) { - try { - if (data.announcement) { - data.announcement = JSON.parse(data.announcement) - } - - if (data.customHeader) { - data.customHeader = JSON.parse(data.customHeader) - } - - setEventData(data) - } catch (error) { - console.error(error) - app.message.error("Failed to parse event data") - } - } - } - - const renderDates = (dates) => { - return
-
- { - dates[0] - } -
- - to - -
- { - dates[1] - } -
-
- } - - console.log(eventData) - - React.useEffect(() => { - fetchEventData() - }, []) - - React.useEffect(() => { - // get average color of custom background image - if (eventData?.customHeader.style) { - let backgroundImage = eventData.customHeader.style.backgroundImage - - if (backgroundImage) { - backgroundImage = backgroundImage.replace("url(", "").replace(")", "").replace(/\"/gi, "").replace(/\"/gi, "").replace(/\'/gi, "") - - console.log(backgroundImage) - - ContrastYIQ.fromUrl(backgroundImage).then((contrastColor) => { - console.log(`YIQ returns [${contrastColor}] as contrast color`) - document.getElementById("eventHeader").style.color = `var(--text-color-${contrastColor})` - }) - } - } - }, [eventData]) - - if (!eventData) { - return - } - - return
- { - eventData.customHeader && -
- { - eventData.customHeader.displayLogo &&
- -
- } - { - eventData.customHeader.displayTitle &&
-

{eventData.name}

-

{eventData.announcement.description}

-
- } -
- } - - { - !eventData.customHeader &&
- {eventData.announcement.logoImg && -
- -
- } -
-

{eventData.name}

-

{eventData.announcement.description}

-
-
- } - -
-
-
-
- {Array.isArray(eventData.dates) && renderDates(eventData.dates)} -
- -
- {ProcessString(LocationProcessRegexs)(eventData.location)} -
-
- -
- -
-
- -
-
-
- - {eventData.description} - -
-
-
-
-
-} \ No newline at end of file diff --git a/packages/app/src/pages/featured-event/index.less b/packages/app/src/pages/featured-event/index.less deleted file mode 100755 index 8146ada6..00000000 --- a/packages/app/src/pages/featured-event/index.less +++ /dev/null @@ -1,117 +0,0 @@ -.event { - display: flex; - flex-direction: column; - - align-items: center; - justify-content: flex-end; - - color: var(--text-color); - - .header { - display: flex; - flex-direction: row; - - width: 100%; - - padding: 20px; - - margin-bottom: 20px; - - border-radius: 12px; - - &.custom { - background-position: center; - background-size: cover; - background-repeat: no-repeat; - } - - .logo { - height: 100%; - - width: fit-content; - - max-width: 200px; - - img { - height: 100%; - width: 100%; - } - } - - .title { - display: flex; - flex-direction: column; - - margin-left: 50px; - - padding: 20px 0; - - font-family: "Space Grotesk", sans-serif; - - h1 { - font-size: 2rem; - font-weight: 700; - } - } - - h1, - h2, - h3, - h4, - h5, - h6, - p, - span { - color: inherit; - } - } - - .content { - display: grid; - grid-template-columns: 30% 1fr; - grid-template-rows: 1fr; - grid-column-gap: 20px; - grid-row-gap: 0px; - - .panel { - display: flex; - flex-direction: column; - - height: fit-content; - - .card { - display: flex; - flex-direction: column; - - height: fit-content; - - padding: 20px; - - background-color: var(--background-color-accent); - - border-radius: 12px; - - margin-bottom: 20px; - } - } - } - - .dates { - display: flex; - flex-direction: row; - - margin-bottom: 10px; - - .startsAt { - display: flex; - - margin-right: 10px; - } - - .endsAt { - display: flex; - - margin-left: 10px; - } - } -} \ No newline at end of file diff --git a/packages/app/src/utils/createGoogleCalendarEvent/index.js b/packages/app/src/utils/createGoogleCalendarEvent/index.js new file mode 100644 index 00000000..e364b375 --- /dev/null +++ b/packages/app/src/utils/createGoogleCalendarEvent/index.js @@ -0,0 +1,41 @@ +export default (eventDetails) => { + // validate required parameters + if ( + !eventDetails.title || + !eventDetails.startDate || + !eventDetails.endDate + ) { + throw new Error( + "Title, start date, and end date are required parameters", + ) + } + + // format dates for calendar URL + const formatDate = (date) => { + return date.toISOString().replace(/-|:|\.\d+/g, "") + } + + const startTime = formatDate(eventDetails.startDate) + const endTime = formatDate(eventDetails.endDate) + + // create calendar URL (Google Calendar format) + let calendarUrl = + "https://calendar.google.com/calendar/render?action=TEMPLATE" + + // add event details to URL + calendarUrl += `&text=${encodeURIComponent(eventDetails.title)}` + calendarUrl += `&dates=${startTime}/${endTime}` + + if (eventDetails.description) { + calendarUrl += `&details=${encodeURIComponent(eventDetails.description)}` + } + + if (eventDetails.location) { + calendarUrl += `&location=${encodeURIComponent(eventDetails.location)}` + } + + // open the calendar URL in a new tab + window.open(calendarUrl, "_blank") + + return calendarUrl +}