Dear Gitlab users, due to maintenance reasons, Gitlab will not be available on Thursday 30.09.2021 from 5:00 pm to approximately 5:30 pm.

Commit c4829894 authored by Stefan Probst's avatar Stefan Probst
Browse files

feat: add media handling

parent 51139082
Pipeline #193280 passed with stages
in 16 minutes and 21 seconds
......@@ -8,7 +8,7 @@ import type { UseMutationOptions, UseMutationResult } from 'react-query'
import { useMutation } from 'react-query'
import type { ImplicitGrantTokenData, OAuthRegistrationDto } from '@/api/sshoc'
import { request } from '@/api/sshoc'
import { baseUrl, request } from '@/api/sshoc'
/**
* Sign in user with username and password.
......@@ -175,3 +175,19 @@ export function useValidateImplicitGrantTokenWithRegistration(
> {
return useMutation(validateImplicitGrantTokenWithRegistration, config)
}
export function getMediaThumbnailUrl({ mediaId }: { mediaId: string }): string {
const url = new URL(
`/api/media/thumbnail/${encodeURIComponent(mediaId)}`,
baseUrl,
)
return String(url)
}
export function getMediaFileUrl({ mediaId }: { mediaId: string }): string {
const url = new URL(
`/api/media/download/${encodeURIComponent(mediaId)}`,
baseUrl,
)
return String(url)
}
......@@ -102,6 +102,21 @@ export function convertToInitialFormValues(
id: item.source.id,
},
sourceItemId: item.sourceItemId,
/**
* Only id needed
*/
thumbnail: item.thumbnail,
/**
* Only id needed, but keep additional info around - will be stripped in `sanitizeFormValues` before submit.
*/
media: item.media?.map((m) => ({
caption: m.caption,
mediaId: m.metadata?.mediaId,
filename: m.metadata?.filename,
hasThumbnail: m.metadata?.hasThumbnail,
category: m.metadata?.category,
location: m.metadata?.location,
})),
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
......
This diff is collapsed.
......@@ -7,6 +7,7 @@ import type { ItemCategory } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
import { DateFormSection } from '@/components/item/DateFormSection/DateFormSection'
import { MainFormSection } from '@/components/item/MainFormSection/MainFormSection'
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { Button } from '@/elements/Button/Button'
......@@ -150,6 +151,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<DateFormSection />
<ActorsFormSection />
<PropertiesFormSection />
<MediaFormSection />
<RelatedItemsFormSection />
<div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link">
......
......@@ -7,6 +7,7 @@ import type { ItemCategory } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
import { DateFormSection } from '@/components/item/DateFormSection/DateFormSection'
import { MainFormSection } from '@/components/item/MainFormSection/MainFormSection'
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { Button } from '@/elements/Button/Button'
......@@ -152,6 +153,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<DateFormSection />
<ActorsFormSection initialValues={props.item} />
<PropertiesFormSection initialValues={props.item} />
<MediaFormSection initialValues={props.item} />
<RelatedItemsFormSection initialValues={props.item} />
<div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link">
......
import { Dialog } from '@reach/dialog'
import { useState } from 'react'
import type { MediaDetails } from '@/api/sshoc'
import { useImportMedia, useUploadMedia } from '@/api/sshoc'
import { getMediaThumbnailUrl } from '@/api/sshoc/client'
import { Button } from '@/elements/Button/Button'
import { Icon } from '@/elements/Icon/Icon'
import { Svg as CloseIcon } from '@/elements/icons/small/cross.svg'
import DocumentIcon from '@/elements/icons/small/document.svg'
import { useToast } from '@/elements/Toast/useToast'
import { useAuth } from '@/modules/auth/AuthContext'
import { FormFieldAddButton } from '@/modules/form/components/FormFieldAddButton/FormFieldAddButton'
import { FormFileInput } from '@/modules/form/components/FormFileInput/FormFileInput'
import { FormSection } from '@/modules/form/components/FormSection/FormSection'
import { FormTextField } from '@/modules/form/components/FormTextField/FormTextField'
import { Form } from '@/modules/form/Form'
import { FormField } from '@/modules/form/FormField'
import { FormFieldArray } from '@/modules/form/FormFieldArray'
import { isUrl } from '@/modules/form/validate'
export interface MediaFormSectionProps {
initialValues?: any
prefix?: string
}
/**
* Form section for media uploads.
*/
export function MediaFormSection(props: MediaFormSectionProps): JSX.Element {
const prefix = props.prefix ?? ''
const [isDialogOpen, setIsDialogOpen] = useState(false)
function openDialog() {
setIsDialogOpen(true)
}
function closeDialog() {
setIsDialogOpen(false)
}
return (
<FormSection title={'Media'}>
<FormFieldArray name={`${prefix}media`}>
{({ fields }) => {
return (
<div>
<ul className="grid grid-cols-3">
{fields.map((name, index) => {
return (
<li key={name}>
<FormField name={name}>
{({ input }) => {
const {
mediaId,
filename,
caption,
hasThumbnail,
location,
} = input.value
return (
<figure className="relative flex flex-col items-center p-2 space-y-2 w-80">
<button
onClick={() => fields.remove(index)}
className="absolute flex flex-col items-center justify-center transition bg-white border rounded cursor-default top-6 right-4 hover:bg-gray-200"
>
<span className="sr-only">Delete</span>
<Icon
icon={CloseIcon}
className="w-5 h-5 p-1"
/>
</button>
{hasThumbnail === true ? (
<img
src={getMediaThumbnailUrl({ mediaId })}
alt=""
className="object-contain w-full h-48 rounded shadow-md"
/>
) : (
<div className="grid object-contain w-full h-48 rounded shadow-md place-items-center">
<img
src={DocumentIcon}
alt=""
className="w-6 h-6"
/>
</div>
)}
<figcaption>
{caption ?? filename ?? location?.sourceUrl}
</figcaption>
</figure>
)
}}
</FormField>
</li>
)
})}
</ul>
<FormFieldAddButton onPress={openDialog}>
{'Add media'}
</FormFieldAddButton>
<AddMediaDialog
isOpen={isDialogOpen}
onDismiss={closeDialog}
onSuccess={(mediaInfo, caption) =>
fields.push({ ...mediaInfo, caption })
}
/>
</div>
)
}}
</FormFieldArray>
</FormSection>
)
}
interface AddMediaDialogProps {
isOpen: boolean
onDismiss: () => void
onSuccess: (mediaInfo: MediaDetails, caption?: string) => void
}
/**
* Dialog to upload media or add media url.
*/
function AddMediaDialog(props: AddMediaDialogProps) {
return (
<Dialog
isOpen={props.isOpen}
onDismiss={props.onDismiss}
aria-label="Add media"
className="flex flex-col w-full max-w-screen-lg px-32 py-16 mx-auto bg-white rounded shadow-lg"
style={{ width: '60vw', marginTop: '10vh', marginBottom: '10vh' }}
>
<button
onClick={props.onDismiss}
className="self-end"
aria-label="Close dialog"
>
<Icon icon={CloseIcon} className="" />
</button>
<section className="flex flex-col space-y-6">
<h2 className="text-2xl font-medium">Add media</h2>
{/* this form is rendered in a portal, so it's valid html, even though it's a <form> "nested" in another <form>. */}
<AddMediaForm onDismiss={props.onDismiss} onSuccess={props.onSuccess} />
</section>
</Dialog>
)
}
type AddMediaFormValues = any // ImportMedia.RequestBody | UploadMedia.QueryParameters
interface AddMediaFormProps {
onDismiss: () => void
onSuccess: (mediaInfo: MediaDetails, caption?: string) => void
}
/**
* Add media form.
*/
function AddMediaForm(props: AddMediaFormProps) {
const auth = useAuth()
const uploadMedia = useUploadMedia()
const importMedia = useImportMedia()
const toast = useToast()
function onSubmit(values: AddMediaFormValues) {
if (auth.session?.accessToken === undefined) {
toast.error('Authentication required.')
return Promise.reject()
}
const caption = values.caption
const callbacks = {
onSuccess(mediaInfo: MediaDetails) {
props.onSuccess(mediaInfo, caption)
toast.success('Sucessfully added media.')
},
onError() {
toast.error('Failed to add media.')
},
onSettled() {
props.onDismiss()
},
}
if (values.files !== undefined) {
if (values.files.length === 0) return
const [file] = values.files
const formData = new FormData()
formData.set('file', file)
uploadMedia.mutate(
[
// @ts-expect-error openapi document is wrong
undefined,
{
token: auth.session.accessToken,
hooks: {
request(_request) {
const request = new Request(_request, { body: formData })
return request
},
},
},
],
callbacks,
)
} else {
importMedia.mutate(
[values, { token: auth.session.accessToken }],
callbacks,
)
}
}
function onValidate(values: Partial<AddMediaFormValues>) {
const errors: Partial<Record<keyof typeof values, any>> = {}
if (values.sourceUrl !== undefined && !isUrl(values.sourceUrl)) {
errors.sourceUrl = 'Must be a valid URL.'
}
if (
values.sourceUrl !== undefined &&
values.files !== undefined &&
values.files.length > 0
) {
errors.sourceUrl = errors.files =
'Please choose between uploading a file, or importing a URL.'
}
return errors
}
return (
<Form onSubmit={onSubmit} validate={onValidate}>
{({ handleSubmit, pristine, invalid, submitting }) => {
return (
<form
noValidate
className="flex flex-col space-y-6"
onSubmit={handleSubmit}
>
<FormFileInput
name="files"
accept="image/*,video/*"
label="Upload media"
variant="form"
style={{ flex: 1 }}
/>
<p>or</p>
<FormTextField
name="sourceUrl"
label="Import media"
variant="form"
style={{ flex: 1 }}
placeholder="https://"
/>
<FormTextField
name="caption"
label="Caption"
variant="form"
style={{ flex: 1 }}
/>
<div className="flex justify-end space-x-12">
<Button variant="link" onPress={props.onDismiss}>
Cancel
</Button>
<Button
type="submit"
variant="gradient"
isLoading={uploadMedia.isLoading || importMedia.isLoading}
isDisabled={
pristine ||
invalid ||
submitting ||
uploadMedia.isLoading ||
importMedia.isLoading
}
>
Add
</Button>
</div>
</form>
)
}}
</Form>
)
}
......@@ -7,6 +7,7 @@ import type { ItemCategory } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
import { DateFormSection } from '@/components/item/DateFormSection/DateFormSection'
import { MainFormSection } from '@/components/item/MainFormSection/MainFormSection'
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { Button } from '@/elements/Button/Button'
......@@ -149,6 +150,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<DateFormSection />
<ActorsFormSection />
<PropertiesFormSection />
<MediaFormSection />
<RelatedItemsFormSection />
<div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link">
......
......@@ -7,6 +7,7 @@ import type { ItemCategory } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
import { DateFormSection } from '@/components/item/DateFormSection/DateFormSection'
import { MainFormSection } from '@/components/item/MainFormSection/MainFormSection'
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { Button } from '@/elements/Button/Button'
......@@ -152,6 +153,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<DateFormSection />
<ActorsFormSection initialValues={props.item} />
<PropertiesFormSection initialValues={props.item} />
<MediaFormSection initialValues={props.item} />
<RelatedItemsFormSection initialValues={props.item} />
<div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link">
......
......@@ -6,6 +6,7 @@ import { useCreateTool, useGetLoggedInUser } from '@/api/sshoc'
import type { ItemCategory } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
import { MainFormSection } from '@/components/item/MainFormSection/MainFormSection'
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { Button } from '@/elements/Button/Button'
......@@ -145,6 +146,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<MainFormSection />
<ActorsFormSection />
<PropertiesFormSection />
<MediaFormSection />
<RelatedItemsFormSection />
<div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link">
......
......@@ -6,6 +6,7 @@ import { useGetLoggedInUser, useUpdateTool } from '@/api/sshoc'
import type { ItemCategory } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
import { MainFormSection } from '@/components/item/MainFormSection/MainFormSection'
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { Button } from '@/elements/Button/Button'
......@@ -148,6 +149,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<MainFormSection />
<ActorsFormSection initialValues={props.item} />
<PropertiesFormSection initialValues={props.item} />
<MediaFormSection initialValues={props.item} />
<RelatedItemsFormSection initialValues={props.item} />
<div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link">
......
......@@ -6,6 +6,7 @@ import { useCreateTrainingMaterial, useGetLoggedInUser } from '@/api/sshoc'
import type { ItemCategory } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
import { MainFormSection } from '@/components/item/MainFormSection/MainFormSection'
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { Button } from '@/elements/Button/Button'
......@@ -145,6 +146,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<MainFormSection />
<ActorsFormSection />
<PropertiesFormSection />
<MediaFormSection />
<RelatedItemsFormSection />
<div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link">
......
......@@ -6,6 +6,7 @@ import { useGetLoggedInUser, useUpdateTrainingMaterial } from '@/api/sshoc'
import type { ItemCategory } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
import { MainFormSection } from '@/components/item/MainFormSection/MainFormSection'
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { Button } from '@/elements/Button/Button'
......@@ -148,6 +149,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<MainFormSection />
<ActorsFormSection initialValues={props.item} />
<PropertiesFormSection initialValues={props.item} />
<MediaFormSection initialValues={props.item} />
<RelatedItemsFormSection initialValues={props.item} />
<div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link">
......
......@@ -18,6 +18,7 @@ import {
import type { ItemCategory } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
import { MainFormSection } from '@/components/item/MainFormSection/MainFormSection'
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { WorkflowStepsFormSection } from '@/components/item/WorkflowStepsFormSection/WorkflowStepsFormSection'
......@@ -471,6 +472,7 @@ function WorkflowPage() {
<MainFormSection />
<ActorsFormSection />
<PropertiesFormSection />
<MediaFormSection />
<RelatedItemsFormSection />
</Fragment>
)
......
......@@ -19,6 +19,7 @@ import {
import type { ItemCategory } from '@/api/sshoc/types'
import { ActorsFormSection } from '@/components/item/ActorsFormSection/ActorsFormSection'
import { MainFormSection } from '@/components/item/MainFormSection/MainFormSection'
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { WorkflowStepsFormSection } from '@/components/item/WorkflowStepsFormSection/WorkflowStepsFormSection'
......@@ -535,6 +536,7 @@ function WorkflowPage(props: FormPageProps) {
<MainFormSection />
<ActorsFormSection initialValues={props.item} />
<PropertiesFormSection initialValues={props.item} />
<MediaFormSection initialValues={props.item} />
<RelatedItemsFormSection initialValues={props.item} />
</Fragment>
)
......
import { mergeProps } from '@react-aria/utils'
import type { ChangeEvent } from 'react'
import { Fragment, useState } from 'react'
import { Button } from '@/elements/Button/Button'
import { Field } from '@/elements/Field/Field'
import DocumentIcon from '@/elements/icons/small/document.svg'
import type { TextFieldProps } from '@/elements/TextField/TextField'
import { useErrorMessage } from '@/modules/a11y/useErrorMessage'
export interface FileInputProps
extends Omit<TextFieldProps, 'type' | 'onChange'> {
accept?: string
multiple?: boolean
onChange?: (files: FileList | null) => void
}
/**
* File input.
*/
export function FileInput(props: FileInputProps): JSX.Element {
const inputProps = {}
const labelProps = {}
const { fieldProps, errorMessageProps } = useErrorMessage(props)
const [files, setFiles] = useState<FileList | null>(null)
const [, forceRender] = useState(false)
function onChange(event: ChangeEvent<HTMLInputElement>) {
const files = event.currentTarget.files
setFiles(files)
// FileList is being mutated, so we force rerender
forceRender((v) => !v)
props.onChange?.(files)
}
const BrowseButton = (
<label>
{/* @ts-expect-error react-aria types hickup. */}
<Button elementType="span" variant="gradient">
Browse
</Button>
<input
type="file"
onChange={onChange}
name={props.name}
accept={props.accept}
multiple={props.multiple}
className="sr-only"
{...mergeProps(inputProps, fieldProps)}
/>
</label>
)
const Previews = (
<Fragment>
{files !== null ? (
<ul className="grid grid-cols-2 gap-4 py-2">
{Array.from(files).map((file) => {
const preview = file.type.startsWith('image') ? (
<img
src={URL.createObjectURL(file)}
alt="Preview thumbnail"
className="object-contain w-48 h-48 rounded shadow-md"
/>
) : file.type.startsWith('video') ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video