From: Daniel Karbach Date: Wed, 14 Jan 2026 17:50:51 +0000 (+0100) Subject: event crew management X-Git-Url: https://git.localhorst.tv/?a=commitdiff_plain;h=97bcd2f7c2eea79d82030bfa1a202162a32975e2;p=alttp.git event crew management --- diff --git a/app/Events/UserChanged.php b/app/Events/UserChanged.php index 9009c44..dc4a3d5 100644 --- a/app/Events/UserChanged.php +++ b/app/Events/UserChanged.php @@ -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), ]; } diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php index e630b8b..4675f30 100644 --- a/app/Http/Controllers/EventController.php +++ b/app/Http/Controllers/EventController.php @@ -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) { diff --git a/app/Models/EventCrew.php b/app/Models/EventCrew.php index 84e2135..3a40f3f 100644 --- a/app/Models/EventCrew.php +++ b/app/Models/EventCrew.php @@ -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', + ]; + } diff --git a/app/Models/User.php b/app/Models/User.php index b13200f..30892d6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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) { diff --git a/app/Policies/EpisodePolicy.php b/app/Policies/EpisodePolicy.php index 6216094..7ae4cb3 100644 --- a/app/Policies/EpisodePolicy.php +++ b/app/Policies/EpisodePolicy.php @@ -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); } /** diff --git a/app/Policies/EventPolicy.php b/app/Policies/EventPolicy.php index c59c0df..521a2a8 100644 --- a/app/Policies/EventPolicy.php +++ b/app/Policies/EventPolicy.php @@ -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); } } diff --git a/resources/js/components/common/Icon.jsx b/resources/js/components/common/Icon.jsx index 7ea4a01..5c0a28a 100644 --- a/resources/js/components/common/Icon.jsx +++ b/resources/js/components/common/Icon.jsx @@ -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 index 0000000..84d9dc8 --- /dev/null +++ b/resources/js/components/events/Crew.jsx @@ -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
+ {mayAdmin ? + + {t('events.addCrew')} + c.user_id)} + onChange={e => addCrew(e.target.value)} + value="" + /> + + : null} + {sortedCrew.map((crew) => ( +
+ + {mayAdmin ? +
+ editCrew(crew.user_id, { role: e.target.value })} + value={crew.role} + > + {['admin', 'helper'].map((role => + + ))} + + +
+ : + {t(`events.roles.${crew.role}`)} + } +
+ ))} +
; +}; + +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 index 0000000..4b5458c --- /dev/null +++ b/resources/js/components/events/CrewDialog.jsx @@ -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 + + + {t('events.manageCrew')} + + + + }> + + + + + + + ; +}; + +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; diff --git a/resources/js/components/events/Detail.jsx b/resources/js/components/events/Detail.jsx index 98e7d28..cde6239 100644 --- a/resources/js/components/events/Detail.jsx +++ b/resources/js/components/events/Detail.jsx @@ -26,6 +26,16 @@ const Detail = ({ actions, event }) => { || event.title}
+ {actions.manageCrew ? + + : null} {event.description && actions.editContent ?