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 ...@@ -10,6 +10,7 @@ import { MainFormSection } from '@/components/item/MainFormSection/MainFormSecti
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection' import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection' import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection' import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { ThumbnailFormSection } from '@/components/item/ThumbnailFormSection/ThumbnailFormSection'
import { Button } from '@/elements/Button/Button' import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast' import { useToast } from '@/elements/Toast/useToast'
import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues' import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues'
...@@ -152,6 +153,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element { ...@@ -152,6 +153,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<ActorsFormSection /> <ActorsFormSection />
<PropertiesFormSection /> <PropertiesFormSection />
<MediaFormSection /> <MediaFormSection />
<ThumbnailFormSection />
<RelatedItemsFormSection /> <RelatedItemsFormSection />
<div className="flex items-center justify-end space-x-6"> <div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link"> <Button onPress={onCancel} variant="link">
......
...@@ -10,6 +10,7 @@ import { MainFormSection } from '@/components/item/MainFormSection/MainFormSecti ...@@ -10,6 +10,7 @@ import { MainFormSection } from '@/components/item/MainFormSection/MainFormSecti
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection' import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection' import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection' import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { ThumbnailFormSection } from '@/components/item/ThumbnailFormSection/ThumbnailFormSection'
import { Button } from '@/elements/Button/Button' import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast' import { useToast } from '@/elements/Toast/useToast'
import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues' import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues'
...@@ -154,6 +155,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element { ...@@ -154,6 +155,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<ActorsFormSection initialValues={props.item} /> <ActorsFormSection initialValues={props.item} />
<PropertiesFormSection initialValues={props.item} /> <PropertiesFormSection initialValues={props.item} />
<MediaFormSection initialValues={props.item} /> <MediaFormSection initialValues={props.item} />
<ThumbnailFormSection initialValues={props.item} />
<RelatedItemsFormSection initialValues={props.item} /> <RelatedItemsFormSection initialValues={props.item} />
<div className="flex items-center justify-end space-x-6"> <div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link"> <Button onPress={onCancel} variant="link">
......
...@@ -2,22 +2,14 @@ import { Dialog } from '@reach/dialog' ...@@ -2,22 +2,14 @@ import { Dialog } from '@reach/dialog'
import { useState } from 'react' import { useState } from 'react'
import type { MediaDetails } from '@/api/sshoc' import type { MediaDetails } from '@/api/sshoc'
import { useImportMedia, useUploadMedia } from '@/api/sshoc' import { AddMediaForm } from '@/components/item/AddMediaForm/AddMediaForm'
import { getMediaThumbnailUrl } from '@/api/sshoc/client' import { Thumbnail } from '@/components/item/Thumbnail/Thumbnail'
import { Button } from '@/elements/Button/Button'
import { Icon } from '@/elements/Icon/Icon' import { Icon } from '@/elements/Icon/Icon'
import { Svg as CloseIcon } from '@/elements/icons/small/cross.svg' 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 { FormFieldAddButton } from '@/modules/form/components/FormFieldAddButton/FormFieldAddButton'
import { FormFileInput } from '@/modules/form/components/FormFileInput/FormFileInput'
import { FormSection } from '@/modules/form/components/FormSection/FormSection' 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 { FormField } from '@/modules/form/FormField'
import { FormFieldArray } from '@/modules/form/FormFieldArray' import { FormFieldArray } from '@/modules/form/FormFieldArray'
import { isUrl } from '@/modules/form/validate'
export interface MediaFormSectionProps { export interface MediaFormSectionProps {
initialValues?: any initialValues?: any
...@@ -50,45 +42,12 @@ export function MediaFormSection(props: MediaFormSectionProps): JSX.Element { ...@@ -50,45 +42,12 @@ export function MediaFormSection(props: MediaFormSectionProps): JSX.Element {
<li key={name}> <li key={name}>
<FormField name={name}> <FormField name={name}>
{({ input }) => { {({ input }) => {
const {
mediaId,
filename,
caption,
hasThumbnail,
location,
} = input.value
return ( return (
<figure className="relative flex flex-col items-center p-2 space-y-2 w-80"> <Thumbnail
<button onRemove={() => fields.remove(index)}
onClick={() => fields.remove(index)} media={input.value}
className="absolute flex flex-col items-center justify-center transition bg-white border rounded cursor-default top-6 right-4 hover:bg-gray-200" caption={input.value.caption}
> />
<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> </FormField>
...@@ -117,7 +76,7 @@ export function MediaFormSection(props: MediaFormSectionProps): JSX.Element { ...@@ -117,7 +76,7 @@ export function MediaFormSection(props: MediaFormSectionProps): JSX.Element {
interface AddMediaDialogProps { interface AddMediaDialogProps {
isOpen: boolean isOpen: boolean
onDismiss: () => void onDismiss: () => void
onSuccess: (mediaInfo: MediaDetails, caption?: string) => void onSuccess?: (mediaInfo: MediaDetails, caption?: string) => void
} }
/** /**
...@@ -136,6 +95,7 @@ function AddMediaDialog(props: AddMediaDialogProps) { ...@@ -136,6 +95,7 @@ function AddMediaDialog(props: AddMediaDialogProps) {
onClick={props.onDismiss} onClick={props.onDismiss}
className="self-end" className="self-end"
aria-label="Close dialog" aria-label="Close dialog"
type="button"
> >
<Icon icon={CloseIcon} className="" /> <Icon icon={CloseIcon} className="" />
</button> </button>
...@@ -147,143 +107,3 @@ function AddMediaDialog(props: AddMediaDialogProps) { ...@@ -147,143 +107,3 @@ function AddMediaDialog(props: AddMediaDialogProps) {
</Dialog> </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 ...@@ -10,6 +10,7 @@ import { MainFormSection } from '@/components/item/MainFormSection/MainFormSecti
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection' import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection' import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection' import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { ThumbnailFormSection } from '@/components/item/ThumbnailFormSection/ThumbnailFormSection'
import { Button } from '@/elements/Button/Button' import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast' import { useToast } from '@/elements/Toast/useToast'
import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues' import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues'
...@@ -151,6 +152,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element { ...@@ -151,6 +152,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<ActorsFormSection /> <ActorsFormSection />
<PropertiesFormSection /> <PropertiesFormSection />
<MediaFormSection /> <MediaFormSection />
<ThumbnailFormSection />
<RelatedItemsFormSection /> <RelatedItemsFormSection />
<div className="flex items-center justify-end space-x-6"> <div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link"> <Button onPress={onCancel} variant="link">
......
...@@ -10,6 +10,7 @@ import { MainFormSection } from '@/components/item/MainFormSection/MainFormSecti ...@@ -10,6 +10,7 @@ import { MainFormSection } from '@/components/item/MainFormSection/MainFormSecti
import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection' import { MediaFormSection } from '@/components/item/MediaFormSection/MediaFormSection'
import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection' import { PropertiesFormSection } from '@/components/item/PropertiesFormSection/PropertiesFormSection'
import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection' import { RelatedItemsFormSection } from '@/components/item/RelatedItemsFormSection/RelatedItemsFormSection'
import { ThumbnailFormSection } from '@/components/item/ThumbnailFormSection/ThumbnailFormSection'
import { Button } from '@/elements/Button/Button' import { Button } from '@/elements/Button/Button'
import { useToast } from '@/elements/Toast/useToast' import { useToast } from '@/elements/Toast/useToast'
import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues' import { sanitizeFormValues } from '@/lib/sshoc/sanitizeFormValues'
...@@ -154,6 +155,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element { ...@@ -154,6 +155,7 @@ export function ItemForm(props: ItemFormProps<ItemFormValues>): JSX.Element {
<ActorsFormSection initialValues={props.item} /> <ActorsFormSection initialValues={props.item} />
<PropertiesFormSection initialValues={props.item} /> <PropertiesFormSection initialValues={props.item} />
<MediaFormSection initialValues={props.item} /> <MediaFormSection initialValues={props.item} />
<ThumbnailFormSection initialValues={props.item} />
<RelatedItemsFormSection initialValues={props.item} /> <RelatedItemsFormSection initialValues={props.item} />
<div className="flex items-center justify-end space-x-6"> <div className="flex items-center justify-end space-x-6">
<Button onPress={onCancel} variant="link"> <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'