public function single(Request $request, Event $event) {
$this->authorize('view', $event);
+ $event->load('description');
return $event->toJson();
}
use HasFactory;
+ public function description() {
+ return $this->belongsTo(Technique::class);
+ }
+
public function episodes() {
return $this->hasMany(Episode::class);
}
--- /dev/null
+<?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');
+ });
+ }
+};
--- /dev/null
+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;
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';
<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>;
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';
: 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>
})),
event: PropTypes.shape({
corner: PropTypes.string,
+ name: PropTypes.string,
title: PropTypes.string,
}),
players: PropTypes.arrayOf(PropTypes.shape({
--- /dev/null
+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;
--- /dev/null
+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);
heading: 'Serverfehler',
},
},
+ events: {
+ upcomingEpisodes: 'Anstehende Rennen',
+ },
footer: {
alttpde: 'Deutscher ALttP Discord',
alttpwiki: 'ALttP Speedrunning Wiki',
heading: 'Server error',
},
},
+ events: {
+ upcomingEpisodes: 'Upcoming races',
+ },
footer: {
alttpde: 'German ALttP Discord',
alttpwiki: 'ALttP Speedrunning Wiki',
// Colors
$bronze: #ad8a56;
+$challonge: #ff7324;
$discord: #5865f2;
$gold: #c9b037;
$silver: #b4b4b4;
// Custom variant
$custom-colors: (
+ "challonge": $challonge,
"discord": $discord,
"twitch": $twitch,
"youtube": $youtube
grid-template-columns: 1fr 1fr;
}
+ .event-link {
+ color: inherit;
+ text-decoration: none;
+ &:hover {
+ color: $link-color;
+ text-decoration: underline;
+ }
+ }
.player-link {
border: none;