From 675e948871c003938a02c4646488ba00a17cb985 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Fri, 11 Jul 2025 13:30:49 +0200 Subject: [PATCH] filter schedule by game --- app/Http/Controllers/EpisodeController.php | 13 +++ .../2025_07_11_110426_event_game.php | 33 +++++++ resources/js/components/episodes/Filter.jsx | 95 +++++++++++++++---- resources/js/helpers/Episode.js | 24 +++++ resources/js/i18n/de.js | 12 +++ resources/js/pages/Schedule.jsx | 33 +++---- 6 files changed, 174 insertions(+), 36 deletions(-) create mode 100644 database/migrations/2025_07_11_110426_event_game.php diff --git a/app/Http/Controllers/EpisodeController.php b/app/Http/Controllers/EpisodeController.php index bb9052c..3f484c2 100644 --- a/app/Http/Controllers/EpisodeController.php +++ b/app/Http/Controllers/EpisodeController.php @@ -201,6 +201,9 @@ class EpisodeController extends Controller 'event' => 'nullable|array', 'event.*' => 'numeric', 'eventInvert' => 'boolean', + 'game' => 'nullable|array', + 'game.*' => 'string', + 'gameInvert' => 'boolean', 'limit' => 'numeric', 'offset' => 'numeric', 'reverse' => 'boolean', @@ -230,6 +233,16 @@ class EpisodeController extends Controller $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()) { diff --git a/database/migrations/2025_07_11_110426_event_game.php b/database/migrations/2025_07_11_110426_event_game.php new file mode 100644 index 0000000..6bf50b3 --- /dev/null +++ b/database/migrations/2025_07_11_110426_event_game.php @@ -0,0 +1,33 @@ +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'); + }); + } +}; diff --git a/resources/js/components/episodes/Filter.jsx b/resources/js/components/episodes/Filter.jsx index 98d965d..fb4e7e6 100644 --- a/resources/js/components/episodes/Filter.jsx +++ b/resources/js/components/episodes/Filter.jsx @@ -1,28 +1,90 @@ 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
- {events.map(event => - - )} + const invertGames = React.useCallback(() => { + setFilter(invertGameFilter(filter)); + }, [filter, setFilter]); + + const toggleGame = React.useCallback(game => { + setFilter(toggleGameFilter(games, filter, game)); + }, [games, filter, setFilter]); + + return
+

{t('schedule.filter')}

+ {events?.length ? <> +
+

{t('schedule.filters.event')}

+ +
+
+ {events.map(event => + + )} +
+ : null} + {games?.length ? <> +
+

{t('schedule.filters.game')}

+ +
+
+ {games.map(game => + + )} +
+ : null}
; }; @@ -30,6 +92,7 @@ Filter.propTypes = { events: PropTypes.arrayOf(PropTypes.shape({ })), filter: PropTypes.shape(), + games: PropTypes.arrayOf(PropTypes.string), setFilter: PropTypes.func, }; diff --git a/resources/js/helpers/Episode.js b/resources/js/helpers/Episode.js index dd7c0d8..cdbbf45 100644 --- a/resources/js/helpers/Episode.js +++ b/resources/js/helpers/Episode.js @@ -81,3 +81,27 @@ export const toggleEventFilter = (events, filter, event) => { 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], + }; +}; diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index be5da23..77b5626 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -598,6 +598,18 @@ export default { }, 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 }}', }, diff --git a/resources/js/pages/Schedule.jsx b/resources/js/pages/Schedule.jsx index 98f321e..21a8a3c 100644 --- a/resources/js/pages/Schedule.jsx +++ b/resources/js/pages/Schedule.jsx @@ -13,9 +13,12 @@ import ApplyDialog from '../components/episodes/ApplyDialog'; 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'); @@ -27,7 +30,7 @@ export const Component = () => { 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(); @@ -77,10 +80,6 @@ export const Component = () => { 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, @@ -269,15 +268,6 @@ export const Component = () => {

{t('schedule.heading')}

- {showFilter ? - - : null}
- {showFilter ? -
- -
- : null} +
+ +
{episodes.length ?