use App\Models\Technique;
use App\Models\TechniqueMap;
+use App\Models\TechniqueTranslation;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
return $tech->toJson();
}
+ public function update(Request $request, Technique $content) {
+ $this->authorize('update', $content);
+
+ $validatedData = $request->validate([
+ 'attribution' => 'string',
+ 'description' => 'string',
+ 'language' => 'string|in:de,en',
+ 'parent_id' => 'integer|exists:App\\Models\\Technique,id',
+ 'short' => 'string',
+ 'title' => 'string',
+ ]);
+
+ if ($validatedData['language'] == 'en') {
+ $this->applyLocalizedValues($validatedData, $content);
+ $content->save();
+ } else {
+ $translation = $this->getTranslation($content, $validatedData['language']);
+ $this->applyLocalizedValues($validatedData, $translation);
+ $translation->save();
+ }
+
+ $result = isset($validatedData['parent_id']) ? Technique::findOrFail($validatedData['parent_id']) : $content->fresh();
+ $result->load(['chapters', 'relations']);
+ return $result->toJson();
+ }
+
+ private function applyLocalizedValues($validatedData, $content) {
+ foreach (['attribution', 'description', 'short', 'title'] as $name) {
+ if (isset($validatedData[$name])) {
+ $content->{$name} = $validatedData[$name];
+ }
+ }
+ }
+
+ private function getTranslation(Technique $content, $language) {
+ foreach ($content->translations as $translation) {
+ if ($translation->locale == $language) {
+ return $translation;
+ }
+ }
+ $translation = new TechniqueTranslation();
+ $translation->technique_id = $content->id;
+ $translation->locale = $language;
+ return $translation;
+ }
+
protected function applyFilter(Request $request, Builder $query) {
$validatedData = $request->validate([
'phrase' => 'string|nullable',
}
+ public function hasGlobalRole($name) {
+ return !empty($this->global_roles) && in_array($name, $this->global_roles);
+ }
+
public function isAdmin() {
return $this->role === 'admin';
}
'avatar' => 'string',
'avatar_cached' => 'datetime',
'verified' => 'boolean',
+ 'global_roles' => 'array',
'locale' => 'string',
'mfa_enabled' => 'boolean',
'refresh_token' => 'encrypted',
*/
public function create(User $user)
{
- return $user->isAdmin();
+ return $user->isAdmin() || $user->hasGlobalRole('content');
}
/**
*/
public function update(User $user, Technique $technique)
{
- return $user->isAdmin();
+ return $user->isAdmin() || $user->hasGlobalRole('content');
}
/**
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table('users', function(Blueprint $table) {
+ $table->text('global_roles')->default('[]');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('users', function(Blueprint $table) {
+ $table->dropColumn('global_roles');
+ });
+ }
+};
import { Helmet } from 'react-helmet';
import { withTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
+import toastr from 'toastr';
import CanonicalLinks from '../common/CanonicalLinks';
import ErrorBoundary from '../common/ErrorBoundary';
import Loading from '../common/Loading';
import NotFound from '../pages/NotFound';
import Detail from '../techniques/Detail';
+import Dialog from '../techniques/Dialog';
+import {
+ mayEditContent,
+} from '../../helpers/permissions';
import { getLanguages, getMatchedLocale, getTranslation } from '../../helpers/Technique';
+import { useUser } from '../../helpers/UserContext';
import i18n from '../../i18n';
const Technique = ({ type }) => {
const params = useParams();
const { name } = params;
+ const user = useUser();
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [technique, setTechnique] = useState(null);
+ const [editContent, setEditContent] = useState(null);
+ const [showContentDialog, setShowContentDialog] = useState(false);
+
+ const actions = React.useMemo(() => ({
+ editContent: mayEditContent(user) ? content => {
+ setEditContent(content);
+ setShowContentDialog(true);
+ } : null,
+ }), [user]);
+
+ const saveContent = React.useCallback(async values => {
+ try {
+ const response = await axios.put(`/api/content/${values.id}`, {
+ parent_id: technique.id,
+ ...values,
+ });
+ toastr.success(i18n.t('content.saveSuccess'));
+ setTechnique(response.data);
+ setShowContentDialog(false);
+ } catch (e) {
+ toastr.error(i18n.t('content.saveError'));
+ }
+ }, [technique && technique.id]);
+
useEffect(() => {
const ctrl = new AbortController();
setLoading(true);
lang={getMatchedLocale(technique, i18n.language)}
langs={getLanguages(technique)}
/>
- <Detail technique={technique} />
+ <Detail actions={actions} technique={technique} />
+ <Dialog
+ content={editContent}
+ language={i18n.language}
+ onHide={() => { setShowContentDialog(false); }}
+ onSubmit={saveContent}
+ show={showContentDialog}
+ />
</ErrorBoundary>;
};
import PropTypes from 'prop-types';
import React from 'react';
-import { Alert, Container } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
+import { Alert, Button, Container } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
import List from './List';
import Outline from './Outline';
import Requirements from './Requirements';
import Rulesets from './Rulesets';
+import Icon from '../common/Icon';
import RawHTML from '../common/RawHTML';
import {
getRelations,
} from '../../helpers/Technique';
import i18n from '../../i18n';
-const Detail = ({ technique }) => <Container as="article">
- <div className="d-flex align-items-center justify-content-between">
- <h1>{getTranslation(technique, 'title', i18n.language)}</h1>
- {technique && technique.rulesets ?
- <Rulesets technique={technique} />
- : null}
- </div>
- <Outline technique={technique} />
- <Requirements technique={technique} />
- <RawHTML html={getTranslation(technique, 'description', i18n.language)} />
- {technique.chapters ? technique.chapters.map(chapter =>
- <section id={`c${chapter.id}`} key={`c${chapter.id}`}>
- {chapter.pivot.level ?
- React.createElement(
- `h${chapter.pivot.level}`,
- {},
- getTranslation(chapter, 'title', i18n.language),
- )
+const Detail = ({ actions, technique }) => {
+ const { t } = useTranslation();
+
+ return <Container as="article">
+ <div className="d-flex align-items-center justify-content-between">
+ <h1>
+ {getTranslation(technique, 'title', i18n.language)}
+ {actions.editContent ?
+ <Button
+ className="ms-3"
+ onClick={() => actions.editContent(technique)}
+ size="sm"
+ title={t('button.edit')}
+ variant="outline-secondary"
+ >
+ <Icon.EDIT title="" />
+ </Button>
+ : null}
+ </h1>
+ {technique && technique.rulesets ?
+ <Rulesets technique={technique} />
: null}
- <RawHTML html={getTranslation(chapter, 'description', i18n.language)} />
- </section>
- ) : null}
- {hasRelations(technique, 'related') ? <>
- <h2 className="mt-5">{i18n.t('techniques.seeAlso')}</h2>
- <List techniques={sorted(getRelations(technique, 'related'))} />
- </> : null}
- {getTranslation(technique, 'attribution', i18n.language) ?
- <Alert variant="dark">
- <RawHTML html={getTranslation(technique, 'attribution', i18n.language)} />
- </Alert>
- : null}
-</Container>;
+ </div>
+ <Outline technique={technique} />
+ <Requirements technique={technique} />
+ <RawHTML html={getTranslation(technique, 'description', i18n.language)} />
+ {technique.chapters ? technique.chapters.map(chapter =>
+ <section id={`c${chapter.id}`} key={`c${chapter.id}`}>
+ {chapter.pivot.level ?
+ React.createElement(
+ `h${chapter.pivot.level}`,
+ {},
+ getTranslation(chapter, 'title', i18n.language),
+ actions.editContent ?
+ <Button
+ className="ms-3"
+ onClick={() => actions.editContent(chapter)}
+ size="sm"
+ title={t('button.edit')}
+ variant="outline-secondary"
+ >
+ <Icon.EDIT title="" />
+ </Button>
+ : null,
+ )
+ : null}
+ <RawHTML html={getTranslation(chapter, 'description', i18n.language)} />
+ </section>
+ ) : null}
+ {hasRelations(technique, 'related') ? <>
+ <h2 className="mt-5">{i18n.t('techniques.seeAlso')}</h2>
+ <List techniques={sorted(getRelations(technique, 'related'))} />
+ </> : null}
+ {getTranslation(technique, 'attribution', i18n.language) ?
+ <Alert variant="dark">
+ <RawHTML html={getTranslation(technique, 'attribution', i18n.language)} />
+ </Alert>
+ : null}
+ </Container>;
+};
Detail.propTypes = {
+ actions: PropTypes.shape({
+ editContent: PropTypes.func,
+ }),
technique: PropTypes.shape({
chapters: PropTypes.arrayOf(PropTypes.shape({
})),
}),
};
-export default withTranslation()(Detail);
+export default Detail;
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Form from './Form';
+import LanguageSwitcher from '../app/LanguageSwitcher';
+
+const Dialog = ({
+ content,
+ language,
+ onHide,
+ onSubmit,
+ show,
+}) => {
+ const { t } = useTranslation();
+
+ return <Modal onHide={onHide} show={show} size="lg">
+ <Modal.Header closeButton>
+ <Modal.Title>
+ {t('content.edit')}
+ </Modal.Title>
+ <div className="mx-3">
+ <LanguageSwitcher />
+ </div>
+ </Modal.Header>
+ <Form
+ content={content}
+ language={language}
+ onCancel={onHide}
+ onSubmit={onSubmit}
+ />
+ </Modal>;
+};
+
+Dialog.propTypes = {
+ content: PropTypes.shape({
+ }),
+ language: PropTypes.string,
+ onHide: PropTypes.func,
+ onSubmit: PropTypes.func,
+ show: PropTypes.bool,
+};
+
+export default Dialog;
--- /dev/null
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import { getTranslation } from '../../helpers/Technique';
+import yup from '../../schema/yup';
+
+const ContentForm = ({
+ errors,
+ handleBlur,
+ handleChange,
+ handleSubmit,
+ onCancel,
+ touched,
+ values,
+}) => {
+ const { t } = useTranslation();
+
+ return <Form noValidate onSubmit={handleSubmit}>
+ <Modal.Body>
+ <Row>
+ <Form.Group as={Col} md={6} controlId="content.title">
+ <Form.Label>{t('content.title')}</Form.Label>
+ <Form.Control
+ isInvalid={!!(touched.title && errors.title)}
+ name="title"
+ onBlur={handleBlur}
+ onChange={handleChange}
+ type="text"
+ value={values.title || ''}
+ />
+ </Form.Group>
+ </Row>
+ <Form.Group controlId="content.short">
+ <Form.Label>{t('content.short')}</Form.Label>
+ <Form.Control
+ as="textarea"
+ isInvalid={!!(touched.short && errors.short)}
+ name="short"
+ onBlur={handleBlur}
+ onChange={handleChange}
+ rows={3}
+ value={values.short || ''}
+ />
+ </Form.Group>
+ <Form.Group controlId="content.description">
+ <Form.Label>{t('content.description')}</Form.Label>
+ <Form.Control
+ as="textarea"
+ isInvalid={!!(touched.description && errors.description)}
+ name="description"
+ onBlur={handleBlur}
+ onChange={handleChange}
+ rows={10}
+ value={values.description || ''}
+ />
+ </Form.Group>
+ <Form.Group controlId="content.attribution">
+ <Form.Label>{t('content.attribution')}</Form.Label>
+ <Form.Control
+ as="textarea"
+ isInvalid={!!(touched.attribution && errors.attribution)}
+ name="attribution"
+ onBlur={handleBlur}
+ onChange={handleChange}
+ rows={3}
+ value={values.attribution || ''}
+ />
+ </Form.Group>
+ </Modal.Body>
+ <Modal.Footer>
+ {onCancel ?
+ <Button onClick={onCancel} variant="secondary">
+ {t('button.cancel')}
+ </Button>
+ : null}
+ <Button type="submit" variant="primary">
+ {t('button.save')}
+ </Button>
+ </Modal.Footer>
+ </Form>;
+};
+
+ContentForm.propTypes = {
+ errors: PropTypes.shape({
+ attribution: PropTypes.string,
+ description: PropTypes.string,
+ short: PropTypes.string,
+ title: PropTypes.string,
+ }),
+ handleBlur: PropTypes.func,
+ handleChange: PropTypes.func,
+ handleSubmit: PropTypes.func,
+ onCancel: PropTypes.func,
+ touched: PropTypes.shape({
+ attribution: PropTypes.bool,
+ description: PropTypes.bool,
+ short: PropTypes.bool,
+ title: PropTypes.bool,
+ }),
+ values: PropTypes.shape({
+ attribution: PropTypes.string,
+ description: PropTypes.string,
+ short: PropTypes.string,
+ title: PropTypes.string,
+ }),
+};
+
+export default withFormik({
+ displayName: 'ContentForm',
+ enableReinitialize: true,
+ handleSubmit: async (values, actions) => {
+ const { onSubmit } = actions.props;
+ await onSubmit(values);
+ },
+ mapPropsToValues: ({ content, language }) => ({
+ attribution: getTranslation(content, 'attribution', language),
+ description: getTranslation(content, 'description', language),
+ id: (content && content.id) || null,
+ language,
+ short: getTranslation(content, 'short', language),
+ title: getTranslation(content, 'title', language),
+ }),
+ validationSchema: yup.object().shape({
+ attribution: yup.string(),
+ description: yup.string(),
+ short: yup.string(),
+ title: yup.string(),
+ }),
+})(ContentForm);
};
export const getTranslation = (tech, prop, lang) => {
+ if (!tech) return '';
const direct = tech.translations.find(t => t.locale === lang);
if (direct) {
return direct[prop];
const UserContext = React.createContext(null);
+export const useUser = () => React.useContext(UserContext);
+
export const withUser = (WrappedComponent, as) => function WithUserContext(props) {
return <UserContext.Consumer>
{user => <WrappedComponent {...{[as || 'user']: user, ...props}} />}
import * as Episode from './Episode';
import Round from './Round';
+export const hasGlobalRole = (user, role) =>
+ user && role && user.global_roles && user.global_roles.includes(role);
+
export const isAdmin = user => user && user.role === 'admin';
export const isSameUser = (user, subject) => user && subject && user.id === subject.id;
user && channel && user.channel_crews &&
user.channel_crews.find(c => c.role === 'admin' && c.channel_id === channel.id);
+// Content
+
+export const mayEditContent = user =>
+ user && hasGlobalRole(user, 'content');
+
// Episodes
export const isCommentator = (user, episode) => {
stop: 'Stop',
unconfirm: 'Zurückziehen',
},
+ content: {
+ attribution: 'Attribution',
+ description: 'Beschreibung',
+ edit: 'Inhalt bearbeiten',
+ saveError: 'Fehler beim Speichern',
+ saveSuccess: 'Gespeichert',
+ short: 'Kurzbeschreibung',
+ title: 'Titel',
+ },
crew: {
roles: {
commentary: 'Kommentar',
stop: 'Stop',
unconfirm: 'Retract',
},
+ content: {
+ attribution: 'Attribution',
+ description: 'Description',
+ edit: 'Edit content',
+ saveError: 'Error saving',
+ saveSuccess: 'Saved',
+ short: 'Short description',
+ title: 'Title',
+ },
crew: {
roles: {
commentary: 'Commentary',
Route::get('content', 'App\Http\Controllers\TechniqueController@search');
Route::get('content/{tech:name}', 'App\Http\Controllers\TechniqueController@single');
+Route::put('content/{content}', 'App\Http\Controllers\TechniqueController@update');
Route::get('discord-guilds', 'App\Http\Controllers\DiscordGuildController@search');
Route::get('discord-guilds/{guild_id}', 'App\Http\Controllers\DiscordGuildController@single');