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