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

feat: add thumbnail form section

parent 4ee93ef4
Pipeline #193523 passed with stages
in 15 minutes and 55 seconds
import type { MediaDetails } from '@/api/sshoc'
import { useImportMedia, useUploadMedia } from '@/api/sshoc'
import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast'
import { MediaError } from '@/lib/error/MediaError'
import { useAuth } from '@/modules/auth/AuthContext'
import { FormFileInput } from '@/modules/form/components/FormFileInput/FormFileInput'
import { FormTextField } from '@/modules/form/components/FormTextField/FormTextField'
import { Form } from '@/modules/form/Form'
import { isUrl } from '@/modules/form/validate'
interface AddMediaFormValues {
files?: FileList
sourceUrl?: string
caption?: string
}
interface AddMediaFormProps {
onDismiss: () => void
onSuccess?: (mediaInfo: MediaDetails, caption?: string) => void
accept?: string
}
/**
* Add media form.
*/
export function AddMediaForm(props: AddMediaFormProps): JSX.Element {
const auth = useAuth()
const uploadMedia = useUploadMedia()
const importMedia = useImportMedia()
const toast = useToast()
const accept = props.accept ?? 'image/*,video/*'
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(error: unknown) {
toast.error(
error instanceof MediaError ? error.message : 'Failed to add media.',
)
},
onSettled() {
props.onDismiss()
},
}
if (values.files !== undefined) {
if (values.files.length === 0) return
const file = values.files[0]
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={accept}
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>
)
}
......@@ -10,6 +10,7 @@ import { MainFormSection } from '@/components/item/MainFormSection/MainFormSecti
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { ThumbnailFormSection } from '@/components/item/ThumbnailFormSection/ThumbnailFormSection'
import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast'
import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues'
......@@ -152,6 +153,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<ActorsFormSection />
<PropertiesFormSection />
<MediaFormSection />
<ThumbnailFormSection />
<RelatedItemsFormSection />
<div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link">
......
......@@ -10,6 +10,7 @@ import { MainFormSection } from '@/components/item/MainFormSection/MainFormSecti
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { ThumbnailFormSection } from '@/components/item/ThumbnailFormSection/ThumbnailFormSection'
import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast'
import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues'
......@@ -154,6 +155,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<ActorsFormSection initialValues={props.item} />
<PropertiesFormSection initialValues={props.item} />
<MediaFormSection initialValues={props.item} />
<ThumbnailFormSection initialValues={props.item} />
<RelatedItemsFormSection initialValues={props.item} />
<div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link">
......
......@@ -2,22 +2,14 @@ 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 { AddMediaForm } from '@/components/item/AddMediaForm/AddMediaForm'
import { Thumbnail } from '@/components/item/Thumbnail/Thumbnail'
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
......@@ -50,45 +42,12 @@ export function MediaFormSection(props: MediaFormSectionProps): JSX.Element {
<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"
<Thumbnail
onRemove={() => fields.remove(index)}
media={input.value}
caption={input.value.caption}
/>
</div>
)}
<figcaption>
{caption ?? filename ?? location?.sourceUrl}
</figcaption>
</figure>
)
}}
</FormField>
......@@ -117,7 +76,7 @@ export function MediaFormSection(props: MediaFormSectionProps): JSX.Element {
interface AddMediaDialogProps {
isOpen: boolean
onDismiss: () => void
onSuccess: (mediaInfo: MediaDetails, caption?: string) => void
onSuccess?: (mediaInfo: MediaDetails, caption?: string) => void
}
/**
......@@ -136,6 +95,7 @@ function AddMediaDialog(props: AddMediaDialogProps) {
onClick={props.onDismiss}
className="self-end"
aria-label="Close dialog"
type="button"
>
<Icon icon={CloseIcon} className="" />
</button>
......@@ -147,143 +107,3 @@ function AddMediaDialog(props: AddMediaDialogProps) {
</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>
)
}
......@@ -10,6 +10,7 @@ import { MainFormSection } from '@/components/item/MainFormSection/MainFormSecti
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { ThumbnailFormSection } from '@/components/item/ThumbnailFormSection/ThumbnailFormSection'
import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast'
import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues'
......@@ -151,6 +152,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<ActorsFormSection />
<PropertiesFormSection />
<MediaFormSection />
<ThumbnailFormSection />
<RelatedItemsFormSection />
<div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link">
......
......@@ -10,6 +10,7 @@ import { MainFormSection } from '@/components/item/MainFormSection/MainFormSecti
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { ThumbnailFormSection } from '@/components/item/ThumbnailFormSection/ThumbnailFormSection'
import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast'
import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues'
......@@ -154,6 +155,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<ActorsFormSection initialValues={props.item} />
<PropertiesFormSection initialValues={props.item} />
<MediaFormSection initialValues={props.item} />
<ThumbnailFormSection initialValues={props.item} />
<RelatedItemsFormSection initialValues={props.item} />
<div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link">
......
import type { MediaDetails } from '@/api/sshoc'
import { getMediaThumbnailUrl } from '@/api/sshoc/client'
import { Icon } from '@/elements/Icon/Icon'
import { Svg as CloseIcon } from '@/elements/icons/small/cross.svg'
import DocumentIcon from '@/elements/icons/small/document.svg'
export interface ThumbnailProps {
onRemove?: () => void
media?: MediaDetails
caption?: string
}
/**
* Thumbnail.
*/
export function Thumbnail(props: ThumbnailProps): JSX.Element | null {
if (props.media == null) return null
const { mediaId, filename, hasThumbnail, location } = props.media
const caption = props.caption
return (
<figure className="relative flex flex-col items-center p-2 space-y-2 w-80">
{props.onRemove !== undefined ? (
<button
onClick={props.onRemove}
className="absolute flex flex-col items-center justify-center transition bg-white border rounded cursor-default top-6 right-4 hover:bg-gray-200"
type="button"
>
<span className="sr-only">Delete</span>
<Icon icon={CloseIcon} className="w-5 h-5 p-1" />
</button>
) : null}
{hasThumbnail === true && mediaId != null ? (
<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>
)
}
import { Dialog } from '@reach/dialog'
import { Fragment, useState } from 'react'
import type { MediaDetails } from '@/api/sshoc'
import { AddMediaForm as AddThumbnailForm } from '@/components/item/AddMediaForm/AddMediaForm'
import { Icon } from '@/elements/Icon/Icon'
import { Svg as CloseIcon } from '@/elements/icons/small/cross.svg'
import { MediaError } from '@/lib/error/MediaError'
import { FormFieldAddButton } from '@/modules/form/components/FormFieldAddButton/FormFieldAddButton'
import { FormSection } from '@/modules/form/components/FormSection/FormSection'
import { FormField } from '@/modules/form/FormField'
import { Thumbnail } from '../Thumbnail/Thumbnail'
export interface ThumbnailFormSectionProps {
initialValues?: any
prefix?: string
}
/**
* Form section for choosing item thumbnail.
*/
export function ThumbnailFormSection(
props: ThumbnailFormSectionProps,
): JSX.Element {
const prefix = props.prefix ?? ''
const addThumbnailDialog = useDialogState()
const chooseThumbnailDialog = useDialogState()
return (
<FormSection title={'Thumbnail'}>
<FormField name={`${prefix}thumbnail`}>
{({ input }) => {
return (
<div>
{input.value != null && input.value !== '' ? (
<Thumbnail
onRemove={() => input.onChange(null)}
media={input.value}
/>
) : null}
<div className="flex items-center space-x-8">
<FormFieldAddButton onPress={addThumbnailDialog.open}>
{'Add new thumbnail'}
</FormFieldAddButton>
<AddThumbnailDialog
isOpen={addThumbnailDialog.isOpen}
onDismiss={addThumbnailDialog.close}
onSuccess={(mediaInfo, caption) => {
if (mediaInfo.category !== 'image') {
throw new MediaError('A thumbnail must be an image.')
}
input.onChange({ ...mediaInfo, caption })
}}
/>
<div>or</div>
<FormField name={`${prefix}media`}>
{({ input: media }) => {
const images =
media.value?.filter(
(m: MediaDetails) => m.category === 'image',
) ?? []
return (
<Fragment>
<FormFieldAddButton
isDisabled={images.length === 0}
onPress={chooseThumbnailDialog.open}
>
{'Choose thumbnail from images'}