From 0a2bb2069cee683d525596dfe0141cac60f0f977 Mon Sep 17 00:00:00 2001
From: Daniel Karbach <daniel.karbach@localhorst.tv>
Date: Wed, 9 Aug 2023 12:14:02 +0200
Subject: [PATCH] event details

---
 app/Http/Controllers/EventController.php      |   1 +
 app/Models/Event.php                          |   4 +
 .../2023_08_06_135347_event_description.php   |  33 ++++
 resources/js/components/app/Routes.js         |  84 ++++++++++
 resources/js/components/app/index.js          |  72 +--------
 resources/js/components/episodes/Item.js      |   6 +-
 resources/js/components/events/Detail.js      |  46 ++++++
 resources/js/components/pages/Event.js        | 148 ++++++++++++++++++
 resources/js/i18n/de.js                       |   3 +
 resources/js/i18n/en.js                       |   3 +
 resources/sass/_variables.scss                |   2 +
 resources/sass/episodes.scss                  |   8 +
 12 files changed, 340 insertions(+), 70 deletions(-)
 create mode 100644 database/migrations/2023_08_06_135347_event_description.php
 create mode 100644 resources/js/components/app/Routes.js
 create mode 100644 resources/js/components/events/Detail.js
 create mode 100644 resources/js/components/pages/Event.js

diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php
index bfe9efd..a0dce45 100644
--- a/app/Http/Controllers/EventController.php
+++ b/app/Http/Controllers/EventController.php
@@ -32,6 +32,7 @@ class EventController extends Controller
 
 	public function single(Request $request, Event $event) {
 		$this->authorize('view', $event);
+		$event->load('description');
 		return $event->toJson();
 	}
 
diff --git a/app/Models/Event.php b/app/Models/Event.php
index b566dbb..331a384 100644
--- a/app/Models/Event.php
+++ b/app/Models/Event.php
@@ -10,6 +10,10 @@ class Event extends Model
 
 	use HasFactory;
 
+	public function description() {
+		return $this->belongsTo(Technique::class);
+	}
+
 	public function episodes() {
 		return $this->hasMany(Episode::class);
 	}
diff --git a/database/migrations/2023_08_06_135347_event_description.php b/database/migrations/2023_08_06_135347_event_description.php
new file mode 100644
index 0000000..27e30ce
--- /dev/null
+++ b/database/migrations/2023_08_06_135347_event_description.php
@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+	/**
+	 * Run the migrations.
+	 *
+	 * @return void
+	 */
+	public function up()
+	{
+		Schema::table('events', function(Blueprint $table) {
+			$table->foreignId('description_id')->nullable()->default(null)->references('id')->on('techniques')->constrained();
+		});
+	}
+
+	/**
+	 * Reverse the migrations.
+	 *
+	 * @return void
+	 */
+	public function down()
+	{
+		Schema::table('events', function(Blueprint $table) {
+			$table->dropForeign(['description_id']);
+			$table->dropColumn('description_id');
+		});
+	}
+};
diff --git a/resources/js/components/app/Routes.js b/resources/js/components/app/Routes.js
new file mode 100644
index 0000000..396b2a7
--- /dev/null
+++ b/resources/js/components/app/Routes.js
@@ -0,0 +1,84 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Navigate, Route, Routes } from 'react-router-dom';
+
+import FullLayout from './FullLayout';
+import AlttpSeed from '../pages/AlttpSeed';
+import DoorsTracker from '../pages/DoorsTracker';
+import Event from '../pages/Event';
+import Front from '../pages/Front';
+import Map from '../pages/Map';
+import Schedule from '../pages/Schedule';
+import Technique from '../pages/Technique';
+import Techniques from '../pages/Techniques';
+import Tournament from '../pages/Tournament';
+import User from '../pages/User';
+
+const AppRoutes = ({ doLogout }) => <Routes>
+	<Route element={<FullLayout doLogout={doLogout} />}>
+		<Route
+			path="dungeons"
+			element={<Techniques namespace="dungeons" type="dungeon" />}
+		/>
+		<Route
+			path="dungeons/:name"
+			element={<Technique namespace="dungeons" type="dungeon" />}
+		/>
+		<Route
+			path="events/:name"
+			element={<Event />}
+		/>
+		<Route path="h/:hash" element={<AlttpSeed />} />
+		<Route
+			path="locations"
+			element={<Techniques namespace="locations" type="location" />}
+		/>
+		<Route
+			path="locations/:name"
+			element={<Technique namespace="locations" type="location" />}
+		/>
+		<Route path="map">
+			<Route index element={<Navigate replace to="lw" />} />
+			<Route path=":activeMap" element={<Map />} />
+		</Route>
+		<Route
+			path="modes"
+			element={<Techniques namespace="modes" type="mode" />}
+		/>
+		<Route
+			path="modes/:name"
+			element={<Technique namespace="modes" type="mode" />}
+		/>
+		<Route
+			path="rulesets"
+			element={<Techniques namespace="rulesets" type="ruleset" />}
+			/>
+		<Route
+			path="rulesets/:name"
+			element={<Technique namespace="rulesets" type="ruleset" />}
+		/>
+		<Route path="schedule" element={<Schedule />} />
+		<Route
+			path="tech"
+			element={<Techniques namespace="techniques" type="tech" />}
+		/>
+		<Route
+			path="tech/:name"
+			element={<Technique namespace="techniques" type="tech" />}
+		/>
+		<Route path="tournaments/:id" element={<Tournament />} />
+		<Route path="users/:id" element={<User />} />
+		<Route path="/" element={<Front />} />
+		<Route path="*" element={<Navigate to="/" />} />
+	</Route>
+	<Route
+		path="doors-tracker"
+		element={<DoorsTracker />}
+	/>
+</Routes>;
+
+AppRoutes.propTypes = {
+	doLogout: PropTypes.func,
+};
+
+export default AppRoutes;
diff --git a/resources/js/components/app/index.js b/resources/js/components/app/index.js
index e7298d7..4f37e9d 100644
--- a/resources/js/components/app/index.js
+++ b/resources/js/components/app/index.js
@@ -2,18 +2,9 @@ import axios from 'axios';
 import React, { useEffect, useState } from 'react';
 import { Helmet } from 'react-helmet';
 import { useTranslation } from 'react-i18next';
