]> git.localhorst.tv Git - alttp.git/commitdiff
basic content editing
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 2 Aug 2023 15:33:16 +0000 (17:33 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 2 Aug 2023 15:33:16 +0000 (17:33 +0200)
14 files changed:
app/Http/Controllers/TechniqueController.php
app/Models/User.php
app/Policies/TechniquePolicy.php
database/migrations/2023_08_02_114353_user_global_roles.php [new file with mode: 0644]
resources/js/components/pages/Technique.js
resources/js/components/techniques/Detail.js
resources/js/components/techniques/Dialog.js [new file with mode: 0644]
resources/js/components/techniques/Form.js [new file with mode: 0644]
resources/js/helpers/Technique.js
resources/js/helpers/UserContext.js
resources/js/helpers/permissions.js
resources/js/i18n/de.js
resources/js/i18n/en.js
routes/api.php

index 7173f1edce28ad515b364e79d2eeb98a454dead1..59559a94a1883edb46c50c7f71e33cd33f87b072 100644 (file)
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
 
 use App\Models\Technique;
 use App\Models\TechniqueMap;
+use App\Models\TechniqueTranslation;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Http\Request;
 
@@ -41,6 +42,52 @@ class TechniqueController extends Controller
                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',
index 72286a4891e48a5d789d2ba682eb4eff53fb79a1..db834b7a240ca423141f254a1494fc9916b82c22 100644 (file)
@@ -40,6 +40,10 @@ class User extends Authenticatable
        }
 
 
+       public function hasGlobalRole($name) {
+               return !empty($this->global_roles) && in_array($name, $this->global_roles);
+       }
+
        public function isAdmin() {
                return $this->role === 'admin';
        }
@@ -226,6 +230,7 @@ class User extends Authenticatable
                'avatar' => 'string',
                'avatar_cached' => 'datetime',
                'verified' => 'boolean',
+               'global_roles' => 'array',
                'locale' => 'string',
                'mfa_enabled' => 'boolean',
                'refresh_token' => 'encrypted',
index 3bebc881ca27f5a4a48bfdc84cd698f366d64e78..4cd87472f8329bb228c0b71494ef3f941c4e833f 100644 (file)
@@ -41,7 +41,7 @@ class TechniquePolicy
         */
        public function create(User $user)
        {
-               return $user->isAdmin();
+               return $user->isAdmin() || $user->hasGlobalRole('content');
        }
 
        /**
@@ -53,7 +53,7 @@ class TechniquePolicy
         */
        public function update(User $user, Technique $technique)
        {
-               return $user->isAdmin();
+               return $user->isAdmin() || $user->hasGlobalRole('content');
        }
 
        /**
diff --git a/database/migrations/2023_08_02_114353_user_global_roles.php b/database/migrations/2023_08_02_114353_user_global_roles.php
new file mode 100644 (file)
index 0000000..327b8e4
--- /dev/null
@@ -0,0 +1,32 @@
+<?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');
+               });
+       }
+};
index 4004707895ad23dc18607e1af5a1758c2b01e3fa..32acd284f56fcd5f4250432ed7c4368e4ced245c 100644 (file)
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from 'react';
 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';
@@ -11,17 +12,47 @@ import ErrorMessage from '../common/ErrorMessage';
 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);
@@ -64,7 +95,14 @@ const Technique = ({ type }) => {
                        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>;
 };
 
index 9d9534ed31f2d6831f883fa2ce043975e90958e7..8d5136dfc523c09e05efacfb99114b0912df2b07 100644 (file)
@@ -1,12 +1,13 @@
 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,
@@ -16,40 +17,71 @@ import {
 } 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({
                })),
@@ -60,4 +92,4 @@ Detail.propTypes = {
        }),
 };
 
-export default withTranslation()(Detail);
+export default Detail;
diff --git a/resources/js/components/techniques/Dialog.js b/resources/js/components/techniques/Dialog.js
new file mode 100644 (file)
index 0000000..96eaba5
--- /dev/null
@@ -0,0 +1,45 @@
+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;
diff --git a/resources/js/components/techniques/Form.js b/resources/js/components/techniques/Form.js
new file mode 100644 (file)
index 0000000..9bbb511
--- /dev/null
@@ -0,0 +1,132 @@
+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);
index f3daae7d1dfd53453dd1166edaa639f27dcf167d..7388ccb97bb5c3d7cb8816277a4e340235eef0f8 100644 (file)
@@ -38,6 +38,7 @@ export const getMatchedLocale = (tech, lang) => {
 };
 
 export const getTranslation = (tech, prop, lang) => {
+       if (!tech) return '';
        const direct = tech.translations.find(t => t.locale === lang);
        if (direct) {
                return direct[prop];
index 507af7d3122154c5ad2ed1a455a5c495278df881..927f262e5f01be3b0599860a1b4bbc281e389405 100644 (file)
@@ -2,6 +2,8 @@ import React from 'react';
 
 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}} />}
index 54bf905fe473a9224419c4325e1b6b9b5c72d5a2..1bdb2622b6a44bb9115521ba9d8a14f1c5503561 100644 (file)
@@ -4,6 +4,9 @@
 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;
@@ -14,6 +17,11 @@ export const isChannelAdmin = (user, channel) =>
        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) => {
index e3fdecb9ddce2b3b76f159d8f042f2800e40b4e4..5036ac0a6be5e63dae0d4017af5748142708c1b9 100644 (file)
@@ -80,6 +80,15 @@ export default {
                        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',
index f9e5bad22afbec6568206032cf36c116238982cd..ca0e4126c5a74f15d3dd2b510d387e0147a8418f 100644 (file)
@@ -80,6 +80,15 @@ export default {
                        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',
index 69a8aee813e42d1f2661fb7350f8d5f80acfe785..1bac2b3562dc2ad954822f1b4863c6c97b2239b5 100644 (file)
@@ -26,6 +26,7 @@ Route::post('application/{application}/reject', 'App\Http\Controllers\Applicatio
 
 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');