From 0a2bb2069cee683d525596dfe0141cac60f0f977 Mon Sep 17 00:00:00 2001 From: Daniel Karbach 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 @@ +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 }) => + }> + } + /> + } + /> + } + /> + } /> + } + /> + } + /> + + } /> + } /> + + } + /> + } + /> + } + /> + } + /> + } /> + } + /> + } + /> + } /> + } /> + } /> + } /> + + } + /> +; + +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 = () => { {t('general.appName')} - - }> - } - /> - } - /> - } /> - } - /> - } - /> - - } /> - } /> - - } - /> - } - /> - } - /> - } - /> - } /> - } - /> - } - /> - } /> - } /> - } /> - } /> - - } - /> - + ; 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 ?
- {episode.event.title} + + {episode.event.title} +
: null} @@ -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 <> +
+

{event.title}

+ {event.description && actions.editContent ? + + : null} +
+ {event.description ? + + : 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 ; + } + + if (error) { + return ; + } + + if (!event) { + return ; + } + + return + + {event.title} + + + + + {episodes.length ? <> +

{i18n.t('events.upcomingEpisodes')}

+ + : null} +
+ { setShowContentDialog(false); }} + onSubmit={saveContent} + show={showContentDialog} + /> +
; +}; + +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.2