Upgrade featured event system

This commit is contained in:
SrGooglo 2025-04-01 21:48:54 +00:00
parent 4a4e5d2bd5
commit e596f7f8bb
9 changed files with 604 additions and 439 deletions

View File

@ -3,42 +3,49 @@ import { Icons } from "@components/Icons"
import "./index.less" import "./index.less"
export default (props) => { const FeaturedEventAnnouncement = (props) => {
const { backgroundImg, backgroundStyle, logoImg, title, description } = props.data?.announcement ?? {} const { announcement } = props.data
const onClickEvent = () => { const onClickEvent = () => {
if (!props.data?._id) { if (!props.data?._id) {
console.error("No event ID provided") console.error("No event ID provided")
return false return false
} }
app.location.push(`/featured-event/${props.data?._id}`) app.location.push(`/event/${props.data?._id}`)
} }
return <div if (!announcement) {
key={props.index} return null
className="featuredEvent" }
style={{
backgroundImage: `url(${backgroundImg})`,
...backgroundStyle
}}
onClick={onClickEvent}
>
<div className="featuredEvent_wrapper">
<div className="logo">
<img
src={logoImg}
/>
</div>
<div className="content"> return (
<h1>{title}</h1> <div
<h3>{description}</h3> key={props.index}
</div> className="featured_event"
</div> style={{
backgroundImage: `url(${announcement.backgroundImg})`,
...announcement.backgroundStyle,
}}
onClick={onClickEvent}
>
<div className="featured_event-logo">
<img
src={announcement.logoImg}
style={announcement.logoStyle}
/>
</div>
<div className="indicator"> <div className="featured_event-content">
<Icons.FiTarget /> <span>Featured event</span> <h1>{announcement.title}</h1>
</div> <h3>{announcement.description}</h3>
</div> </div>
}
<div className="featured_event-indicator">
<Icons.FiTarget /> <span>Featured event</span>
</div>
</div>
)
}
export default FeaturedEventAnnouncement

View File

