$validatedData = $request->validate([
'after' => 'nullable|date',
'before' => 'nullable|date',
+ 'order' => 'nullable|string',
+ 'with' => 'nullable|array',
+ 'with.*' => 'string',
]);
$events = Event::where('visible', '=', true);
if (isset($validatedData['before'])) {
$query->orWhere('end', '>', $validatedData['after']);
});
}
+ if (isset($validatedData['order'])) {
+ switch ($validatedData['order']) {
+ case 'recency':
+ $events->orderByRaw('start IS NOT NULL');
+ $events->orderByRaw('end IS NOT NULL');
+ $events->orderBy('end', 'DESC');
+ $events->orderBy('start', 'DESC');
+ $events->orderBy('name', 'ASC');
+ break;
+ }
+ }
+ if (isset($validatedData['with'])) {
+ if (in_array('description', $validatedData['with'])) {
+ $events->with('description');
+ }
+ }
return $events->get()->toJson();
}
namespace App\Http\Controllers;
+use App\Models\Episode;
+use App\Models\Event;
use App\Models\SitemapUrl;
use App\Models\Technique;
use App\Models\TechniqueMap;
$urls[] = $url;
}
+ $url = new SitemapUrl();
+ $url->path = '/schedule';
+ $url->lastmod = Episode::where('confirmed', true)->latest()->first()->created_at;
+ $url->changefreq = 'daily';
+ $url->priority = 1;
+ $urls[] = $url;
+
+ $url = new SitemapUrl();
+ $url->path = '/events';
+ $url->lastmod = Event::where('visible', true)->latest()->first()->created_at;
+ $url->changefreq = 'monthly';
+ $url->priority = 0.8;
+ $urls[] = $url;
+
+ foreach (Event::where('visible', true)->get() as $event) {
+ $url = new SitemapUrl();
+ $url->path = '/events/'.rawurlencode($event->name);
+ $url->lastmod = $event->updated_at ? $event->updated_at : ($event->created_at ? $event->created_at : now());
+ $url->changefreq = 'never';
+ $url->priority = 0.4;
+ $urls[] = $url;
+ }
+
return response()->view('sitemap', [
'urls' => $urls,
])->header('Content-Type', 'text/xml');
</Nav.Link>
</LinkContainer>
</Nav.Item>
+ <Nav.Item as="li">
+ <LinkContainer to="/schedule">
+ <Nav.Link className="p-0 text-muted" href="/schedule">
+ {t('footer.schedule')}
+ </Nav.Link>
+ </LinkContainer>
+ </Nav.Item>
+ <Nav.Item as="li">
+ <LinkContainer to="/events">
+ <Nav.Link className="p-0 text-muted" href="/events">
+ {t('footer.events')}
+ </Nav.Link>
+ </LinkContainer>
+ </Nav.Item>
</Nav>
</Col>
<Col md={4}>
{t('footer.muffins')}
</Nav.Link>
</Nav.Item>
- <Nav.Item as="li">
- <LinkContainer to="/schedule">
- <Nav.Link className="p-0 text-muted" href="/schedule">
- {t('footer.schedule')}
- </Nav.Link>
- </LinkContainer>
- </Nav.Item>
<Nav.Item as="li">
<Nav.Link
className="p-0 text-muted"
{t('footer.connect')}
</Nav.Link>
</Nav.Item>
+ <Nav.Item as="li">
+ <Nav.Link
+ className="p-0 text-muted"
+ href="https://discord.gg/cx6nZkekXz"
+ target="_blank"
+ >
+ {t('footer.restreamCentral')}
+ </Nav.Link>
+ </Nav.Item>
</Nav>
</Col>
</Row>
import DiscordBot from '../pages/DiscordBot';
import DoorsTracker from '../pages/DoorsTracker';
import Event from '../pages/Event';
+import Events from '../pages/Events';
import Front from '../pages/Front';
import Map from '../pages/Map';
import Schedule from '../pages/Schedule';
path="dungeons/:name"
element={<Technique basepath="dungeons" type="dungeon" />}
/>
+ <Route
+ path="events"
+ element={<Events />}
+ />
<Route
path="events/:name"
element={<Event />}
import Players from './Players';
import Icon from '../common/Icon';
import { hasPassed, hasSGRestream, isActive } from '../../helpers/Episode';
+import { getLink } from '../../helpers/Event';
import { canApplyForEpisode, canRestreamEpisode } from '../../helpers/permissions';
import { withUser } from '../../helpers/UserContext';
{episode.event ?
<div className="episode-event mt-auto">
{episode.event.description_id ?
- <Link className="event-link" to={`/events/${episode.event.name}`}>
+ <Link className="event-link" to={getLink(episode.event)}>
{episode.event.title}
</Link>
:
--- /dev/null
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
+
+import RawHTML from '../common/RawHTML';
+import {
+ getLink,
+} from '../../helpers/Event';
+import {
+ getTranslation,
+} from '../../helpers/Technique';
+import i18n from '../../i18n';
+
+const Item = ({ event }) => {
+ const { t } = useTranslation();
+
+ const style = React.useMemo(() => {
+ if (event && event.corner) {
+ return {
+ backgroundImage: `url(${event.corner})`,
+ };
+ }
+ return null;
+ }, [event && event.corner]);
+
+ return <li className="events-item my-3 p-2 pb-5 border rounded" style={style}>
+ <h3>
+ <Link to={getLink(event)}>
+ {(event.description && getTranslation(event.description, 'title', i18n.language))
+ || event.title}
+ </Link>
+ </h3>
+ <div className="d-flex align-items-start justify-content-start">
+ {event.start || event.end ?
+ <div className="event-pane">
+ {event.start ? <>
+ <div><small>{t('events.start')}</small></div>
+ <div className="mb-2">{moment(event.start).format('LL')}</div>
+ </> : null}
+ {event.end ? <>
+ <div><small>{t('events.end')}</small></div>
+ <div className="mb-2">{moment(event.end).format('LL')}</div>
+ </> : null}
+ </div>
+ : null}
+ {event.description?
+ <div>
+ <RawHTML
+ html={getTranslation(event.description, 'description', i18n.language)}
+ />
+ </div>
+ : null}
+ </div>
+ </li>;
+};
+
+Item.propTypes = {
+ event: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.number,
+ name: PropTypes.string,
+ })),
+};
+
+export default Item;
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Item from './Item';
+
+const List = ({ events }) => <ul className="event-list">
+ {events.map(event =>
+ <Item event={event} key={event.id} />
+ )}
+</ul>;
+
+List.propTypes = {
+ events: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.number,
+ name: PropTypes.string,
+ })),
+};
+
+export default List;
import moment from 'moment';
+export const getLink = event => `/events/${event.name}`;
+
export const hasConcluded = event => event && event.end && moment(event.end).isBefore(moment());
},
events: {
concluded: 'Diese Veranstaltung is abgeschlossen.',
+ end: 'Ende',
+ evergreen: 'Ständige Veranstaltungen',
+ heading: 'Veranstaltungen',
+ ongoing: 'Laufende Veranstaltungen',
+ past: 'Vergangene Veranstaltungen',
pastEpisodes: 'Vergangene Rennen',
+ start: 'Start',
upcomingEpisodes: 'Anstehende Rennen',
},
footer: {
competitions: 'Wettbewerbe',
connect: 'Connect Spedruns Discord',
contact: 'Wenn du gerne ein Turnier auf dieser Seite organisieren möchtest, wende dich bitte an holysmoke86 im Discord.',
+ events: 'Veranstaltungen',
info: 'Infos',
map: 'ALttP Karte',
muffins: 'Muffins\' Glitch Map (EN)',
privacy: 'Datenschutz',
resources: 'Ressourcen',
+ restreamCentral: 'Restream Central Discord',
schedule: 'Terminplan',
smd: 'Deutscher Super Metroid Discord',
smwiki: 'Super Metroid Speedrunning Wiki',
},
events: {
concluded: 'This event has concluded.',
+ end: 'End',
+ evergreen: 'Evergreen events',
+ heading: 'Events',
+ ongoing: 'Ongoing events',
+ past: 'Past events',
pastEpisodes: 'Past races',
+ start: 'Start',
upcomingEpisodes: 'Upcoming races',
},
footer: {
competitions: 'Competitions',
connect: 'Connect Spedruns Discord',
contact: 'If you would like to organize a Tournament on this site, please contact holysmoke86 on Discord.',
+ events: 'Events',
info: 'Infos',
map: 'ALttP Map',
muffins: 'Muffins\' Glitch Map',
privacy: 'Privacy',
resources: 'Resources',
+ restreamCentral: 'Restream Central Discord',
schedule: 'Schedule',
smd: 'German Super Metroid Discord',
smwiki: 'Super Metroid Speedrunning Wiki',
--- /dev/null
+import axios from 'axios';
+import React from 'react';
+import { Container } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+
+import CanonicalLinks from '../components/common/CanonicalLinks';
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import ErrorMessage from '../components/common/ErrorMessage';
+import Loading from '../components/common/Loading';
+import List from '../components/events/List';
+
+const Events = () => {
+ const { t } = useTranslation();
+
+ const [error, setError] = React.useState(null);
+ const [loading, setLoading] = React.useState(true);
+ const [events, setEvents] = React.useState([]);
+
+ const fetchEvents = React.useCallback(async (controller) => {
+ const params = {
+ order: 'recency',
+ with: ['description'],
+ };
+ try {
+ const response = await axios.get(`/api/events`, {
+ signal: controller.signal,
+ params,
+ });
+ return response.data || [];
+ } catch (error) {
+ if (!axios.isCancel(error)) {
+ throw error;
+ }
+ return [];
+ }
+ }, []);
+
+ React.useEffect(() => {
+ const controller = new AbortController();
+ setLoading(true);
+ fetchEvents(controller)
+ .then(events => {
+ setError(null);
+ setLoading(false);
+ setEvents(events);
+ })
+ .catch(error => {
+ setError(error);
+ setLoading(false);
+ setEvents([]);
+ });
+ return () => {
+ controller.abort();
+ };
+ }, [fetchEvents]);
+
+ const evergreen = React.useMemo(() =>
+ events.filter(event => !event.start)
+ , [events]);
+ const ongoing = React.useMemo(() =>
+ events.filter(event => event.start && !event.end)
+ , [events]);
+ const past = React.useMemo(() =>
+ events.filter(event => event.end)
+ , [events]);
+
+ if (loading) {
+ return <Loading />;
+ }
+
+ if (error) {
+ return <ErrorMessage error={error} />;
+ }
+
+ return <ErrorBoundary>
+ <Helmet>
+ <title>
+ {t('events.heading')}
+ </title>
+ </Helmet>
+ <CanonicalLinks base={`/events`} />
+ <Container>
+ <h1>{t('events.ongoing')}</h1>
+ <List events={ongoing} />
+ <h1>{t('events.evergreen')}</h1>
+ <List events={evergreen} />
+ <h1>{t('events.past')}</h1>
+ <List events={past} />
+ </Container>
+ </ErrorBoundary>;
+};
+
+export default Events;
@import 'discord';
@import 'doors';
@import 'episodes';
+@import 'events';
@import 'form';
@import 'front';
@import 'map';
--- /dev/null
+.event-list {
+ list-style: none;
+ padding: 0;
+}
+
+.events-item {
+ background-size: 6rem auto;
+ background-repeat: no-repeat;
+ background-position: left bottom;
+
+ .event-pane {
+ width: 10rem;
+ }
+}