浏览代码

fixing the forms

Tomi Cvetic 4 年之前
父节点
当前提交
fee13fea6d

+ 8 - 7
docker-compose.yml

@@ -8,10 +8,8 @@ services:
     volumes:
       - './frontend:/app'
       - './backend/database:/database'
-      - '/app/node_modules'
+      - 'frontend-nm:/app/node_modules'
       - '/app/.next'
-    ports:
-      - '127.0.0.1:8800:3000'
     environment:
       - NODE_ENV=development
     depends_on:
@@ -23,9 +21,7 @@ services:
       context: backend
     volumes:
       - './backend:/app'
-      - '/app/node_modules'
-    ports:
-      - '127.0.0.1:8801:4000'
+      - 'backend-nm:/app/node_modules'
     environment:
       - NODE_ENV=development
       - PRISMA_MANAGEMENT_API_SECRET=PrismaSecret
@@ -56,7 +52,7 @@ services:
     environment:
       MYSQL_ROOT_PASSWORD: prisma
     volumes:
-      - '/var/lib/mysql'
+      - 'mysql:/var/lib/mysql'
       - './dbBackup:/backup'
 
   proxy:
@@ -66,3 +62,8 @@ services:
       - '8820:8820'
     volumes:
       - './proxy/nginx.conf:/etc/nginx/nginx.conf:ro'
+
+volumes:
+  frontend-nm:
+  backend-nm:
+  mysql:

+ 9 - 5
frontend/pages/admin/training/[id].tsx

@@ -1,18 +1,22 @@
 import { useRouter } from 'next/router'
 import EditTraining from '../../../src/training/components/EditTraining'
 import { useTrainingQuery } from '../../../src/gql'
+import { AdminPage } from '../../../src/app'
 
 const EditTrainingPage = () => {
   const router = useRouter()
   const { id } = router.query
   const { data, error, loading } = useTrainingQuery({
-    variables: { id: typeof id === 'string' ? id : id[0] }
+    variables: { id: typeof id === 'string' ? id : id[0] },
   })
 
-  if (loading) return <p>Loading data...</p>
-  if (error) return <p>Error loading data.</p>
-  if (data?.training) return <EditTraining training={data.training} />
-  else return <p>Training {id} not found.</p>
+  let content
+  if (loading) content = <p>Loading data...</p>
+  if (error) content = <p>Error loading data.</p>
+  if (data?.training) content = <EditTraining training={data.training} />
+  else content = <p>Training {id} not found.</p>
+
+  return <AdminPage>{content}</AdminPage>
 }
 
 export default EditTrainingPage

+ 6 - 1
frontend/pages/admin/training/create.tsx

@@ -1,9 +1,14 @@
 import EditTraining from '../../../src/training/components/EditTraining'
 import { emptyTraining } from '../../../src/training/utils'
+import { AdminPage } from '../../../src/app'
 
 const CreateTrainingPage = () => {
   const newTraining = emptyTraining()
-  return <EditTraining training={newTraining} />
+  return (
+    <AdminPage>
+      <EditTraining training={newTraining} />
+    </AdminPage>
+  )
 }
 
 export default CreateTrainingPage

+ 7 - 8
frontend/pages/index.tsx