@ -1,103 +1,88 @@
.featuredEvent { .featured_event {
position: relative; position: relative;
z-index: 50; z-index: 50;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; width: 100%;
//justify-content: flex-start; min-height: 120px;
min-width: 300px; border-radius: 12px;
max-width: 350px;
border-radius: 12px; background-repeat: no-repeat;
background-size: cover;
background-position: center;
padding: 10px 20px; overflow: hidden;
background-repeat: no-repeat; cursor: pointer;
background-size: cover;
background-position: center;
cursor: pointer; .featured_event-logo {
height: 100%;
width: 155px;
.featuredEvent_wrapper { padding: 20px;
display: flex;
flex-direction: row;
width: 100%; img {
height: 100%; height: 100%;
width: 100%;
}
}
align-items: center; .featured_event-content {
justify-content: space-between; display: flex;
flex-direction: column;
.logo { align-items: flex-start;
height: 100%; 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 { color: inherit;
height: 100%;
width: 100%;
}
}
.content { overflow: hidden;
display: flex;
flex-direction: column;
align-self: flex-end;
width: 100%; text-overflow: ellipsis;
max-width: calc(90% - 70px); white-space: nowrap;
}
overflow: hidden; h3 {
font-size: 0.8rem;
font-weight: 400;
margin-left: 20px; color: inherit;
margin-bottom: 10px; overflow: hidden;
h1 { text-overflow: ellipsis;
font-size: 1.5rem; white-space: nowrap;
font-weight: 700; word-break: break-all;
font-family: "Space Grotesk", sans-serif; }
}
color: inherit; .featured_event-indicator {
position: absolute;
z-index: 55;
overflow: hidden; font-size: 0.6rem;
text-overflow: ellipsis; bottom: 0;
white-space: nowrap; right: 0;
}
h3 { padding: 10px;
font-size: 0.8rem;
font-weight: 400;
color: inherit; background-color: rgba(0, 0, 0, 0.5);
border-radius: 12px 0 0 0;
overflow: hidden; svg,
span {
text-overflow: ellipsis; color: inherit;
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;
}
}
}

View File

@ -1,47 +1,29 @@
import React from "react" import React from "react"
import Announcement from "../FeaturedEventAnnouncement" import Announcement from "../FeaturedEventAnnouncement"
import EventsModel from "@models/events"
import "./index.less" import "./index.less"
export default React.memo((props) => { const FeaturedEventsAnnouncements = React.memo((props) => {
const [featuredEvents, setFeaturedEvents] = React.useState([]) const [
L_FeaturedEvents,
R_FeaturedEvents,
E_FeaturedEvents,
M_FeaturedEvents,
] = app.cores.api.useRequest(EventsModel.getFeatured)
const fetchFeaturedEvents = React.useCallback(async () => { if (!Array.isArray(R_FeaturedEvents)) {
let { data } = await app.cores.api.customRequest({ return null
url: "/featured_events", }
method: "GET"
}).catch((err) => {
console.error(err)
app.message.error(`Failed to fetch featured events`)
return { return (
data: null <div className="featuredEvents">
} {R_FeaturedEvents.map((event, index) => (
}) <Announcement index={index} data={event} />
))}
</div>
)
})
if (data) { export default FeaturedEventsAnnouncements
// 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 <div className="featuredEvents">
{featuredEvents.map((event, index) => <Announcement index={index} data={event} />)}
</div>
})

View File

@ -1,8 +1,6 @@
.featuredEvents { .featuredEvents {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.featuredEvent { width: 100%;
margin-bottom: 20px; }
}
}

View File

@ -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) => (
<a
key={key}
href={result[1]}
target="_blank"
rel="noopener noreferrer"
>
{result[1]}
</a>
),
},
]
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 (
<div className="field">
<div className="field-label">
<Icons.FiClock />
<p>
{prefix} {label}
</p>
</div>
</div>
)
}
const EventStartDate = ({ startDate, started }) => (
<Popover
content={
<EventCountdown
date={startDate}
prefix={started ? "Started" : "Starts"}
/>
}
>
<div className="field">
<div className="field-label">
<Icons.FiCalendar />
{startDate.toLocaleString(DateTime.DATE_FULL)}
</div>
<div className="field-label">
<Icons.FiClock />
{startDate.toLocaleString(DateTime.TIME_SIMPLE)}
</div>
</div>
</Popover>
)
const EventHeader = ({ pageConfig, event, contrastColor }) => {
if (pageConfig.header) {
return (
<div
id="eventHeader"
className="header custom"
style={{
...(pageConfig.header.style ?? {}),
color: `var(--text-color-${contrastColor})`,
}}
>
{pageConfig.header.displayLogo && (
<div className="logo">
<img src={pageConfig.header.logoImg} alt="Event Logo" />
</div>
)}
{pageConfig.header.displayTitle && (
<div className="title">
<h1>{pageConfig.header.title}</h1>
<h2>{pageConfig.header.description}</h2>
</div>
)}
</div>
)
}
return (
<div
className="header"
style={event.announcement.backgroundStyle}
id="eventHeader"
>
{event.announcement.logoImg && (
<div className="logo">
<img src={event.announcement.logoImg} alt="Event Logo" />
</div>
)}
<div className="title">
<h1>{event.name}</h1>
<h2>{event.announcement.description}</h2>
</div>
</div>
)
}
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 <Skeleton active />
}
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 (
<div className="event">
<EventHeader
pageConfig={pageConfig}
event={R_Event}
contrastColor={contrastColor}
/>
<div className="content">
<div className="panel">
<div className="card">
{started && !ended && (
<div className="field">
<div className="field-label">
<div className="pulse_circle" />
<p>Started</p>
</div>
<div className="field-value">
<EventCountdown
date={endDate}
prefix="Ends"
/>
</div>
</div>
)}
{!started && (
<EventStartDate
startDate={startDate}
started={started}
/>
)}
<div className="field">
<div className="field-label">
<Icons.FiMapPin />
{ProcessString(LocationProcessRegexs)(
R_Event.location,
)}
</div>
</div>
</div>
{!eventStartedOrEnded && (
<div className="card">
<Button onClick={handleClickAddToCalendar}>
<Icons.FiCalendar /> Add to Calendar
</Button>
</div>
)}
{started && pageConfig.livestreamId && (
<div className="card">
<Button
type="primary"
onClick={handleClickWatchLiveStream}
>
<Icons.FiPlay /> Watch Live
</Button>
</div>
)}
</div>
<div className="panel">
<div className="card">
<div className="page-render">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
children={R_Event.page}
/>
</div>
</div>
</div>
</div>
</div>
)
}
export default EventPage

View File

@ -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);
}
}

View File

@ -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 <a key={key} href={result[1]} target="_blank" rel="noopener noreferrer">{result[1]}</a>
}
}
]
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 <div className="dates">
<div className="startsAt">
{
dates[0]
}
</div>
<span className="separator">
to
</span>
<div className="endsAt">
{
dates[1]
}
</div>
</div>
}
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 <Skeleton active />
}
return <div className="event">
{
eventData.customHeader &&
<div className="header custom" style={eventData.customHeader.style} id="eventHeader">
{
eventData.customHeader.displayLogo && <div className="logo">
<img src={eventData.announcement.logoImg} />
</div>
}
{
eventData.customHeader.displayTitle && <div className="title">
<h1>{eventData.name}</h1>
<h2>{eventData.announcement.description}</h2>
</div>
}
</div>
}
{
!eventData.customHeader && <div className="header" style={eventData.announcement.backgroundStyle} id="eventHeader">
{eventData.announcement.logoImg &&
<div className="logo">
<img src={eventData.announcement.logoImg} />
</div>
}
<div className="title">
<h1>{eventData.name}</h1>
<h2>{eventData.announcement.description}</h2>
</div>
</div>
}
<div className="content">
<div className="panel">
<div className="card">
<div className="dates">
<Icons.FiCalendar /> {Array.isArray(eventData.dates) && renderDates(eventData.dates)}
</div>
<div className="location">
<Icons.FiMapPin /> {ProcessString(LocationProcessRegexs)(eventData.location)}
</div>
</div>
<div className="card">
<Button>
<Icons.FiCalendar /> Add to Calendar
</Button>
</div>
</div>
<div className="panel">
<div className="card">
<div className="description">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{eventData.description}
</ReactMarkdown>
</div>
</div>
</div>
</div>
</div>
}

View File

@ -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;
}
}
}

View File

@ -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
}