]> git.localhorst.tv Git - alttp.git/commitdiff
show episodes on user profile
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Mon, 23 Jun 2025 19:34:45 +0000 (21:34 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Mon, 23 Jun 2025 19:34:45 +0000 (21:34 +0200)
app/Http/Controllers/UserController.php
app/Models/User.php
eslint.config.mjs
resources/js/app/User.jsx
resources/js/components/episodes/List.jsx
resources/js/components/users/Box.jsx
resources/js/components/users/Profile.jsx
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/js/pages/User.jsx
resources/sass/episodes.scss

index 01b8f06fabeec18bdea89117806b9aea0c39f866..3fe68a532a6b9c88efdaed6f12d9b55b6af51bfa 100644 (file)
@@ -68,18 +68,104 @@ class UserController extends Controller
        }
 
        public function single(Request $request, $id) {
-               $user = User::findOrFail($id);
+               $user = is_numeric($id) ? User::findOrFail($id) : User::where('username', '=', $id)->firstOrFail();
                $this->authorize('view', $user);
+
+               $validatedData = $request->validate([
+                       'with' => 'array',
+                       'with.*' => 'string|in:episodes_as_comms,episodes_as_crew,episodes_as_setup,episodes_as_tracker,episodes_as_runner',
+               ]);
+
                $user->append('random_quote');
-               $user->load('participation');
-               $user->load('participation.tournament');
-               $user->loadCount('round_first');
-               $user->loadCount('round_second');
-               $user->loadCount('round_third');
-               $user->loadCount('tournament_first');
-               $user->loadCount('tournament_second');
-               $user->loadCount('tournament_third');
-               return $user->toJson();
+               $user->load([
+                       'participation',
+                       'participation.tournament',
+               ]);
+               $user->loadCount([
+                       'round_first',
+                       'round_second',
+                       'round_third',
+                       'tournament_first',
+                       'tournament_second',
+                       'tournament_third',
+               ]);
+
+               $json = $user->toArray();
+
+               if (!empty($validatedData['with'])) {
+                       if (in_array('episodes_as_crew', $validatedData['with'])) {
+                               $json['episodes_as_crew'] = $user->episodes_as_crew()->with([
+                                       'channels',
+                                       'crew' => function ($query) use ($request) {
+                                               $query->where('confirmed', true);
+                                               $query->orWhere('user_id', '=', $request->user()->id);
+                                               $query->orWhereIn('channel_id', $request->user()->channel_crews->pluck('channel_id'));
+                                       },
+                                       'crew.user',
+                                       'event',
+                                       'players',
+                                       'players.user',
+                               ])->orderBy('start', 'DESC')->limit(50)->get();
+                       }
+                       if (in_array('episodes_as_comms', $validatedData['with'])) {
+                               $json['episodes_as_comms'] = $user->episodes_as_comms()->with([
+                                       'channels',
+                                       'crew' => function ($query) use ($request) {
+                                               $query->where('confirmed', true);
+                                               $query->orWhere('user_id', '=', $request->user()->id);
+                                               $query->orWhereIn('channel_id', $request->user()->channel_crews->pluck('channel_id'));
+                                       },
+                                       'crew.user',
+                                       'event',
+                                       'players',
+                                       'players.user',
+                               ])->orderBy('start', 'DESC')->limit(50)->get();
+                       }
+                       if (in_array('episodes_as_setup', $validatedData['with'])) {
+                               $json['episodes_as_setup'] = $user->episodes_as_setup()->with([
+                                       'channels',
+                                       'crew' => function ($query) use ($request) {
+                                               $query->where('confirmed', true);
+                                               $query->orWhere('user_id', '=', $request->user()->id);
+                                               $query->orWhereIn('channel_id', $request->user()->channel_crews->pluck('channel_id'));
+                                       },
+                                       'crew.user',
+                                       'event',
+                                       'players',
+                                       'players.user',
+                               ])->orderBy('start', 'DESC')->limit(50)->get();
+                       }
+                       if (in_array('episodes_as_tracker', $validatedData['with'])) {
+                               $json['episodes_as_tracker'] = $user->episodes_as_tracker()->with([
+                                       'channels',
+                                       'crew' => function ($query) use ($request) {
+                                               $query->where('confirmed', true);
+                                               $query->orWhere('user_id', '=', $request->user()->id);
+                                               $query->orWhereIn('channel_id', $request->user()->channel_crews->pluck('channel_id'));
+                                       },
+                                       'crew.user',
+                                       'event',
+                                       'players',
+                                       'players.user',
+                               ])->orderBy('start', 'DESC')->limit(50)->get();
+                       }
+                       if (in_array('episodes_as_runner', $validatedData['with'])) {
+                               $json['episodes_as_runner'] = $user->episodes_as_runner()->with([
+                                       'channels',
+                                       'crew' => function ($query) use ($request) {
+                                               $query->where('confirmed', true);
+                                               $query->orWhere('user_id', '=', $request->user()->id);
+                                               $query->orWhereIn('channel_id', $request->user()->channel_crews->pluck('channel_id'));
+                                       },
+                                       'crew.user',
+                                       'event',
+                                       'players',
+                                       'players.user',
+                               ])->orderBy('start', 'DESC')->limit(50)->get();
+                       }
+               }
+
+               return $json;
        }
 
 }
