]> git.localhorst.tv Git - alttp.git/commitdiff
filter schedule by game
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 11 Jul 2025 11:30:49 +0000 (13:30 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 11 Jul 2025 11:30:49 +0000 (13:30 +0200)
app/Http/Controllers/EpisodeController.php
database/migrations/2025_07_11_110426_event_game.php [new file with mode: 0644]
resources/js/components/episodes/Filter.jsx
resources/js/helpers/Episode.js
resources/js/i18n/de.js
resources/js/pages/Schedule.jsx

index bb9052c6f2ef3710d33347cc5faa81efdab4cbf0..3f484c240be2f02f70a4ea68ff855f48f191d6ae 100644 (file)
@@ -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 (file)
index 0000000..6bf50b3
--- /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.
+        */
+       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');
+               });
+       }
+};
index 98d965dd3ff902751164140df20a79cc280b211d..fb4e7e6c0b8cf79e2286450d88f7b20e49e91b49 100644 (file)
@@ -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 <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>;
 };
 
@@ -30,6 +92,7 @@ Filter.propTypes = {
        events: PropTypes.arrayOf(PropTypes.shape({
        })),
        filter: PropTypes.shape(),
+       games: PropTypes.arrayOf(PropTypes.string),
        setFilter: PropTypes.func,
 };
 
index dd7c0d81a2e6f48a66992eda45b0c33e12f0711d..cdbbf4572e7d4f5040850df5231fef4b2195674b 100644 (file)
@@ -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],
+       };
+};
index be5da23843b25ff5df729448cd4bdce439bb9c05..77b5626626a8c4b09b961738beb8ab6866a238d9 100644 (file)
@@ -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 }}',
                },
index 98f321ecfa330d7f6e85c69b4451047ac20918d8..21a8a3c4843e946c807103a66b24105a78704bf5 100644 (file)
@@ -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 = () => {
                <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')}
@@ -287,11 +277,14 @@ export const Component = () => {
                                </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