@@ -49,9 +49,9 @@ const Home = () => {
             position: relative;
           }
           .training-title::before {
-            content: 'your next training:';
+            content: 'your next training';
             text-transform: none;
-            font-weight: 900;
+            font-weight: 300;
             font-size: 1.1rem;
             display: block;
             position: absolute;
@@ -95,9 +95,8 @@ const Home = () => {
                 to bottom,
                 #84cae7,
                 #84cae7 400px,
-                #c7c7c7 400px,
-                #c7c7c7 401px,
-                #ebebeb 401px
+                #cdcdcd 400px,
+                #ebebeb 406px
               );
             }
             .training-title {
@@ -109,11 +108,11 @@ const Home = () => {
               display: block;
               position: absolute;
               top: -0.7rem;
-              left: 0.4rem;
+              left: 0.8rem;
               color: ${theme.colors.darkerblue};
             }
             .training-meta-wrap {
-              filter: drop-shadow(0 0 12px rgba(0, 0, 0, 0.18));
+              filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.5));
             }
             .next-training :global(.training-meta) {
               grid-area: info;
@@ -134,7 +133,7 @@ const Home = () => {
               padding: 2em;
               background-color: #ebebeb;
               /* box-shadow: ${theme.bs}; */
-              filter: drop-shadow(0 0 12px rgba(0,0,0,0.18));
+              filter: drop-shadow(0 0 10px rgba(0,0,0,0.18));
             }
           }
         `}

+ 4 - 0
frontend/src/app/components/AdminPage.tsx

@@ -21,9 +21,13 @@ const AdminPage: FunctionComponent = ({ children }) => {
       <style jsx>{`
         @media (min-width: 768px) {
           .admin-page {
+            min-height: 100%;
             display: grid;
             grid-template-columns: 300px 1fr;
           }
+          .admin-content {
+            padding: 2em 3em;
+          }
         }
       `}</style>
     </div>

+ 37 - 4
frontend/src/app/components/AdminSideBar.tsx

@@ -4,13 +4,14 @@ import theme from '../../styles/theme'
 const AdminSideBar = () => {
   return (
     <nav className='admin-sidebar'>
+      <h3>Admin Area</h3>
       <ul className='admin-menu'>
-        <Link href='training'>
+        <Link href='/admin/training'>
           <a>
             <li className='admin-item'>Trainings</li>
           </a>
         </Link>
-        <Link href='user'>
+        <Link href='/admin/user'>
           <a>
             <li className='admin-item'>Users</li>
           </a>
@@ -18,10 +19,42 @@ const AdminSideBar = () => {
       </ul>
 
       <style jsx>{`