index 9360255455f9dbf1696a933e54e744ff457705c1..6ed6a810098ef8089f0c301f14b5fc1a97f1c62c 100644 (file)
@@ -124,6 +124,31 @@ class User extends Authenticatable
                return $this->hasMany(ChannelCrew::class);
        }
 
+       public function episodes_as_crew() {
+               return $this
+                       ->belongsToMany(Episode::class, 'episode_crews')
+                       ->wherePivot('episode_crews.confirmed', '=', 1)
+                       ->where('episodes.confirmed', '=', 1);
+       }
+
+       public function episodes_as_comms() {
+               return $this->episodes_as_crew()->wherePivot('episode_crews.role', '=', 'commentary');
+       }
+
+       public function episodes_as_setup() {
+               return $this->episodes_as_crew()->wherePivot('episode_crews.role', '=', 'setup');
+       }
+
+       public function episodes_as_tracker() {
+               return $this->episodes_as_crew()->wherePivot('episode_crews.role', '=', 'tracking');
+       }
+
+       public function episodes_as_runner() {
+               return $this
+                       ->belongsToMany(Episode::class, 'episode_players')
+                       ->where('episodes.confirmed', '=', 1);
+       }
+
        public function participation() {
                return $this->hasMany(Participant::class);
        }
index cd3f9a167ee4a460f752e7ef9a8865fbfcd61faa..7214e21f9310893c54fab1bd4b468eacc3c04ddd 100644 (file)
@@ -1,11 +1,17 @@
+import { defineConfig } from 'eslint/config';
 import js from '@eslint/js';
 import react from 'eslint-plugin-react';
 import globals from 'globals';
 
