]> git.localhorst.tv Git - alttp.git/commitdiff
event details
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 9 Aug 2023 10:14:02 +0000 (12:14 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 9 Aug 2023 10:14:02 +0000 (12:14 +0200)
12 files changed:
app/Http/Controllers/EventController.php
app/Models/Event.php
database/migrations/2023_08_06_135347_event_description.php [new file with mode: 0644]
resources/js/components/app/Routes.js [new file with mode: 0644]
resources/js/components/app/index.js
resources/js/components/episodes/Item.js
resources/js/components/events/Detail.js [new file with mode: 0644]
resources/js/components/pages/Event.js [new file with mode: 0644]
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/sass/_variables.scss
resources/sass/episodes.scss

index bfe9efd82eaa854f0f4836637f711264dfb0e379..a0dce45abb908bbd3202753f57b36f6957ed6af6 100644 (file)
@@ -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();
        }
 
index b566dbb5553925e36ba26bba2ce7ae44ad3d1964..331a384f8d1147deb04292552bdfec15a156eef4 100644 (file)
@@ -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 (file)
index 0000000..27e30ce
--- /dev/null
@@ -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 (file)
index 0000000..396b2a7
--- /dev/null
@@ -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;
index e7298d7b00a327be31f58f6b9540943728feb758..4f37e9d62845997702d45646b0750561ebb10c55 100644 (file)
@@ -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>;
index 9acb95d29859b0bb6242312edccb333c8918061b..d7c4bab4ff7d68245e87ff84f5a3cabe243fb554 100644 (file)
@@ -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 (file)
index 0000000..3626fbd
--- /dev/null
@@ -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 (file)
index 0000000..6c41c70
--- /dev/null
@@ -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);
index fd8bfede1335aae7500f1e490432a672a3de4bb9..28848e6462ae61cdce2ea4661fb09a87946ebf41 100644 (file)
@@ -140,6 +140,9 @@ export default {
                                heading: 'Serverfehler',
                        },
                },
+               events: {
+                       upcomingEpisodes: 'Anstehende Rennen',
+               },
                footer: {
                        alttpde: 'Deutscher ALttP Discord',
                        alttpwiki: 'ALttP Speedrunning Wiki',
index 179d6849ea13ebeffa60dcf40ab59f7406b976ab..4f4038a9310e28607454def2c3945a20a08b2a67 100644 (file)
@@ -140,6 +140,9 @@ export default {
                                heading: 'Server error',
                        },
                },
+               events: {
+                       upcomingEpisodes: 'Upcoming races',
+               },
                footer: {
                        alttpde: 'German ALttP Discord',
                        alttpwiki: 'ALttP Speedrunning Wiki',
index f546d4f0dbb496eb67b392707c3b61a0a46e6ef0..3c0d0e7460a876c1df890bea895db559d19918b3 100644 (file)
@@ -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
index d4248cb2e2bd2dc4f10d63f16ecc591df273a4a0..45582a06fd8b99746fde2861c1db6a2a0b870f8d 100644 (file)
                grid-template-columns: 1fr 1fr;
        }
 
+       .event-link {
+               color: inherit;
+               text-decoration: none;
+               &:hover {
+                       color: $link-color;
+                       text-decoration: underline;
+               }
+       }
        .player-link {
                border: none;