-        nav * {
-          all: unset;
+        nav {
+          margin: 0;
+          padding: 0;
           color: ${theme.colors.mobileNav};
           background-color: ${theme.colors.mobileNavBackground};
+          min-height: 100%;
+        }
+        nav h1,
+        nav h2,
+        nav h3,
+        nav h4 {
+          color: ${theme.colors.mobileNav};
+          padding: 0.9em;
+        }
+        nav ul {
+          list-style: none;
+          margin: 0;
+          padding: 0;
+        }
+        nav li {
+          display: block;
+          padding: 0.9em;
+          border-top: 1px solid #dedede44;
+        }
+        nav a:last-child li {
+          border-bottom: 1px solid #dedede44;
+        }
+        nav li:hover {
+          box-shadow: ${theme.bsSmall};
+        }
+        nav a {
+          color: ${theme.colors.mobileNav};
+          text-decoration: none;
+        }
+        nav a:hover {
+          color: ${theme.colors.lightblue};
         }
       `}</style>
     </nav>

+ 57 - 22
frontend/src/form/components/Checkbox.tsx

@@ -3,30 +3,65 @@ import { DetailedHTMLProps, InputHTMLAttributes } from 'react'
 type ICheckbox = {
   value?: boolean
   label?: string
-} & Omit<
-  DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
-  'value'
->
+} & Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, 'value'>
 
-const Checkbox = ({
-  name,
-  label,
-  id,
-  value = false,
-  type,
-  ...props
-}: ICheckbox) => {
+const Checkbox = ({ name, label, id, value = false, type, ...props }: ICheckbox) => {
   return (
-    <>
-      {label && <label htmlFor={id || name}>{label}</label>}
-      <input
-        id={id || label}
-        name={name}
-        checked={value}
-        type='checkbox'
-        {...props}
-      />
-    </>
+    <div className='checkbox'>
+      <label htmlFor={id || name}>
+        {label || name}
+        <span className={value ? 'checked' : 'unchecked'} />
+      </label>
+      <input id={id || name} name={name} checked={value} type='checkbox' {...props} />
+      <style jsx>{`
+        input {
+          display: none;
+        }
+        label span {
+          position: relative;
+          display: block;
+          width: 1.2em;
+          height: 1.2em;
+          background: transparent;
+          border: 2px solid black;
+        }
+        label span.checked {
+          border: 2px solid green;
+        }
+
+        label span::after,
+        label span::before {
+          display: block;
+          content: '';
+          position: absolute;
+          height: 2px;
+          left: 0;
+          right: 0;
+          top: 50%;
+          vertical-align: middle;
+          background-color: black;
+          transition: all 250ms ease-in-out;
+        }
+        label span.checked::before,
+        label span.checked::after {
+          background-color: green;
+        }
+
+        label span::before {
+          transform: translate(0, -50%) rotate(-45deg) scale(0);
+        }
+        label span::after {
+          transform: translate(0, -50%) rotate(45deg) scale(0);
+        }
+
+        label span.checked::before {
+          transform: translate(0, -50%) rotate(-45deg) scale(1);
+        }
+        label span.checked::after {
+          transform: translate(0, -50%) rotate(45deg) scale(1);
+        }
+      `}</style>
+    </div>
   )
 }
 

+ 19 - 11
frontend/src/form/components/DateTimeInput.tsx

@@ -1,15 +1,11 @@
 import { format, parse, parseISO } from 'date-fns'
-import {
-  useState,
-  ChangeEvent,
-  DetailedHTMLProps,
-  InputHTMLAttributes
-} from 'react'
+import { useState, ChangeEvent, DetailedHTMLProps, InputHTMLAttributes } from 'react'
 
 type IDateTime = {
   onChange: GenericEventHandler
   label?: string
   value: string
+  className?: string
 } & Omit<
   DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
   'onChange' | 'value'
@@ -22,6 +18,7 @@ const DateTimeInput = ({
   type,
   value,
   onChange,
+  className,
   ...props
 }: IDateTime) => {
   const timeFormat = 'HH:mm'
@@ -29,7 +26,7 @@ const DateTimeInput = ({
 
   const [state, setState] = useState({
     DTILOCALSTATEdate: format(parseISO(value), dateFormat),
-    DTILOCALSTATEtime: format(parseISO(value), timeFormat)
+    DTILOCALSTATEtime: format(parseISO(value), timeFormat),
   })
 
   function handleChange(event: ChangeEvent<HTMLInputElement>) {
@@ -46,14 +43,14 @@ const DateTimeInput = ({
       target: {
         name,
         type: 'custom',
-        value: timeISO.toISOString()
-      }
+        value: timeISO.toISOString(),
+      },
     }
     onChange(returnEvent)
   }
 
   return (
-    <>
+    <div className={className}>
       <input
         type='date'
         id='DTILOCALSTATEdate'
@@ -73,7 +70,18 @@ const DateTimeInput = ({
         onChange={handleChange}
         onBlur={handleBlur}
       />
-    </>
+
+      <style jsx>{`
+        .${className} {
+          display: flex;
+          justify-content: space-between;
+        }
+        input {
+          display: block;
+          flex-grow: 1;
+        }
+      `}</style>
+    </div>
   )
 }
 

+ 15 - 2
frontend/src/form/components/Select.tsx

@@ -1,9 +1,22 @@
 const Select = ({ name, id, label, touched, error, ...props }: any) => {
   return (
     <>
-      {label && <label htmlFor={props.id || props.name}>{label}</label>}
-      <select name={name} id={id || name} {...props} />
+      {label && <label htmlFor={id || name}>{label}</label>}
+      <select name={name} id={id || name} {...props}>
+        {props.children}
+      </select>
       {touched && error && <div className='error'>{error}</div>}
+      <style jsx>{`
+        label {
+          display: none;
+        }
+        select {
+          border: none;
+          decoration: none;
+          -moz-decoration: none;
+          -webkit-decoration: none;
+        }
+      `}</style>
     </>
   )
 }

+ 13 - 2
frontend/src/form/components/TextInput.tsx

@@ -5,7 +5,7 @@ type ITextInput = {
   label?: string
 } & Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, 'onChange'>
 
-const TextInput = ({ name, label, id, type, onChange, value, ...props }: ITextInput) => {
+const TextInput = ({ name, label, id, type, onChange, value, className, ...props }: ITextInput) => {
   function handleChange(event: ChangeEvent<HTMLInputElement>) {
     if (event.target.type === 'number') {
       const newValue = {
@@ -22,16 +22,27 @@ const TextInput = ({ name, label, id, type, onChange, value, ...props }: ITextIn
 
   return (
     <>
-      {label && <label htmlFor={id || name}>{label}</label>}
+      {label && (
+        <label htmlFor={id || name} className={className}>
+          {label}
+        </label>
+      )}
       <input
         id={id || name}
         name={name}
         value={value ?? ''}
         type={type}
+        className={className}
         placeholder={label}
         onChange={handleChange}
         {...props}
       />
+
+      <style jsx>{`
+        label {
+          display: none;
+        }
+      `}</style>
     </>
   )
 }

+ 34 - 6
frontend/src/styles/global.ts

@@ -56,10 +56,10 @@ const GlobalStyle = css.global`
   h4,
   h5,
   h6,
-  th {
+  th,
+  legend {
     font-family: 'Exo 2', sans-serif;
     font-weight: 700;
-    color: ${theme.colors.darkerblue};
   }
 
   /* Use monospace font for pre */
@@ -72,11 +72,11 @@ const GlobalStyle = css.global`
   form textbox {
     display: block;
     width: 100%;
-    margin: 0.8em auto;
+    margin: 0.3em auto;
     padding: 0.3em;
     border: 2px solid ${theme.colors.formBackground}00;
     color: ${theme.colors.formColor};
-    background-color: ${theme.colors.formBackground};
+    background-color: ${theme.colors.formBackground}22;
     transition: all 250ms ease-in-out;
   }
 
@@ -84,8 +84,16 @@ const GlobalStyle = css.global`
   form select:focus,
   form textbox:focus {
     color: ${theme.colors.formHighlight};
-    background-color: ${theme.colors.formHighlightBackground}55;
-    border-bottom: 2px solid ${theme.colors.formHighlightBackground}ff;
+    background-color: ${theme.colors.formHighlightBackground}22;
+    border-bottom: 2px solid ${theme.colors.formHighlightBackground}22;
+  }
+
+  form input.error,
+  form select.error,
+  form textbox.error {
+    color: ${theme.colors.formError};
+    background-color: ${theme.colors.formErrorBackground}22;
+    border-bottom: 2px solid ${theme.colors.formErrorBackground}22;
   }
 
   button {
@@ -96,6 +104,26 @@ const GlobalStyle = css.global`
     background-color: ${theme.colors.darkerblue};
     color: ${theme.colors.offWhite};
   }
+
+  fieldset {
+    border: none;
+    margin: none;
+    padding: 1em 0.3em;
+    background: ${theme.colors.offWhite};
+    box-shadow: ${theme.bsSmall};
+  }
+
+  fieldset legend {
+    font-size: 120%;
+    padding: 0.5em;
+    box-shadow: ${theme.bsSmall};
+  }
+
+  @media (min-width: 768px) {
+    fieldset {
+      padding: 2em 3em;
+    }
+  }
 `
 
 export default GlobalStyle

+ 10 - 8
frontend/src/styles/theme.ts

@@ -17,19 +17,21 @@ const theme = {
 
     color: '#424242',
     background: '#ededed',
-    links: '#4e386b',
-    linksHighlight: '#6a4c93',
+    links: '#00A54D',
+    linksHighlight: '#35D27E',
     headerColor: '#424242',
     headerBackground: '#efefef',
     footerBackground: '#393939',
-    formColor: '#204567',
-    formBackground: '#b0d3f0',
-    formHighlight: '#ffca3a',
-    formHighlightBackground: '#f4f1bb',
+    formColor: '#085B96',
+    formBackground: '#8CC1E7',
+    formHighlight: '#00A54D',
+    formHighlightBackground: '#88ECB6',
+    formError: '#EA3500',
+    formErrorBackground: '#FFAC94',
     mobileNav: '#efefef',
-    mobileNavBackground: '#393939e7',
+    mobileNavBackground: '#565656',
     presentation: '#e6ebe0',
-    presentationBackground: '#31afd4',
+    presentationBackground: '#3C8EC8',
   },
   theme2: {
     terraCotta: '#ed6a5a',

+ 61 - 41
frontend/src/training/components/BlockInstanceInputs.tsx

@@ -8,32 +8,23 @@ import { TBlockInstance } from '../types'
 const BlockInstanceInputs = ({
   value = [],
   name,
-  onChange
+  onChange,
 }: {
   value?: TBlockInstance[]
   name: string
   onChange: GenericEventHandler
 }) => {
-  const [state, setState] = useState(value.map(item => item.id))
+  const [state, setState] = useState(value.map((item) => item.id))
 
-  function updateOrderProperty<T extends { id: U }, U>(
-    values: T[],
-    orderList: U[]
-  ) {
-    const orderedValues = values.map(value => {
-      const order = orderList.findIndex(orderedId => orderedId === value.id)
+  function updateOrderProperty<T extends { id: U }, U>(values: T[], orderList: U[]) {
+    const orderedValues = values.map((value) => {
+      const order = orderList.findIndex((orderedId) => orderedId === value.id)
       return { ...value, order }
     })
     onChange({ target: { type: 'custom', name, value: orderedValues } })
   }
 
-  function onSortEnd({
-    oldIndex,
-    newIndex
-  }: {
-    oldIndex: number
-    newIndex: number
-  }) {
+  function onSortEnd({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) {
     const newOrder = arrayMove(state, oldIndex, newIndex)
     setState(newOrder)
     updateOrderProperty(value, newOrder)
@@ -41,18 +32,18 @@ const BlockInstanceInputs = ({
 
   useEffect(() => {
     const missingIds = value
-      .filter(item => !state.includes(item.id))
-      .filter(item => !item.id.startsWith('--'))
-      .map(item => item.id)
-    const stateWithoutRemovedItems = state.filter(stateId =>
-      value.find(item => stateId === item.id)
+      .filter((item) => !state.includes(item.id))
+      .filter((item) => !item.id.startsWith('--'))
+      .map((item) => item.id)
+    const stateWithoutRemovedItems = state.filter((stateId) =>
+      value.find((item) => stateId === item.id)
     )
     setState([...stateWithoutRemovedItems, ...missingIds])
   }, [value])
 
   const items = state
-    .map(stateId => {
-      const itemIndex = value.findIndex(item => item.id === stateId)
+    .map((stateId) => {
+      const itemIndex = value.findIndex((item) => item.id === stateId)
       if (itemIndex < 0) return null
       const item = value[itemIndex]
       return (
@@ -64,6 +55,7 @@ const BlockInstanceInputs = ({
             value={item.order}
             type='number'
             onChange={onChange}
+            className='bi-order'
           />
           <TextInput
             name={`${name}.${itemIndex}.rounds`}
@@ -71,50 +63,78 @@ const BlockInstanceInputs = ({
             value={item.rounds}
             type='number'
             onChange={onChange}
+            className='bi-rounds'
           />
           <TextInput
             name={`${name}.${itemIndex}.variation`}
             label='Variation'
             value={item.variation}
             onChange={onChange}
+            className='bi-variation'
           />
-          {item.block && (
-            <BlockInputs
-              name={`${name}.${itemIndex}.block`}
-              value={item.block}
-              onChange={onChange}
-            />
-          )}
+          <div className='bi-block'>
+            {item.block && (
+              <BlockInputs
+                name={`${name}.${itemIndex}.block`}
+                value={item.block}
+                onChange={onChange}
+              />
+            )}
+          </div>
           <button
             type='button'
-            onClick={ev => {
-              const newOrder = state.filter(orderedId => item.id !== orderedId)
+            onClick={(ev) => {
+              const newOrder = state.filter((orderedId) => item.id !== orderedId)
               setState(newOrder)
               const newValues = item.id?.startsWith('++')
                 ? [...value.slice(0, itemIndex), ...value.slice(itemIndex + 1)]
                 : [
                     ...value.slice(0, itemIndex),
                     { ...item, id: `--${item.id}` },
-                    ...value.slice(itemIndex + 1)
+                    ...value.slice(itemIndex + 1),
                   ]
               updateOrderProperty(newValues, newOrder)
             }}
+            className='bi-button'
           >
             Delete block
           </button>
+
+          <style jsx>{`
+            @media (min-width: 768px) {
+              div {
+                display: grid;
+                grid-template-areas:
+                  'order  variation'
+                  'rounds variation'
+                  'block  block'
+                  'button button';
+                grid-template-columns: 1fr 2fr;
+              }
+
+              div :global(.bi-order) {
+                grid-area: order;
+              }
+              div :global(.bi-rounds) {
+                grid-area: rounds;
+              }
+              div :global(.bi-variation) {
+                grid-area: variation;
+              }
+              div :global(.bi-block) {
+                grid-area: block;
+              }
+              div :global(.bi-button) {
+                grid-area: button;
+              }
+            }
+          `}</style>
         </div>
       )
     })
-    .filter(block => block !== null)
+    .filter((block) => block !== null)
 
-  return (
-    <SortableList
-      items={items}
-      onSortEnd={onSortEnd}
-      useDragHandle
-      lockAxis={'y'}
-    />
-  )
+  return <SortableList items={items} onSortEnd={onSortEnd} useDragHandle lockAxis={'y'} />
 }
 
 export default BlockInstanceInputs

+ 107 - 37
frontend/src/training/components/EditTraining.tsx

@@ -35,62 +35,132 @@ const EditTraining = ({ training }: { training?: TTraining }) => {
       }}
     >
       <fieldset className='fields-training'>
-        <p>
-          {values.createdAt} {values.id}
-        </p>
-        <TextInput name='title' label='Title' value={values.title} onChange={onChange} />
-        <TrainingTypeSelector name='type' value={values.type} onChange={onChange} />
+        <TextInput
+          name='title'
+          label='Title'
+          value={values.title}
+          onChange={onChange}
+          className='training-title'
+        />
+        <TrainingTypeSelector
+          name='type'
+          value={values.type}
+          onChange={onChange}
+          className='training-type'
+        />
         <DateTimeInput
           name='trainingDate'
           label='Training date'
           value={values.trainingDate}
           onChange={onChange}
+          className='training-time'
         />
-        <TextInput name='location' label='Location' value={values.location} onChange={onChange} />
-        <Registrations registrations={values.registrations} />
+        <TextInput
+          name='location'
+          label='Location'
+          value={values.location}
+          onChange={onChange}
+          className='training-location'
+        />
+        <Registrations registrations={values.registrations} className='training-registrations' />
         <TextInput
           name='attendance'
           label='Attendance'
           type='number'
           value={values.attendance}
           onChange={onChange}
+          className='training-attendance'
         />
-        <Ratings ratings={values.ratings} />
-        <Checkbox name='published' label='Published' value={values.published} onChange={onChange} />
-        <label>Blocks</label>
-        {values.blocks && (
-          <BlockInstanceInputs name='blocks' value={values.blocks} onChange={onChange} />
-        )}
-        <button
-          onClick={(event) => {
-            event.preventDefault()
-            const newBlock = emptyBlockInstance({
-              order: values.blocks
-                ? values.blocks.filter((block) => !block.id.startsWith('--')).length
-                : 0,
-            })
-            onChange({
-              target: {
-                type: 'custom',
-                name: 'blocks',
-                value: values.blocks ? [...values.blocks, newBlock] : [newBlock],
-              },
-            })
-          }}
-          type='button'
-        >
-          Add block
-        </button>
-        <button type='submit' disabled={createData.loading}>
-          Save
+        <Ratings ratings={values.ratings} className='training-ratings' />
+        <Checkbox
+          name='published'
+          label='Published'
+          value={values.published}
+          onChange={onChange}
+          className='published'
+        />
+        <fieldset className='training-blocks'>
+          <label>Blocks</label>
+          {values.blocks && (
+            <BlockInstanceInputs name='blocks' value={values.blocks} onChange={onChange} />
+          )}
+          <button
+            onClick={(event) => {
+              event.preventDefault()
+              const newBlock = emptyBlockInstance({
+                order: values.blocks
+                  ? values.blocks.filter((block) => !block.id.startsWith('--')).length
+                  : 0,
+              })
+              onChange({
+                target: {
+                  type: 'custom',
+                  name: 'blocks',
+                  value: values.blocks ? [...values.blocks, newBlock] : [newBlock],
+                },
+              })
+            }}
+            type='button'
+          >
+            Add block
+          </button>
+        </fieldset>
+        <button type='submit' disabled={createData.loading} className='training-button'>
+          Save Training
         </button>
         {createData.data && <span color='green'>Saved.</span>}
         {createData.error && <span color='red'>Error saving: {createData.error.message}</span>}
       </fieldset>
 
       <style jsx>{`
-        .fields-training {
-          display: grid;
+        .fields-training :global(.training-title) {
+          font-size: 120%;
+        }
+
+        @media (min-width: 768px) {
+          .fields-training {
+            display: grid;
+            grid-gap: 0.2em 2em;
+            grid-template-areas:
+              'title title type'
+              'time time location'
+              'published attendance empty'
+              'registrations ratings empty'
+              'blocks blocks blocks'
+              'button button button';
+            grid-template-columns: 1fr 1fr 1fr;
+          }
+          .fields-training :global(.training-title) {
+            grid-area: title;
+            font-size: 140%;
+          }
+          .fields-training :global(.training-type) {
+            grid-area: type;
+          }
+          .fields-training :global(.training-time) {
+            grid-area: time;
+          }
+          .fields-training :global(.training-location) {
+            grid-area: location;
+          }
+          .fields-training :global(.training-published) {
+            grid-area: published;
+          }
+          .fields-training :global(.training-attendance) {
+            grid-area: attendance;
+          }
+          .fields-training :global(.training-registrations) {
+            grid-area: registrations;
+          }
+          .fields-training :global(.training-ratings) {
+            grid-area: ratings;
+          }
+          .fields-training :global(.training-blocks) {
+            grid-area: blocks;
+          }
+          .fields-training :global(.training-button) {
+            grid-area: button;
+          }
         }
       `}</style>
     </form>

+ 4 - 4
frontend/src/training/components/Ratings.tsx

@@ -1,12 +1,12 @@
 import { Rating } from '../../gql'
 
-const Ratings = ({ ratings }: { ratings?: Rating[] }) => {
+const Ratings = ({ ratings, className }: { ratings?: Rating[]; className?: string }) => {
   return (
-    <>
+    <div className={className}>
       <h2>Ratings</h2>
       {ratings ? (
         <ul>
-          {ratings.map(rating => (
+          {ratings.map((rating) => (
             <li key={rating.id}>
               {rating.comment} {rating.value} {rating.user.name}
             </li>
@@ -15,7 +15,7 @@ const Ratings = ({ ratings }: { ratings?: Rating[] }) => {
       ) : (
         <p>No ratings found</p>
       )}
-    </>
+    </div>
   )
 }
 

+ 10 - 4
frontend/src/training/components/Registrations.tsx

@@ -1,12 +1,18 @@
 import { User } from '../../gql'
 
-const Registrations = ({ registrations }: { registrations?: User[] }) => {
+const Registrations = ({
+  registrations,
+  className,
+}: {
+  registrations?: User[]
+  className: string
+}) => {
   return (
-    <>
+    <div className={className}>
       <h2>Registrations</h2>
       {registrations && registrations.length > 0 ? (
         <ul>
-          {registrations.map(registration => (
+          {registrations.map((registration) => (
             <li key={registration.id}>
               <button type='button' onClick={() => alert('not implemented.')}>
                 delete
@@ -18,7 +24,7 @@ const Registrations = ({ registrations }: { registrations?: User[] }) => {
       ) : (
         <p>No registrations found.</p>
       )}
-    </>
+    </div>
   )
 }
 

+ 25 - 31
frontend/src/training/components/TrainingTypeSelector.tsx

@@ -2,62 +2,47 @@ import { useTrainingTypesQuery, TrainingType } from '../../gql'
 import AddTrainingType from './AddTrainingType'
 import { useState, useEffect } from 'react'
 import { Modal } from '../../modal'
+import Select from '../../form/components/Select'
 
 interface ITrainingTypeSelector {
   value?: TrainingType
   onChange: GenericEventHandler
   name?: string
   label?: string
+  className?: string
 }
 
 const TrainingTypeSelector = ({
   value,
   onChange,
   name = 'type',
-  label = 'Training type'
+  label = 'Training type',
+  className = 'training-type',
 }: ITrainingTypeSelector) => {
   const [modalState, setModalState] = useState(false)
   const trainingTypes = useTrainingTypesQuery()
-  const id = value?.id
+  const id = value?.id ?? ''
 
   useEffect(() => {
-    if (
-      trainingTypes.data &&
-      trainingTypes.data.trainingTypes.length > 0 &&
-      !id
-    ) {
+    if (trainingTypes.data && trainingTypes.data.trainingTypes.length > 0 && !id) {
       onChange({
         target: {
           type: 'custom',
           value: trainingTypes.data.trainingTypes[0],
-          name
-        }
+          name,
+        },
       })
     }
   }, [trainingTypes.data])
 
   return (
-    <>
+    <div className={className}>
       <label>{label}</label>
-      <select
-        id={name}
-        name={name}
-        value={id}
-        onChange={event => {
-          const copy: CustomChangeEvent = {
-            target: {
-              type: 'custom',
-              value: { id: event.target.value },
-              name
-            }
-          }
-          onChange(copy)
-        }}
-      >
+      <select id={name} name={name} value={id} onChange={onChange}>
         {trainingTypes.loading && 'loading training types...'}
         {trainingTypes.error && 'error loading training types'}
         {trainingTypes.data &&
-          trainingTypes.data.trainingTypes.map(trainingType => (
+          trainingTypes.data.trainingTypes.map((trainingType) => (
             <option key={trainingType.id} value={trainingType.id}>
               {trainingType.name}
             </option>
@@ -65,7 +50,7 @@ const TrainingTypeSelector = ({
       </select>
       <button
         type='button'
-        onClick={event => {
+        onClick={(event) => {
           setModalState(true)
         }}
       >
@@ -73,21 +58,30 @@ const TrainingTypeSelector = ({
       </button>
       <Modal state={[modalState, setModalState]}>
         <AddTrainingType
-          onSuccess={result => {
+          onSuccess={(result) => {
             setModalState(false)
             if (result.data) {
               onChange({
                 target: {
                   type: 'custom',
                   value: { id: result.data.createTrainingType.id },
-                  name
-                }
+                  name,
+                },
               })
             }
           }}
         />
       </Modal>
-    </>
+
+      <style jsx>{`
+        label,
+        select,
+        button {
+          display: inline-block;
+          width: auto;
+        }
+      `}</style>
+    </div>
   )
 }
 export default TrainingTypeSelector

+ 1 - 1
proxy/nginx.conf

@@ -29,7 +29,7 @@ http{
     }
 
     location /graphql/ {
-      proxy_pass      http://backend:4000/graphql;
+      proxy_pass      http://backend:4000/graphql/;
       proxy_redirect  off;
     }
   }