-export default [
-    js.configs.recommended,
-    react.configs.flat.recommended,
+export default defineConfig([
     {
+               extends: [
+                       js.configs.recommended,
+                       react.configs.flat.recommended,
+               ],
+
+               files: ['resources/js/**/*.{js,jsx}'],
+
         plugins: {
             react,
         },
@@ -31,4 +37,4 @@ export default [
             },
         },
     },
-];
+]);
index 402178a6435dbae160ddc842f669a90cc1090125..0b6f098eff06f1b65e8157699fe40aa8928341a6 100644 (file)
@@ -14,7 +14,7 @@ const User = () => {
        return user
                ? <>
                        <Nav className="ms-auto">
-                               <LinkContainer to={`/users/${user.id}`}>
+                               <LinkContainer to={`/users/${user.username}`}>
                                        <Nav.Link>
                                                <img alt="" src={getAvatarUrl(user)} />
                                                {user.username}
@@ -25,7 +25,7 @@ const User = () => {
                                </LinkContainer>
                        </Nav>
                        <Button
-                       className="ms-2"
+                               className="ms-2"
                                onClick={logout}
                                title={t('button.logout')}
                                variant="outline-secondary"
index a2a3b29f982d014bdcff9edd3db608843501a69a..fc562eae03177c856be6e651d9ae18bb1af23921 100644 (file)
@@ -4,7 +4,13 @@ import React from 'react';
 
 import Item from './Item';
 
-const List = ({ episodes, onAddRestream, onApply, onEditRestream }) => {
+const List = ({
+       compact = false,
+       episodes,
+       onAddRestream,
+       onApply,
+       onEditRestream,
+}) => {
        const grouped = React.useMemo(() => episodes.reduce((groups, episode) => {
                const day = moment(episode.start).format('YYYY-MM-DD');
                return {
@@ -16,11 +22,25 @@ const List = ({ episodes, onAddRestream, onApply, onEditRestream }) => {
                };
        }, {}), [episodes]);
 
-       return <div className="episodes-list">
+       const className = React.useMemo(() => {
+               const classNames = ['episodes-list'];
+               if (compact) {
+                       classNames.push('compact');
+               }
+               return classNames.join(' ');
+       }, [compact]);
+
+       return <div className={className}>
                {Object.entries(grouped).map(([day, group]) => <div key={day}>
-                       <h2 className="text-center episodes-group-heading">
-                               {moment(day).format('dddd, L')}
-                       </h2>
+                       {compact ?
+                               <h3 className="episodes-group-heading">
+                                       {moment(day).format('dddd, L')}
+                               </h3>
+                       :
+                               <h2 className="text-center episodes-group-heading">
+                                       {moment(day).format('dddd, L')}
+                               </h2>
+                       }
                        {group.map(episode =>
                                <Item
                                        episode={episode}
@@ -35,6 +55,7 @@ const List = ({ episodes, onAddRestream, onApply, onEditRestream }) => {
 };
 
 List.propTypes = {
+       compact: PropTypes.bool,
        episodes: PropTypes.arrayOf(PropTypes.shape({
                start: PropTypes.string,
        })),
index c0d011bc106d4060dcbfd222865b62ed3d1e2fad..864a4f8edd3a14e7a4367874bb72acffe45f7ca3 100644 (file)
@@ -31,7 +31,12 @@ const Box = ({ discriminator, noLink, user }) => {
 
        return <Button
                className="user-box"
-               onClick={() => navigate(`/users/${user.id}`)}
+               href={`/users/${user.username}`}
+               onClick={(e) => {
+                       navigate(`/users/${user.username}`);
+                       e.preventDefault();
+                       e.stopPropagation();
+               }}
                variant="link"
        >
                {content}
index b4c40605abbdb06e0027e966d9e00b89c7d53a22..0b9f940921be0d6d97bf62440dc54a53f2caa440 100644 (file)
 import PropTypes from 'prop-types';
 import React from 'react';
-import { Alert, Button, Col, Container, Row } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
+import { Alert, Button, ButtonGroup, Col, Container, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
 
 import Box from './Box';
 import Records from './Records';
 import EditNicknameButton from './EditNicknameButton';
 import EditStreamLinkButton from './EditStreamLinkButton';
 import Participation from './Participation';
+import EpisodesList from '../episodes/List';
 import Icon from '../common/Icon';
-import i18n from '../../i18n';
 
-const Profile = ({ user }) => <Container>
-       <h1>
-               {user.nickname || user.username}
-               {' '}
-               <EditNicknameButton user={user} />
-       </h1>
-       {user.random_quote && user.random_quote.comment ?
-               <Alert className="quote-alert" variant="dark">
-                       <blockquote className="blockquote mb-0">
-                               {user.random_quote.comment}
-                       </blockquote>
-               </Alert>
-       : null}
-       <Row>
-               <Col md={6} className="mb-5">
-                       <h2>{i18n.t('users.discordTag')}</h2>
-                       <Box discriminator user={user} />
-               </Col>
-               <Col md={6} className="mb-5">
-                       <h2>{i18n.t('users.streamLink')}</h2>
-                       <p>
-                               {user.stream_link ?
-                                       <Button
-                                               href={user.stream_link}
-                                               target="_blank"
-                                               variant="outline-twitch"
-                                       >
-                                               <Icon.STREAM />
-                                               {' '}
-                                               {user.stream_link}
-                                       </Button>
-                               :
-                                       i18n.t('users.noStream')
-                               }
-                               {' '}
-                               <EditStreamLinkButton user={user} />
-                       </p>
-               </Col>
-               <Col md={6} className="mb-5">
-                       <h2>{i18n.t('users.tournamentRecords')}</h2>
-                       <Records
-                               first={user.tournament_first_count}
-                               second={user.tournament_second_count}
-                               third={user.tournament_third_count}
-                       />
-               </Col>
-               <Col md={6} className="mb-5">
-                       <h2>{i18n.t('users.roundRecords')}</h2>
-                       <Records
-                               first={user.round_first_count}
-                               second={user.round_second_count}
-                               third={user.round_third_count}
-                       />
-               </Col>
-               <Col md={12} className="mb-5">
-                       <h2>{i18n.t('users.tournaments')}</h2>
-                       <Participation user={user} />
-               </Col>
-       </Row>
-</Container>;
+const Profile = ({ user }) => {
+       const [activeEpisodes, setActiveEpisodes] = React.useState('runner');
+
+       const { t } = useTranslation();
+
+       React.useEffect(() => {
+               if (!user.episodes_as_runner.length) {
+                       if (user.episodes_as_comms.length) {
+                               setActiveEpisodes('comms');
+                       } else if (user.episodes_as_tracker.length) {
+                               setActiveEpisodes('tracker');
+                       } else if (user.episodes_as_setup.length) {
+                               setActiveEpisodes('setup');
+                       }
+               } else {
+                       setActiveEpisodes('runner');
+               }
+       }, [user])
+
+       const participation = React.useMemo(() => {
+               let p = 0;
+               if (user.episodes_as_runner.length) ++p;
+               if (user.episodes_as_comms.length) ++p;
+               if (user.episodes_as_tracker.length) ++p;
+               if (user.episodes_as_setup.length) ++p;
+               return p;
+       }, [user]);
+
+
+       return <Container>
+               <h1>
+                       {user.nickname || user.username}
+                       {' '}
+                       <EditNicknameButton user={user} />
+               </h1>
+               {user.random_quote && user.random_quote.comment ?
+                       <Alert className="quote-alert" variant="dark">
+                               <blockquote className="blockquote mb-0">
+                                       {user.random_quote.comment}
+                               </blockquote>
+                       </Alert>
+               : null}
+               <Row>
+                       <Col md={6} className="mb-5">
+                               <h2>{t('users.discordTag')}</h2>
+                               <Box discriminator user={user} />
+                       </Col>
+                       <Col md={6} className="mb-5">
+                               <h2>{t('users.streamLink')}</h2>
+                               <p>
+                                       {user.stream_link ?
+                                               <Button
+                                                       href={user.stream_link}
+                                                       target="_blank"
+                                                       variant="outline-twitch"
+                                               >
+                                                       <Icon.STREAM />
+                                                       {' '}
+                                                       {user.stream_link}
+                                               </Button>
+                                       :
+                                               t('users.noStream')
+                                       }
+                                       {' '}
+                                       <EditStreamLinkButton user={user} />
+                               </p>
+                       </Col>
+                       <Col md={6} className="mb-5">
+                               <h2>{t('users.tournamentRecords')}</h2>
+                               <Records
+                                       first={user.tournament_first_count}
+                                       second={user.tournament_second_count}
+                                       third={user.tournament_third_count}
+                               />
+                       </Col>
+                       <Col md={6} className="mb-5">
+                               <h2>{t('users.roundRecords')}</h2>
+                               <Records
+                                       first={user.round_first_count}
+                                       second={user.round_second_count}
+                                       third={user.round_third_count}
+                               />
+                       </Col>
+                       <Col md={12} className="mb-5">
+                               <h2>{t('users.tournaments')}</h2>
+                               <Participation user={user} />
+                       </Col>
+                       {participation > 0 ?
+                               <Col md={12} className="mb-5">
+                                       <h2>{t('users.episodes')}</h2>
+                                       {participation > 1 ?
+                                               <ButtonGroup>
+                                                       {user.episodes_as_runner.length ?
+                                                               <Button
+                                                                       onClick={() => setActiveEpisodes('runner')}
+                                                                       variant={activeEpisodes === 'runner' ? 'secondary' : 'outline-secondary'}
+                                                               >
+                                                                       {t('button.asRunner')}
+                                                               </Button>
+                                                       : null}
+                                                       {user.episodes_as_comms.length ?
+                                                               <Button
+                                                                       onClick={() => setActiveEpisodes('comms')}
+                                                                       variant={activeEpisodes === 'comms' ? 'secondary' : 'outline-secondary'}
+                                                               >
+                                                                       {t('button.asComms')}
+                                                               </Button>
+                                                       : null}
+                                                       {user.episodes_as_tracker.length ?
+                                                               <Button
+                                                                       onClick={() => setActiveEpisodes('tracker')}
+                                                                       variant={activeEpisodes === 'tracker' ? 'secondary' : 'outline-secondary'}
+                                                               >
+                                                                       {t('button.asTracker')}
+                                                               </Button>
+                                                       : null}
+                                                       {user.episodes_as_setup.length ?
+                                                               <Button
+                                                                       onClick={() => setActiveEpisodes('setup')}
+                                                                       variant={activeEpisodes === 'setup' ? 'secondary' : 'outline-secondary'}
+                                                               >
+                                                                       {t('button.asSetup')}
+                                                               </Button>
+                                                       : null}
+                                               </ButtonGroup>
+                                       : null}
+                                       {activeEpisodes === 'runner' ?
+                                               <EpisodesList compact episodes={user.episodes_as_runner} />
+                                       : null}
+                                       {activeEpisodes === 'comms' ?
+                                               <EpisodesList compact episodes={user.episodes_as_comms} />
+                                       : null}
+                                       {activeEpisodes === 'tracker' ?
+                                               <EpisodesList compact episodes={user.episodes_as_tracker} />
+                                       : null}
+                                       {activeEpisodes === 'setup' ?
+                                               <EpisodesList compact episodes={user.episodes_as_setup} />
+                                       : null}
+                               </Col>
+                       : null}
+               </Row>
+       </Container>;
+};
 
 Profile.propTypes = {
        user: PropTypes.shape({
+               episodes_as_comms: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               episodes_as_runner: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               episodes_as_setup: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               episodes_as_tracker: PropTypes.arrayOf(PropTypes.shape({
+               })),
                nickname: PropTypes.string,
                participation: PropTypes.arrayOf(PropTypes.shape({
                })),
@@ -91,4 +182,4 @@ Profile.propTypes = {
        }),
 };
 
-export default withTranslation()(Profile);
+export default Profile;
index c05f87e7d51b27d6da96fcca35b92e3a00a4f4a5..6c171323e53768024dda55b28cfef017156017b9 100644 (file)
@@ -66,6 +66,11 @@ export default {
                },
                button: {
                        add: 'Hinzufügen',
+                       asComms: 'Als Kommentator',
+                       asCrew: 'Als Crew',
+                       asRunner: 'Als Runner',
+                       asSetup: 'Als Setup-Helper',
+                       asTracker: 'Als Tracker',
                        back: 'Zurück',
                        browserSource: 'Browser Source',
                        cancel: 'Abbrechen',
@@ -891,6 +896,7 @@ export default {
                        discordTag: 'Discord Tag',
                        editNickname: 'Name bearbeiten',
                        editStreamLink: 'Stream Link bearbeiten',
+                       episodes: 'Restreams',
                        nickname: 'Name',
                        noStream: 'Kein Stream gesetzt',
                        participationEmpty: 'Hat noch an keinen Turnieren teilgenommen.',
index 08783ce10814f06f1be9bb8531f16436e27308ab..1341118c185a01f8e316e91267e4d0a0e7369fca 100644 (file)
@@ -66,6 +66,11 @@ export default {
                },
                button: {
                        add: 'Add',
+                       asComms: 'As Commentary',
+                       asCrew: 'As Crew',
+                       asRunner: 'As Runner',
+                       asSetup: 'As Setup Helper',
+                       asTracker: 'As Tracker',
                        back: 'Back',
                        browserSource: 'Browser source',
                        cancel: 'Cancel',
@@ -891,6 +896,7 @@ export default {
                        discordTag: 'Discord tag',
                        editNickname: 'Edit name',
                        editStreamLink: 'Edit stream link',
+                       episodes: 'Restreams',
                        nickname: 'Name',
                        noStream: 'No stream set',
                        participationEmpty: 'Has not participated in any tourneys yet.',
index 9e8d22afc9fac5d75032e3e2c47ecb56a160ea59..58796c83aec1a355f70e2ebcab83fe1007caf322 100644 (file)
@@ -22,7 +22,17 @@ const User = () => {
                setLoading(true);
                const ctrl = new AbortController();
                axios
-                       .get(`/api/users/${id}`, { signal: ctrl.signal })
+                       .get(`/api/users/${id}`, {
+                               params: {
+                                       with: [
+                                               'episodes_as_comms',
+                                               'episodes_as_runner',
+                                               'episodes_as_setup',
+                                               'episodes_as_tracker',
+                                       ],
+                               },
+                               signal: ctrl.signal,
+                       })
                        .then(response => {
                                setError(null);
                                setLoading(false);
index 6caf0dd6eafc8cdbaf9e23f71b5ce91be3eeb3f5..02d2a70185b91f723612ef0f58010602e424ba2c 100644 (file)
@@ -8,6 +8,11 @@
        z-index: 1;
 }
 
+.episodes-list.compact .episodes-group-heading {
+       padding-bottom: 1rem;
+       background: linear-gradient(180deg, $body-bg, $body-bg 75%, transparent);
+}
+
 .episodes-item {
        background-size: 6rem auto;
        background-repeat: no-repeat;