'event' => 'nullable|array',
'event.*' => 'numeric',
'eventInvert' => 'boolean',
+ 'game' => 'nullable|array',
+ 'game.*' => 'string',
+ 'gameInvert' => 'boolean',
'limit' => 'numeric',
'offset' => 'numeric',
'reverse' => 'boolean',
$episodes = $episodes->whereIn('episodes.event_id', $validatedData['event']);
}
}
+ if (!empty($validatedData['game'])) {
+ if (isset($validatedData['gameInvert']) && $validatedData['gameInvert']) {
+ $episodes->where(function ($query) use ($validatedData) {
+ $query->whereNotIn(DB::raw('COALESCE(`episodes`.`game`, `events`.`game`)'), $validatedData['game']);
+ $query->orWhereNull(DB::raw('COALESCE(`episodes`.`game`, `events`.`game`)'));
+ });
+ } else {
+ $episodes->whereIn(DB::raw('COALESCE(`episodes`.`game`, `events`.`game`)'), $validatedData['game']);
+ }
+ }
if ($request->user() && $request->user()->isPrivileged()) {
$episodes = $episodes->with(['crew', 'crew.user']);
} else if ($request->user()) {
--- /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.
+ */
+ public function up(): void {
+ Schema::table('events', function (Blueprint $table) {
+ $table->string('game')->nullable()->default(null);
+ });
+ Schema::table('episodes', function (Blueprint $table) {
+ $table->string('game')->nullable()->default(null);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('events', function (Blueprint $table) {
+ $table->dropColumn('game');
+ });
+ Schema::table('episodes', function (Blueprint $table) {
+ $table->dropColumn('game');
+ });
+ }
+};
import PropTypes from 'prop-types';
import React from 'react';
import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
-import { isEventSelected, toggleEventFilter } from '../../helpers/Episode';
+import Icon from '../common/Icon';
+import {
+ invertEventFilter,
+ invertGameFilter,
+ isEventSelected,
+ isGameSelected,
+ toggleEventFilter,
+ toggleGameFilter,
+} from '../../helpers/Episode';
+
+const Filter = ({ events, filter, games, setFilter }) => {
+ const { t } = useTranslation();
+
+ const invertEvents = React.useCallback(() => {
+ setFilter(invertEventFilter(filter));
+ }, [filter, setFilter]);
-const Filter = ({ events, filter, setFilter }) => {
const toggleEvent = React.useCallback(event => {
setFilter(toggleEventFilter(events, filter, event));
}, [events, filter, setFilter]);
- if (!events || !events.length) return null;
-
- return <div className="episode-filter button-bar text-end">
- {events.map(event =>
- <Button
- active={isEventSelected(filter, event)}
- key={event.id}
- onClick={() => toggleEvent(event)}
- title={event.short ? event.title : null}
- variant="outline-secondary"
- >
- {event.short || event.title}
- </Button>
- )}
+ const invertGames = React.useCallback(() => {
+ setFilter(invertGameFilter(filter));
+ }, [filter, setFilter]);
+
+ const toggleGame = React.useCallback(game => {
+ setFilter(toggleGameFilter(games, filter, game));
+ }, [games, filter, setFilter]);
+
+ return <div className="episode-filter my-3">
+ <h2>{t('schedule.filter')}</h2>
+ {events?.length ? <>
+ <div className="d-flex align-items-start justify-content-start gap-3 mt-3">
+ <h3>{t('schedule.filters.event')}</h3>
+ <Button
+ onClick={invertEvents}
+ size="sm"
+ title={t('button.invert')}
+ variant="outline-secondary"
+ >
+ <Icon.INVERT title="" />
+ </Button>
+ </div>
+ <div className="button-bar my-2">
+ {events.map(event =>
+ <Button
+ active={isEventSelected(filter, event)}
+ key={event.id}
+ onClick={() => toggleEvent(event)}
+ title={event.short ? event.title : null}
+ variant="outline-secondary"
+ >
+ {event.short || event.title}
+ </Button>
+ )}
+ </div>
+ </> : null}
+ {games?.length ? <>
+ <div className="d-flex align-items-start justify-content-start gap-3 mt-3">
+ <h3>{t('schedule.filters.game')}</h3>
+ <Button
+ onClick={invertGames}
+ size="sm"
+ title={t('button.invert')}
+ variant="outline-secondary"
+ >
+ <Icon.INVERT title="" />
+ </Button>
+ </div>
+ <div className="button-bar my-2">
+ {games.map(game =>
+ <Button
+ active={isGameSelected(filter, game)}
+ key={game}
+ onClick={() => toggleGame(game)}
+ variant="outline-secondary"
+ >
+ {t(`schedule.games.${game}`)}
+ </Button>
+ )}
+ </div>
+ </> : null}
</div>;
};
events: PropTypes.arrayOf(PropTypes.shape({
})),
filter: PropTypes.shape(),
+ games: PropTypes.arrayOf(PropTypes.string),
setFilter: PropTypes.func,
};
event: [...eventFilter, event.id],
};
};
+
+export const isGameSelected = (filter, game) => {
+ const found = (filter.game || []).includes(game);
+ return filter.gameInvert ? !found : found;
+};
+
+export const invertGameFilter = (filter) => ({
+ ...filter,
+ gameInvert: filter.gameInvert ? 0 : 1,
+});
+
+export const toggleGameFilter = (games, filter, game) => {
+ const gameFilter = filter.game || [];
+ if (gameFilter.includes(game)) {
+ return {
+ ...filter,
+ game: gameFilter.filter(id => id !== game && games.find(g => g === id)),
+ };
+ }
+ return {
+ ...filter,
+ game: [...gameFilter, game],
+ };
+};
},
schedule: {
description: 'Anstehende Spiele und andere Termine.',
+ filter: 'Filter',
+ filters: {
+ event: 'Nach Veranstaltung',
+ game: 'Nach Spiel',
+ },
+ games: {
+ alttp: 'ALttP',
+ alttpr: 'ALttPR',
+ sm: 'SM',
+ smr: 'SMR',
+ smz3: 'SMZ3',
+ },
heading: 'Terminplan',
startTime: '{{ date, LT }}',
},
import Filter from '../components/episodes/Filter';
import List from '../components/episodes/List';
import RestreamDialog from '../components/episodes/RestreamDialog';
-import { invertEventFilter } from '../helpers/Episode';
import { useUser } from '../hooks/user';
+const GAMES = [
+ 'alttp', 'alttpr', 'sm', 'smr', 'smz3',
+];
+
export const Component = () => {
const [ahead] = React.useState(14);
const [applyAs, setApplyAs] = React.useState('commentary');
const [restreamEpisode, setRestreamEpisode] = React.useState(null);
const [showApplyDialog, setShowApplyDialog] = React.useState(false);
const [showRestreamDialog, setShowRestreamDialog] = React.useState(false);
- const [showFilter, setShowFilter] = React.useState(false);
+ const [showFilter, setShowFilter] = React.useState(true);
const { t } = useTranslation();
const { user } = useUser();
setFilter(newFilter);
}, []);
- const invertFilter = React.useCallback(() => {
- updateFilter(invertEventFilter(filter));
- }, [filter, updateFilter]);
-
const fetchEpisodes = React.useCallback((controller, ahead, behind, filter) => {
axios.get(`/api/episodes`, {
signal: controller.signal,
<div className="d-flex align-items-end justify-content-between">
<h1 className="mb-0">{t('schedule.heading')}</h1>
<div className="button-bar">
- {showFilter ?
- <Button
- onClick={invertFilter}
- title={t('button.invert')}
- variant="outline-secondary"
- >
- <Icon.INVERT title="" />
- </Button>
- : null}
<Button
onClick={toggleFilter}
title={t('button.filter')}
</Button>
</div>
</div>
- {showFilter ?
- <div className="my-2">
- <Filter events={events} filter={filter} setFilter={updateFilter} />
- </div>
- : null}
+ <div className={`my-2 ${showFilter ? '' : ' d-none'}`}>
+ <Filter
+ events={events}
+ filter={filter}
+ games={GAMES}
+ setFilter={updateFilter}
+ />
+ </div>
<ErrorBoundary>
{episodes.length ?
<List