]> git.localhorst.tv Git - alttp.git/commitdiff
event crew management
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 14 Jan 2026 17:50:51 +0000 (18:50 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 14 Jan 2026 17:50:51 +0000 (18:50 +0100)
17 files changed:
app/Events/UserChanged.php
app/Http/Controllers/EventController.php
app/Models/EventCrew.php
app/Models/User.php
app/Policies/EpisodePolicy.php
app/Policies/EventPolicy.php
resources/js/components/common/Icon.jsx
resources/js/components/events/Crew.jsx [new file with mode: 0644]
resources/js/components/events/CrewDialog.jsx [new file with mode: 0644]
resources/js/components/events/Detail.jsx
resources/js/helpers/permissions.js
resources/js/hooks/user.jsx
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/js/pages/Event.jsx
routes/api.php
routes/channels.php

index 9009c445b18d29d0939a5a3f4b14555451738182..dc4a3d5efb1dba6c31b9398209f0bd5bcf6069d4 100644 (file)
@@ -34,7 +34,7 @@ class UserChanged implements ShouldBroadcast
        {
                return [
                        new Channel('App.Control'),
-                       new PrivateChannel('App.Models.User.'.$this->user->id),
+                       new PrivateChannel('User.'.$this->user->id),
                ];
        }
 
index e630b8bc381103a75f54b93b4e4a3de37096a3a1..4675f3035c680826c6c873c895462e64e7be7d44 100644 (file)
@@ -55,6 +55,9 @@ class EventController extends Controller
                        $events->where('title', 'LIKE', '%'.$validatedData['phrase'].'%');
                }
                if (isset($validatedData['with'])) {
+                       if (in_array('crews', $validatedData['with'])) {
+                               $events->with(['crews', 'crews.user']);
+                       }
                        if (in_array('description', $validatedData['with'])) {
                                $events->with('description');
                        }
@@ -64,10 +67,38 @@ class EventController extends Controller
 
        public function single(Request $request, Event $event) {
                $this->authorize('view', $event);
-               $event->load('description');
+               $event->load(['crews', 'crews.user', 'description']);
                return $event->toArray();
        }
 
+       public function manageCrew(Request $request, Event $event) {
+               $this->authorize('manageCrew', $event);
+
+               $validatedData = $request->validate([
+                       'add_user' => 'numeric|exists:App\Models\User,id',
+                       'modify_user' => 'numeric|exists:App\Models\User,id',
+                       'remove_user' => 'numeric|exists:App\Models\User,id',
+                       'role' => 'string|in:admin,helper',
+               ]);
+
+               if (isset($validatedData['add_user'])) {
+                       $event->crews()->create([
+                               'user_id' => $validatedData['add_user'],
+                       ]);
+               }
+               if (isset($validatedData['modify_user'], $validatedData['role'])) {
+                       $crew = $event->crews()->where('user_id', '=', $validatedData['modify_user'])->firstOrFail();
+                       $crew->role = $validatedData['role'];
+                       $crew->save();
+               }
+               if (isset($validatedData['remove_user'])) {
+                       $crew = $event->crews()->where('user_id', '=', $validatedData['remove_user'])->firstOrFail();
+                       $crew->delete();
+               }
+
+               return $event->fresh()->load(['crews', 'crews.user'])->toArray();
+       }
+
        public function web(Request $request, string $name) {
                $event = Event::where('name', '=', $name)->first();
                if ($event) {
index 84e21354832db55a50074a87d3600fae2de25c85..3a40f3f65273aabecf24cde1fe624f8d27cf2627 100644 (file)
@@ -2,10 +2,26 @@
 
 namespace App\Models;
 
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Database\Eloquent\BroadcastsEvents;
 use Illuminate\Database\Eloquent\Model;
 
 class EventCrew extends Model {
 
+       use BroadcastsEvents;
+
+       public function broadcastOn(string $event): array {
+               $channels = [
+                       new PrivateChannel('User.'.$this->user_id),
+               ];
+               return $channels;
+       }
+
+       public function broadcastWith(string $event): void {
+               $this->load(['event']);
+       }
+
+
        public function event() {
                return $this->belongsTo(Event::class);
        }
@@ -18,4 +34,8 @@ class EventCrew extends Model {
                'user_id' => 'string',
        ];
 
+       protected $fillable = [
+               'user_id',
+       ];
+
 }
index b13200f6c6d9fefda74bca7e6126986db91f2bef..30892d65661e95db9e143cd4b7d08fde41048154 100644 (file)
@@ -66,6 +66,12 @@ class User extends Authenticatable
                          ->count() > 0;
        }
 
+       public function isEventCrew(Event $event) {
+               return $this->event_crews()
+                         ->where('event_id', '=', $event->id)
+                         ->count() > 0;
+       }
+
        public function isApplicant(Tournament $tournament) {
                foreach ($tournament->applications as $applicant) {
                        if ($applicant->user_id == $this->id) {
index 6216094712c57675ef97d79e733e9d4844efc27b..7ae4cb345308dffe77b03b62e1659afa32daf083 100644 (file)
@@ -30,7 +30,7 @@ class EpisodePolicy
         */
        public function view(User $user, Episode $episode)
        {
-               return $episode->event->visible;
+               return $episode->event->visible || $user->isEventCrew($episode->event);
        }
 
        /**
@@ -52,7 +52,7 @@ class EpisodePolicy
         * @return \Illuminate\Auth\Access\Response|bool
         */
        public function update(User $user, Episode $episode) {
-               return $user->isEventAdmin($episode->event);
+               return $user->isEventCrew($episode->event);
        }
 
        /**
@@ -64,7 +64,7 @@ class EpisodePolicy
         */
        public function delete(User $user, Episode $episode)
        {
-               return $user->isEventAdmin($episode->event);
+               return $user->isEventCrew($episode->event);
        }
 
        /**
index c59c0dfca4b4e48de5a98331a6d946e572c795b5..521a2a818bad6cf3d95347ee58c6168d395c777d 100644 (file)
@@ -100,7 +100,18 @@ class EventPolicy
         * @return \Illuminate\Auth\Access\Response|bool
         */
        public function addEpisode(User $user, Event $event) {
-               return $user->isEventAdmin($event);
+               return $user->isEventCrew($event);
+       }
+
+       /**
+        * Determine whether the user manage the crew for the event.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\Event  $event
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function manageCrew(User $user, Event $event) {
+               return $user->isAdmin() || $user->isEventAdmin($event);
        }
 
 }
index 7ea4a01db735917ac7486abb38a939095621f595..5c0a28a511a115a5fcb0c6baf0eaedaf59c9505c 100644 (file)
@@ -58,6 +58,7 @@ Icon.APPLICATIONS = makePreset('ApplicationsIcon', 'person-running');
 Icon.BROWSER_SOURCE = makePreset('BrowserSourceIcon', 'tv');
 Icon.CHANGED = makePreset('ChangedIcon', 'pen-to-square');
 Icon.CHART = makePreset('ChartIcon', 'chart-line');
+Icon.CREW = makePreset('CrewIcon', 'users');
 Icon.CROSSHAIRS = makePreset('CrosshairsIcon', 'crosshairs');
 Icon.DELETE = makePreset('DeleteIcon', 'user-xmark');
 Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']);
diff --git a/resources/js/components/events/Crew.jsx b/resources/js/components/events/Crew.jsx
new file mode 100644 (file)
index 0000000..84d9dc8
--- /dev/null
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import UserSelect from '../common/UserSelect';
+import Icon from '../common/Icon';
+import UserBox from '../users/Box';
+import { mayAdminEvent } from '../../helpers/permissions';
+import { compareUsername } from '../../helpers/User';
+import { useUser } from '../../hooks/user';
+
+const sortCrew = (crew) => {
+       const sorted = [...crew];
+       sorted.sort((a, b) => {
+               if (a.role === b.role) {
+                       return compareUsername(a.user, b.user);
+               }
+               if (a.role === 'admin' && b.role !== 'admin') {
+                       return -1;
+               }
+               if (b.role === 'admin' && a.role !== 'admin') {
+                       return 1;
+               }
+               return compareUsername(a.user, b.user);
+       });
+       return sorted;
+};
+
+const Crew = ({ addCrew, editCrew, event, removeCrew }) => {
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       const mayAdmin = React.useMemo(() => mayAdminEvent(user, event), [event, user]);
+
+       const sortedCrew = React.useMemo(() => sortCrew(event.crews || []), [event]);
+
+       return <div>
+               {mayAdmin ?
+                       <Form.Group controlId="crew.addCrew">
+                               <Form.Label>{t('events.addCrew')}</Form.Label>
+                               <Form.Control
+                                       as={UserSelect}
+                                       excludeIds={event.crews.map(c => c.user_id)}
+                                       onChange={e => addCrew(e.target.value)}
+                                       value=""
+                               />
+                       </Form.Group>
+               : null}
+               {sortedCrew.map((crew) => (
+                       <div className="d-flex align-items-center justify-content-between my-2" key={crew.id}>
+                               <UserBox user={crew.user} />
+                               {mayAdmin ?
+                                       <div className="button-bar d-flex align-items-center">
+                                               <Form.Select
+                                                       onChange={(e) => editCrew(crew.user_id, { role: e.target.value })}
+                                                       value={crew.role}
+                                               >
+                                                       {['admin', 'helper'].map((role =>
+                                                               <option key={role} value={role}>{t(`events.roles.${role}`)}</option>
+                                                       ))}
+                                               </Form.Select>
+                                               <Button
+                                                       onClick={() => removeCrew(crew.user_id)}
+                                                       size="sm"
+                                                       title={t('button.remove')}
+                                                       variant="outline-danger"
+                                               >
+                                                       <Icon.DELETE title="" />
+                                               </Button>
+                                       </div>
+                               :
+                                       <span>{t(`events.roles.${crew.role}`)}</span>
+                               }
+                       </div>
+               ))}
+       </div>;
+};
+
+Crew.propTypes = {
+       addCrew: PropTypes.func,
+       editCrew: PropTypes.func,
+       event: PropTypes.shape({
+               crews: PropTypes.arrayOf(PropTypes.shape({
+               })),
+       }),
+       removeCrew: PropTypes.func,
+};
+
+export default Crew;
diff --git a/resources/js/components/events/CrewDialog.jsx b/resources/js/components/events/CrewDialog.jsx
new file mode 100644 (file)
index 0000000..4b5458c
--- /dev/null
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Crew from './Crew';
+import Loading from '../common/Loading';
+
+const CrewDialog = ({
+       addCrew,
+       editCrew,
+       event,
+       onHide,
+       removeCrew,
+       show,
+}) => {
+       const { t } = useTranslation();
+
+       return <Modal onHide={onHide} show={show} size="md">
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('events.manageCrew')}
+                       </Modal.Title>
+               </Modal.Header>
+               <Modal.Body>
+                       <React.Suspense fallback={<Loading />}>
+                               <Crew
+                                       addCrew={addCrew}
+                                       editCrew={editCrew}
+                                       event={event}
+                                       removeCrew={removeCrew}
+                               />
+                       </React.Suspense>
+               </Modal.Body>
+               <Modal.Footer>
+                       <Button onClick={onHide} variant="secondary">
+                               {t('button.close')}
+                       </Button>
+               </Modal.Footer>
+       </Modal>;
+};
+
+CrewDialog.propTypes = {
+       addCrew: PropTypes.func,
+       editCrew: PropTypes.func,
+       event: PropTypes.shape({
+               id: PropTypes.number,
+       }),
+       onHide: PropTypes.func,
+       removeCrew: PropTypes.func,
+       show: PropTypes.bool,
+};
+
+export default CrewDialog;
index 98e7d28943a127011a90376ea6eb83b087d55902..cde623924acd78cdaf93835b45eff9dd26427981 100644 (file)
@@ -26,6 +26,16 @@ const Detail = ({ actions, event }) => {
                                        || event.title}
                        </h1>
                        <div className="button-bar">
+                               {actions.manageCrew ?
+                                       <Button
+                                               onClick={() => actions.manageCrew(event)}
+                                               size="sm"
+                                               title={t('button.crew')}
+                                               variant="outline-secondary"
+                                       >
+                                               <Icon.CREW title="" />
+                                       </Button>
+                               : null}
                                {event.description && actions.editContent ?
                                        <Button
                                                onClick={() => actions.editContent(event.description)}
@@ -67,6 +77,7 @@ const Detail = ({ actions, event }) => {
 Detail.propTypes = {
        actions: PropTypes.shape({
                editContent: PropTypes.func,
+               manageCrew: PropTypes.func,
        }),
        event: PropTypes.shape({
                banner: PropTypes.string,
index 17201d00103c0aad165053054a8dc20d5cde5c83..e8ca44db5b8876aa2d8de94e6a4e194af7cd8f31 100644 (file)
@@ -101,7 +101,13 @@ export const isEventAdmin = (user, event) =>
        user && event && user.event_crews &&
                user.event_crews.find(c => c.role === 'admin' && c.event_id === event.id);
 
-export const mayAddEpisodes = (user, event) => isEventAdmin(user, event);
+export const isEventCrew = (user, event) =>
+       user && event && user.event_crews &&
+               user.event_crews.find(c => c.event_id === event.id);
+
+export const mayAddEpisodes = (user, event) => isEventCrew(user, event);
+
+export const mayAdminEvent = (user, event) => isAdmin(user) || isEventAdmin(user, event);
 
 // Tournaments
 
index 03173d5380a6bb8bd8d599097a76a62647fef852..dbbb3caa9b867db69f8248540c90ad81af61288b 100644 (file)
@@ -42,6 +42,35 @@ export const UserProvider = ({ children }) => {
                };
        }, []);
 
+       React.useEffect(() => {
+               if (!user?.id) return;
+               window.Echo.private(`User.${user.id}`)
+                       .listen('.EventCrewCreated', ({ model }) => {
+                               setUser((u) => ({
+                                       ...u,
+                                       event_crews: [
+                                               ...u.event_crews || [],
+                                               model,
+                                       ],
+                               }));
+                       })
+                       .listen('.EventCrewUpdated', ({ model }) => {
+                               setUser((u) => ({
+                                       ...u,
+                                       event_crews: (u.event_crews || []).map((c) => c.id === model.id ? model : c),
+                               }));
+                       })
+                       .listen('.EventCrewDeleted', ({ model }) => {
+                               setUser((u) => ({
+                                       ...u,
+                                       event_crews: (u.event_crews || []).filter((c) => c.id !== model.id),
+                               }));
+                       });
+               return () => {
+                       window.Echo.leave(`User.${user.id}`);
+               };
+       }, [user?.id]);
+
        const login = React.useCallback(async (creds) => {
                try {
                        await axios.post('/login', {
index 600f62b503518c5cee491af28f7d98d91816d694..c665da5b04eb19266490390d6588e66bf1e6039c 100644 (file)
@@ -77,6 +77,7 @@ export default {
                        chart: 'Diagramm',
                        close: 'Schließen',
                        confirm: 'Bestätigen',
+                       crew: 'Crew',
                        delete: 'Löschen',
                        edit: 'Bearbeiten',
                        exportExcel: 'XLSX Exportieren',
@@ -296,16 +297,25 @@ export default {
                        },
                },
                events: {
+                       addCrew: 'Mitglied hinzufügen',
                        concluded: 'Diese Veranstaltung is abgeschlossen.',
+                       crewAddError: 'Fehler beim Hinzufügen',
+                       crewEditError: 'Fehler beim Speichern',
+                       crewRemoveError: 'Fehler beim Entfernen',
                        description: 'Speedrun und Randomizer Veranstaltungen für A Link to the Past',
                        end: 'Ende',
                        evergreen: 'Ständige Veranstaltungen',
                        heading: 'Veranstaltungen',
+                       manageCrew: 'Crew verwalten',
                        noPastEpisodes: 'Keine vergangenen Rennen gefunden.',
                        noUpcomingEpisodes: 'Keine anstehenden Rennen gefunden.',
                        ongoing: 'Laufende Veranstaltungen',
                        past: 'Vergangene Veranstaltungen',
                        pastEpisodes: 'Vergangene Rennen',
+                       roles: {
+                               admin: 'Admin',
+                               helper: 'Aushilfe',
+                       },
                        setFutureMode: 'Anstehende Rennen zeigen',
                        setPastMode: 'Vergangene Rennen zeigen',
                        start: 'Start',
index ca5c805ab98acaae3d495e5c1b489773ef3e7713..c03b7c38b6af6239a5ae4a0e26f44524a01ed0e5 100644 (file)
@@ -77,6 +77,7 @@ export default {
                        chart: 'Chart',
                        close: 'Close',
                        confirm: 'Confirm',
+                       crew: 'Crew',
                        delete: 'Delete',
                        edit: 'Edit',
                        exportExcel: 'Export XLSX',
@@ -296,16 +297,25 @@ export default {
                        },
                },
                events: {
+                       addCrew: 'Add member',
                        concluded: 'This event has concluded.',
+                       crewAddError: 'Error adding member',
+                       crewEditError: 'Error saving changes',
+                       crewRemoveError: 'Error removing member',
                        description: 'Speedrun and randomizer events for A Link to the Past',
                        end: 'End',
                        evergreen: 'Evergreen events',
                        heading: 'Events',
+                       manageCrew: 'Manage crew',
                        noPastEpisodes: 'No past races found.',
                        noUpcomingEpisodes: 'No upcoming races found.',
                        ongoing: 'Ongoing events',
                        past: 'Past events',
                        pastEpisodes: 'Past races',
+                       roles: {
+                               admin: 'Admin',
+                               helper: 'Helper',
+                       },
                        setFutureMode: 'Show upcoming races',
                        setPastMode: 'Show past races',
                        start: 'Start',
index 22154b7513a5097f3ae48134b5475c032ce78792..07c79616734aaf16f279e8c3ba476f7769782663 100644 (file)
@@ -14,11 +14,13 @@ import ErrorMessage from '../components/common/ErrorMessage';
 import Icon from '../components/common/Icon';
 import Loading from '../components/common/Loading';
 import EpisodeList from '../components/episodes/List';
+import CrewDialog from '../components/events/CrewDialog';
 import Detail from '../components/events/Detail';
 import Dialog from '../components/techniques/Dialog';
 import { getTimeZoneString } from '../helpers/Episode';
 import { hasConcluded } from '../helpers/Event';
 import {
+       mayAdminEvent,
        mayEditContent,
 } from '../helpers/permissions';
 import { getTranslation } from '../helpers/Technique';
@@ -40,13 +42,17 @@ export const Component = () => {
        const [episodes, setEpisodes] = React.useState([]);
        const [pastMode, setPastMode] = React.useState(false);
        const [showContentDialog, setShowContentDialog] = React.useState(false);
+       const [showCrewDialog, setShowCrewDialog] = React.useState(false);
 
        const actions = React.useMemo(() => ({
                editContent: mayEditContent(user) ? content => {
                        setEditContent(content);
                        setShowContentDialog(true);
                } : null,
-       }), [user]);
+               manageCrew: mayAdminEvent(user, event) ? () => {
+                       setShowCrewDialog(true);
+               } : null,
+       }), [event, user]);
 
        const fetchEpisodes = React.useCallback((controller, event) => {
                if (!event) {
@@ -79,6 +85,40 @@ export const Component = () => {
                });
        }, [pastMode]);
 
+       const addCrew = React.useCallback(async (user_id) => {
+               try {
+                       const response = await axios.post(`/api/events/${event.id}/crew`, {
+                               add_user: user_id,
+                       });
+                       setEvent(e => ({ ...e, crews: response.data.crews }));
+               } catch (error) {
+                       toastr.error(t('events.crewAddError', { error }));
+               }
+       }, [event?.id, t]);
+
+       const editCrew = React.useCallback(async (user_id, changes) => {
+               try {
+                       const response = await axios.post(`/api/events/${event.id}/crew`, {
+                               modify_user: user_id,
+                               ...changes,
+                       });
+                       setEvent(e => ({ ...e, crews: response.data.crews }));
+               } catch (error) {
+                       toastr.error(t('events.crewEditError', { error }));
+               }
+       }, [event?.id, t]);
+
+       const removeCrew = React.useCallback(async (user_id) => {
+               try {
+                       const response = await axios.post(`/api/events/${event.id}/crew`, {
+                               remove_user: user_id,
+                       });
+                       setEvent(e => ({ ...e, crews: response.data.crews }));
+               } catch (error) {
+                       toastr.error(t('events.crewRemoveError', { error }));
+               }
+       }, [event?.id, t]);
+
        const saveContent = React.useCallback(async values => {
                try {
                        const response = await axios.put(`/api/content/${values.id}`, {
@@ -200,6 +240,14 @@ export const Component = () => {
                                }
                        </Container>
                </EpisodesProvider>
+               <CrewDialog
+                       addCrew={addCrew}
+                       editCrew={editCrew}
+                       event={event}
+                       onHide={() => { setShowCrewDialog(false); }}
+                       removeCrew={removeCrew}
+                       show={showCrewDialog}
+               />
                <Dialog
                        content={editContent}
                        language={i18n.language}
index e32b4504b5bc61fdad66645bf0c145b65f141975..abe4e6d67f208a594f60df514ab45207bd25bfec 100644 (file)
@@ -75,6 +75,7 @@ Route::post('episodes/{episode}/remove-restream', 'App\Http\Controllers\EpisodeC
 Route::get('events', 'App\Http\Controllers\EventController@search');
 Route::get('events/{event:name}', 'App\Http\Controllers\EventController@single');
 Route::post('events/{event}/add-episode', 'App\Http\Controllers\EpisodeController@create');
+Route::post('events/{event}/crew', 'App\Http\Controllers\EventController@manageCrew');
 
 Route::post('group-assignments/{assignment}/change', 'App\Http\Controllers\GroupAssignmentController@changeAssignment');
 
index cea1e319db424543eece892f7ae321c7e107d5fc..dbbe2afcb02b6c9b42e3f0b0be1c55e1404722eb 100644 (file)
@@ -16,7 +16,7 @@ use Illuminate\Support\Facades\Broadcast;
 |
 */
 
-Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
+Broadcast::channel('User.{id}', function ($user, $id) {
        return (int) $user->id === (int) $id;
 });