-import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
+import { BrowserRouter } from 'react-router-dom';
 
-import FullLayout from './FullLayout';
-import AlttpSeed from '../pages/AlttpSeed';
-import DoorsTracker from '../pages/DoorsTracker';
-import Front from '../pages/Front';
-import Map from '../pages/Map';
-import Schedule from '../pages/Schedule';
-import Technique from '../pages/Technique';
-import Techniques from '../pages/Techniques';
-import Tournament from '../pages/Tournament';
-import User from '../pages/User';
+import Routes from './Routes';
 import AlttpBaseRomProvider from '../../helpers/AlttpBaseRomContext';
 import UserContext from '../../helpers/UserContext';
 import i18n from '../../i18n';
@@ -68,64 +59,7 @@ const App = () => {
 					<title>{t('general.appName')}</title>
 					<meta name="description" content={t('general.appDescription')} />
 				</Helmet>
-				<Routes>
-					<Route element={<FullLayout doLogout={doLogout} />}>
-						<Route
-							path="dungeons"
-							element={<Techniques namespace="dungeons" type="dungeon" />}
-						/>
-						<Route
-							path="dungeons/:name"
-							element={<Technique namespace="dungeons" type="dungeon" />}
-						/>
-						<Route path="h/:hash" element={<AlttpSeed />} />
-						<Route
-							path="locations"
-							element={<Techniques namespace="locations" type="location" />}
-						/>
-						<Route
-							path="locations/:name"
-							element={<Technique namespace="locations" type="location" />}
-						/>
-						<Route path="map">
-							<Route index element={<Navigate replace to="lw" />} />
-							<Route path=":activeMap" element={<Map />} />
-						</Route>
-						<Route
-							path="modes"
-							element={<Techniques namespace="modes" type="mode" />}
-						/>
-						<Route
-							path="modes/:name"
-							element={<Technique namespace="modes" type="mode" />}
-						/>
-						<Route
-							path="rulesets"
-							element={<Techniques namespace="rulesets" type="ruleset" />}
-							/>
-						<Route
-							path="rulesets/:name"
-							element={<Technique namespace="rulesets" type="ruleset" />}
-						/>
-						<Route path="schedule" element={<Schedule />} />
-						<Route
-							path="tech"
-							element={<Techniques namespace="techniques" type="tech" />}
-						/>
-						<Route
-							path="tech/:name"
-							element={<Technique namespace="techniques" type="tech" />}
-						/>
-						<Route path="tournaments/:id" element={<Tournament />} />
-						<Route path="users/:id" element={<User />} />
-						<Route path="/" element={<Front />} />
-						<Route path="*" element={<Navigate to="/" />} />
-					</Route>
-					<Route
-						path="doors-tracker"
-						element={<DoorsTracker />}
-					/>
-				</Routes>
+				<Routes doLogout={doLogout} />
 			</UserContext.Provider>
 		</AlttpBaseRomProvider>
 	</BrowserRouter>;
diff --git a/resources/js/components/episodes/Item.js b/resources/js/components/episodes/Item.js
index 9acb95d..d7c4bab 100644
--- a/resources/js/components/episodes/Item.js
+++ b/resources/js/components/episodes/Item.js
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import { Button } from 'react-bootstrap';
 import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
 
 import Channels from './Channels';
 import Crew from './Crew';
@@ -102,7 +103,9 @@ const Item = ({ episode, onAddRestream, onApply, onEditRestream, user }) => {
 			: null}
 			{episode.event ?
 				<div className="episode-event mt-auto">
-					{episode.event.title}
+					<Link className="event-link" to={`/events/${episode.event.name}`}>
+						{episode.event.title}
+					</Link>
 				</div>
 			: null}
 		</div>
@@ -118,6 +121,7 @@ Item.propTypes = {
 		})),
 		event: PropTypes.shape({
 			corner: PropTypes.string,
+			name: PropTypes.string,
 			title: PropTypes.string,
 		}),
 		players: PropTypes.arrayOf(PropTypes.shape({
diff --git a/resources/js/components/events/Detail.js b/resources/js/components/events/Detail.js
new file mode 100644
index 0000000..3626fbd
--- /dev/null
+++ b/resources/js/components/events/Detail.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+import RawHTML from '../common/RawHTML';
+import { getTranslation } from '../../helpers/Technique';
+import i18n from '../../i18n';
+
+const Detail = ({ actions, event }) => {
+	const { t } = useTranslation();
+
+	return <>
+		<div className="d-flex align-items-center justify-content-between">
+			<h1>{event.title}</h1>
+			{event.description && actions.editContent ?
+				<Button
+					className="ms-3"
+					onClick={() => actions.editContent(event.description)}
+					size="sm"
+					title={t('button.edit')}
+					variant="outline-secondary"
+				>
+					<Icon.EDIT title="" />
+				</Button>
+			: null}
+		</div>
+		{event.description ?
+			<RawHTML html={getTranslation(event.description, 'description', i18n.language)} />
+		: null}
+	</>;
+};
+
+Detail.propTypes = {
+	actions: PropTypes.shape({
+		editContent: PropTypes.func,
+	}),
+	event: PropTypes.shape({
+		description: PropTypes.shape({
+		}),
+		title: PropTypes.string,
+	}),
+};
+
+export default Detail;
diff --git a/resources/js/components/pages/Event.js b/resources/js/components/pages/Event.js
new file mode 100644
index 0000000..6c41c70
--- /dev/null
+++ b/resources/js/components/pages/Event.js
@@ -0,0 +1,148 @@
+import axios from 'axios';
+import moment from 'moment';
+import React from 'react';
+import { Container } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { withTranslation } from 'react-i18next';
+import { useParams } from 'react-router-dom';
+import toastr from 'toastr';
+
+import NotFound from './NotFound';
+import CanonicalLinks from '../common/CanonicalLinks';
+import ErrorBoundary from '../common/ErrorBoundary';
+import ErrorMessage from '../common/ErrorMessage';
+import Loading from '../common/Loading';
+import EpisodeList from '../episodes/List';
+import Detail from '../events/Detail';
+import Dialog from '../techniques/Dialog';
+import {
+	mayEditContent,
+} from '../../helpers/permissions';
+import { useUser } from '../../helpers/UserContext';
+import i18n from '../../i18n';
+
+const Event = () => {
+	const params = useParams();
+	const { name } = params;
+	const user = useUser();
+
+	const [error, setError] = React.useState(null);
+	const [loading, setLoading] = React.useState(true);
+	const [event, setEvent] = React.useState(null);
+
+	const [editContent, setEditContent] = React.useState(null);
+	const [episodes, setEpisodes] = React.useState([]);
+	const [showContentDialog, setShowContentDialog] = React.useState(false);
+
+	const actions = React.useMemo(() => ({
+		editContent: mayEditContent(user) ? content => {
+			setEditContent(content);
+			setShowContentDialog(true);
+		} : null,
+	}), [user]);
+
+	const fetchEpisodes = React.useCallback((controller, event) => {
+		if (!event) {
+			setEpisodes([]);
+			return;
+		}
+		axios.get(`/api/episodes`, {
+			signal: controller.signal,
+			params: {
+				after: moment().subtract(3, 'hours').toISOString(),
+				before: moment().add(14, 'days').toISOString(),
+				event: [event.id],
+			},
+		}).then(response => {
+			setEpisodes(response.data || []);
+		}).catch(e => {
+			if (!axios.isCancel(e)) {
+				console.error(e);
+			}
+		});
+	}, []);
+
+	const saveContent = React.useCallback(async values => {
+		try {
+			const response = await axios.put(`/api/content/${values.id}`, {
+				parent_id: event.description_id,
+				...values,
+			});
+			toastr.success(i18n.t('content.saveSuccess'));
+			setEvent(event => ({
+				...event,
+				description: response.data,
+			}));
+			setShowContentDialog(false);
+		} catch (e) {
+			toastr.error(i18n.t('content.saveError'));
+		}
+	}, [event && event.description_id]);
+
+	React.useEffect(() => {
+		const ctrl = new AbortController();
+		setLoading(true);
+		axios
+			.get(`/api/events/${name}`, { signal: ctrl.signal })
+			.then(response => {
+				setError(null);
+				setLoading(false);
+				setEvent(response.data);
+			})
+			.catch(error => {
+				setError(error);
+				setLoading(false);
+				setEvent(null);
+			});
+		return () => {
+			ctrl.abort();
+		};
+	}, [name]);
+
+	React.useEffect(() => {
+		const controller = new AbortController();
+		fetchEpisodes(controller, event);
+		const timer = setInterval(() => {
+			fetchEpisodes(controller, event);
+		}, 1.5 * 60 * 1000);
+		return () => {
+			controller.abort();
+			clearInterval(timer);
+		};
+	}, [event, fetchEpisodes]);
+
+	if (loading) {
+		return <Loading />;
+	}
+
+	if (error) {
+		return <ErrorMessage error={error} />;
+	}
+
+	if (!event) {
+		return <NotFound />;
+	}
+
+	return <ErrorBoundary>
+		<Helmet>
+			<title>{event.title}</title>
+		</Helmet>
+		<CanonicalLinks base={`/event/${event.name}`} />
+		<Container>
+			<Detail actions={actions} event={event} />
+			{episodes.length ? <>
+				<h2>{i18n.t('events.upcomingEpisodes')}</h2>
+				<EpisodeList episodes={episodes} />
+			</> : null}
+		</Container>
+		<Dialog
+			content={editContent}
+			language={i18n.language}
+			onHide={() => { setShowContentDialog(false); }}
+			onSubmit={saveContent}
+			show={showContentDialog}
+		/>
+	</ErrorBoundary>;
+};
+
+export default withTranslation()(Event);
diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js
index fd8bfed..28848e6 100644
--- a/resources/js/i18n/de.js
+++ b/resources/js/i18n/de.js
@@ -140,6 +140,9 @@ export default {
 				heading: 'Serverfehler',
 			},
 		},
+		events: {
+			upcomingEpisodes: 'Anstehende Rennen',
+		},
 		footer: {
 			alttpde: 'Deutscher ALttP Discord',
 			alttpwiki: 'ALttP Speedrunning Wiki',
diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js
index 179d684..4f4038a 100644
--- a/resources/js/i18n/en.js
+++ b/resources/js/i18n/en.js
@@ -140,6 +140,9 @@ export default {
 				heading: 'Server error',
 			},
 		},
+		events: {
+			upcomingEpisodes: 'Upcoming races',
+		},
 		footer: {
 			alttpde: 'German ALttP Discord',
 			alttpwiki: 'ALttP Speedrunning Wiki',
diff --git a/resources/sass/_variables.scss b/resources/sass/_variables.scss
index f546d4f..3c0d0e7 100644
--- a/resources/sass/_variables.scss
+++ b/resources/sass/_variables.scss
@@ -7,6 +7,7 @@ $line-height-base: 1.6;
 
 // Colors
 $bronze: #ad8a56;
+$challonge: #ff7324;
 $discord: #5865f2;
 $gold: #c9b037;
 $silver: #b4b4b4;
@@ -15,6 +16,7 @@ $youtube: #ff0000;
 
 // Custom variant
 $custom-colors: (
+	"challonge": $challonge,
 	"discord": $discord,
 	"twitch": $twitch,
 	"youtube": $youtube
diff --git a/resources/sass/episodes.scss b/resources/sass/episodes.scss
index d4248cb..45582a0 100644
--- a/resources/sass/episodes.scss
+++ b/resources/sass/episodes.scss
@@ -36,6 +36,14 @@
 		grid-template-columns: 1fr 1fr;
 	}
 
+	.event-link {
+		color: inherit;
+		text-decoration: none;
+		&:hover {
+			color: $link-color;
+			text-decoration: underline;
+		}
+	}
 	.player-link {
 		border: none;
 
-- 
2.39.5