mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2025-12-12 23:36:00 +00:00
parent
afdf589b1e
commit
05f65291f4
66
package-lock.json
generated
66
package-lock.json
generated
@ -22,11 +22,14 @@
|
||||
"@tiptap/react": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"axios": "^1.6.5",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"classnames": "^2.5.1",
|
||||
"date-fns": "^3.3.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.locatecontrol": "^0.79.0",
|
||||
"radash": "^12.1.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-image-crop": "^10.1.8",
|
||||
"react-inlinesvg": "^4.2.0",
|
||||
@ -3852,6 +3855,15 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/attr-accept": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
@ -3989,6 +4001,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/browser-image-compression": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz",
|
||||
"integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uzip": "0.20201231.0"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.24.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
|
||||
@ -4333,6 +4354,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clean-stack": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||
@ -6407,6 +6434,18 @@
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@ -9586,7 +9625,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -10811,7 +10849,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@ -10823,7 +10860,6 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
@ -11148,6 +11184,23 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dropzone": {
|
||||
"version": "14.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
|
||||
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"attr-accept": "^2.2.4",
|
||||
"file-selector": "^2.1.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-from-dom": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/react-from-dom/-/react-from-dom-0.7.5.tgz",
|
||||
@ -12805,7 +12858,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsutils": {
|
||||
@ -13248,6 +13300,12 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/uzip": {
|
||||
"version": "0.20201231.0",
|
||||
"resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz",
|
||||
"integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/verror": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
|
||||
|
||||
@ -110,11 +110,14 @@
|
||||
"@tiptap/react": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"axios": "^1.6.5",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"classnames": "^2.5.1",
|
||||
"date-fns": "^3.3.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.locatecontrol": "^0.79.0",
|
||||
"radash": "^12.1.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-image-crop": "^10.1.8",
|
||||
"react-inlinesvg": "^4.2.0",
|
||||
|
||||
38
patches/react-dropzone/attr-accept.js
vendored
Normal file
38
patches/react-dropzone/attr-accept.js
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Patched version of attr-accept to fix compatibility issues with react-dropzone
|
||||
*/
|
||||
|
||||
function attrAccept(file, acceptedFiles) {
|
||||
if (file && acceptedFiles) {
|
||||
const acceptedFilesArray = Array.isArray(acceptedFiles)
|
||||
? acceptedFiles
|
||||
: acceptedFiles.split(',')
|
||||
|
||||
if (acceptedFilesArray.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fileName = file.name || ''
|
||||
const mimeType = (file.type || '').toLowerCase()
|
||||
const baseMimeType = mimeType.replace(/\/.*$/, '')
|
||||
|
||||
return acceptedFilesArray.some(function (type) {
|
||||
const validType = type.trim().toLowerCase()
|
||||
|
||||
if (validType.charAt(0) === '.') {
|
||||
return fileName.toLowerCase().endsWith(validType)
|
||||
} else if (validType.endsWith('/*')) {
|
||||
// This is something like a image/* mime type
|
||||
return baseMimeType === validType.replace(/\/.*$/, '')
|
||||
}
|
||||
|
||||
return mimeType === validType
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Export as both default and named export to support different import styles
|
||||
export default attrAccept
|
||||
export { attrAccept }
|
||||
@ -2,6 +2,8 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
import { linkItem } from './itemFunctions'
|
||||
|
||||
import type { Item } from '#types/Item'
|
||||
|
||||
const toastErrorMock: (t: string) => void = vi.fn()
|
||||
const toastSuccessMock: (t: string) => void = vi.fn()
|
||||
|
||||
@ -14,8 +16,45 @@ vi.mock('react-toastify', () => ({
|
||||
|
||||
describe('linkItem', () => {
|
||||
const id = 'some-id'
|
||||
let updateApi: () => void = vi.fn()
|
||||
const item = { layer: { api: { updateItem: () => updateApi() } } }
|
||||
let updateApi: (item: Partial<Item>) => Promise<Item> = vi.fn()
|
||||
const item: Item = {
|
||||
layer: {
|
||||
api: {
|
||||
updateItem: (item) => updateApi(item),
|
||||
getItems: vi.fn(),
|
||||
},
|
||||
name: '',
|
||||
menuIcon: '',
|
||||
menuColor: '',
|
||||
menuText: '',
|
||||
markerIcon: {
|
||||
image: '',
|
||||
},
|
||||
markerShape: 'square',
|
||||
markerDefaultColor: '',
|
||||
itemType: {
|
||||
name: 'Test Item Type',
|
||||
show_name_input: true,
|
||||
show_profile_button: false,
|
||||
show_start_end: true,
|
||||
show_start_end_input: true,
|
||||
show_text: true,
|
||||
show_text_input: true,
|
||||
custom_text: 'This is a custom text for the item type.',
|
||||
profileTemplate: [
|
||||
{ collection: 'users', id: null, item: {} },
|
||||
{ collection: 'posts', id: '123', item: {} },
|
||||
],
|
||||
offers_and_needs: true,
|
||||
icon_as_labels: {},
|
||||
relations: true,
|
||||
template: 'default',
|
||||
questlog: false,
|
||||
},
|
||||
},
|
||||
id: '',
|
||||
name: '',
|
||||
}
|
||||
const updateItem = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
import classNames from 'classnames'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
@ -46,6 +46,8 @@ export function ProfileForm() {
|
||||
start: '',
|
||||
end: '',
|
||||
openCollectiveSlug: '',
|
||||
gallery: [],
|
||||
uploadingImages: [],
|
||||
})
|
||||
|
||||
const [updatePermission, setUpdatePermission] = useState<boolean>(false)
|
||||
@ -132,6 +134,8 @@ export function ProfileForm() {
|
||||
start: item.start ?? '',
|
||||
end: item.end ?? '',
|
||||
openCollectiveSlug: item.openCollectiveSlug ?? '',
|
||||
gallery: item.gallery ?? [],
|
||||
uploadingImages: [],
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [item, tags, items])
|
||||
@ -147,6 +151,8 @@ export function ProfileForm() {
|
||||
}
|
||||
}, [item, layers])
|
||||
|
||||
const isUpdatingGallery = state.uploadingImages.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<MapOverlayPage
|
||||
@ -192,15 +198,20 @@ export function ProfileForm() {
|
||||
state={state}
|
||||
setState={setState}
|
||||
updatePermission={updatePermission}
|
||||
linkItem={(id) => linkItem(id, item, updateItem)}
|
||||
unlinkItem={(id) => unlinkItem(id, item, updateItem)}
|
||||
linkItem={(id: string) => linkItem(id, item, updateItem)}
|
||||
unlinkItem={(id: string) => unlinkItem(id, item, updateItem)}
|
||||
setUrlParams={setUrlParams}
|
||||
></TabsForm>
|
||||
)}
|
||||
|
||||
<div className='tw:mb-4 tw:mt-6 tw:flex-none'>
|
||||
<button
|
||||
className={`${loading ? ' tw:loading tw:btn tw:float-right' : 'tw:btn tw:float-right'}`}
|
||||
className={classNames(
|
||||
'tw:btn',
|
||||
'tw:float-right',
|
||||
{ 'tw:loading': loading },
|
||||
{ 'tw:cursor-not-allowed tw:opacity-50': loading || isUpdatingGallery },
|
||||
)}
|
||||
type='submit'
|
||||
style={{
|
||||
// We could refactor this, it is used several times at different locations
|
||||
|
||||
@ -13,7 +13,7 @@ import DialogModal from '#components/Templates/DialogModal'
|
||||
import type { Crop } from 'react-image-crop'
|
||||
|
||||
interface AvatarWidgetProps {
|
||||
avatar: string
|
||||
avatar?: string
|
||||
setAvatar: React.Dispatch<React.SetStateAction<any>>
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
import { TextInput } from '#components/Input'
|
||||
|
||||
import type { FormState } from '#types/FormState'
|
||||
@ -9,7 +7,7 @@ export const ContactInfoForm = ({
|
||||
setState,
|
||||
}: {
|
||||
state: FormState
|
||||
setState: React.Dispatch<React.SetStateAction<any>>
|
||||
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||
}) => {
|
||||
return (
|
||||
<div className='tw:mt-2 tw:space-y-2'>
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
import { TextInput } from '#components/Input'
|
||||
|
||||
import type { FormState } from '#types/FormState'
|
||||
@ -9,7 +7,7 @@ export const CrowdfundingForm = ({
|
||||
setState,
|
||||
}: {
|
||||
state: FormState
|
||||
setState: React.Dispatch<React.SetStateAction<any>>
|
||||
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||
}) => {
|
||||
return (
|
||||
<div className='tw:mt-4 tw:space-y-4'>
|
||||
|
||||
@ -1,14 +1,20 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import { TextInput } from '#components/Input'
|
||||
|
||||
import { AvatarWidget } from './AvatarWidget'
|
||||
import { ColorPicker } from './ColorPicker'
|
||||
|
||||
export const FormHeader = ({ item, state, setState }) => {
|
||||
import type { FormState } from '#types/FormState'
|
||||
import type { Item } from '#types/Item'
|
||||
|
||||
interface Props {
|
||||
item: Item
|
||||
state: Partial<FormState>
|
||||
setState: React.Dispatch<React.SetStateAction<Partial<FormState>>>
|
||||
}
|
||||
|
||||
export const FormHeader = ({ item, state, setState }: Props) => {
|
||||
return (
|
||||
<div className='tw:flex-none'>
|
||||
<div className='tw:flex'>
|
||||
@ -34,7 +40,7 @@ export const FormHeader = ({ item, state, setState }) => {
|
||||
<div className='tw:grow tw:mr-4 tw:pt-1'>
|
||||
<TextInput
|
||||
placeholder='Name'
|
||||
defaultValue={item?.name ? item.name : ''}
|
||||
defaultValue={item.name ? item.name : ''}
|
||||
updateFormValue={(v) =>
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
@ -47,7 +53,7 @@ export const FormHeader = ({ item, state, setState }) => {
|
||||
<TextInput
|
||||
placeholder='Subtitle'
|
||||
required={false}
|
||||
defaultValue={item?.subname ? item.subname : ''}
|
||||
defaultValue={item.subname ? item.subname : ''}
|
||||
updateFormValue={(v) =>
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
|
||||
116
src/Components/Profile/Subcomponents/GalleryForm.spec.tsx
Normal file
116
src/Components/Profile/Subcomponents/GalleryForm.spec.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { render, fireEvent, screen } from '@testing-library/react'
|
||||
import { describe, it, vi, expect } from 'vitest'
|
||||
|
||||
import { GalleryForm } from './GalleryForm'
|
||||
|
||||
import type { FormState } from '#types/FormState'
|
||||
import type { GalleryItem, Item } from '#types/Item'
|
||||
import type { MarkerIcon } from '#types/MarkerIcon'
|
||||
import type { Tag } from '#types/Tag'
|
||||
|
||||
const testImagePaths = ['./tests/image1.jpg', './tests/image2.jpg', './tests/image3.jpg']
|
||||
|
||||
const testImages = testImagePaths.map((imagePath) =>
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
readFileSync(path.join(__dirname, '../../../../', imagePath)),
|
||||
)
|
||||
|
||||
const setState = vi.fn()
|
||||
|
||||
const baseState = {
|
||||
color: '',
|
||||
id: '',
|
||||
group_type: 'wuerdekompass',
|
||||
status: 'active',
|
||||
name: '',
|
||||
subname: '',
|
||||
text: '',
|
||||
contact: '',
|
||||
telephone: '',
|
||||
next_appointment: '',
|
||||
image: '',
|
||||
marker_icon: {} as MarkerIcon,
|
||||
offers: [] as Tag[],
|
||||
needs: [] as Tag[],
|
||||
relations: [] as Item[],
|
||||
start: '',
|
||||
end: '',
|
||||
openCollectiveSlug: '',
|
||||
gallery: [],
|
||||
uploadingImages: [],
|
||||
}
|
||||
|
||||
describe('GalleryForm', () => {
|
||||
const Wrapper = ({ gallery = [] as GalleryItem[], uploadingImages = [] as File[] } = {}) => {
|
||||
const state: FormState = {
|
||||
...baseState,
|
||||
gallery,
|
||||
uploadingImages,
|
||||
}
|
||||
return render(<GalleryForm state={state} setState={setState} />)
|
||||
}
|
||||
|
||||
describe('without previous images', () => {
|
||||
it('renders', () => {
|
||||
const wrapper = Wrapper()
|
||||
expect(wrapper.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with previous images', () => {
|
||||
const gallery = [
|
||||
{
|
||||
directus_files_id: {
|
||||
id: '1',
|
||||
width: 800,
|
||||
height: 600,
|
||||
},
|
||||
},
|
||||
{
|
||||
directus_files_id: {
|
||||
id: '2',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
it('renders', () => {
|
||||
const wrapper = Wrapper({ gallery })
|
||||
expect(wrapper.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('can open and close delete modal', () => {
|
||||
Wrapper({ gallery })
|
||||
const deleteButton = screen.getAllByTestId('trash')[0]
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
fireEvent.click(deleteButton)
|
||||
const confirmationText = screen.getByText('Do you want to delete this image?')
|
||||
expect(confirmationText).toBeInTheDocument()
|
||||
const confirmButton = screen.getByText('Yes')
|
||||
expect(confirmButton).toBeInTheDocument()
|
||||
fireEvent.click(confirmButton)
|
||||
expect(confirmationText).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with uploading images', () => {
|
||||
it('renders', () => {
|
||||
const wrapper = Wrapper({
|
||||
uploadingImages: testImages.map(
|
||||
(image, index) =>
|
||||
new File([image], `test-image-${index + 1}.jpg`, { type: 'image/jpeg' }),
|
||||
),
|
||||
})
|
||||
wrapper.container.querySelectorAll('img').forEach((img) => {
|
||||
expect(img.src).toMatch(/blob:/) // Ensure the image is a blob URL
|
||||
// Replace random blob URL for consistent snapshots
|
||||
img.src = 'blob-url-placeholder'
|
||||
})
|
||||
expect(wrapper.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
161
src/Components/Profile/Subcomponents/GalleryForm.tsx
Normal file
161
src/Components/Profile/Subcomponents/GalleryForm.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import TrashIcon from '@heroicons/react/24/solid/TrashIcon'
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import { useState } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { BiSolidImage } from 'react-icons/bi'
|
||||
|
||||
import { useAppState } from '#components/AppShell/hooks/useAppState'
|
||||
import DialogModal from '#components/Templates/DialogModal'
|
||||
import { getImageDimensions } from '#utils/getImageDimensions'
|
||||
|
||||
import type { FormState } from '#types/FormState'
|
||||
|
||||
interface Props {
|
||||
state: FormState
|
||||
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||
}
|
||||
|
||||
const compressionOptions = {
|
||||
maxSizeMB: 1,
|
||||
maxWidthOrHeight: 1920,
|
||||
useWebWorker: true,
|
||||
}
|
||||
|
||||
export const GalleryForm = ({ state, setState }: Props) => {
|
||||
const appState = useAppState()
|
||||
|
||||
const [imageSelectedToDelete, setImageSelectedToDelete] = useState<number | null>(null)
|
||||
|
||||
const closeModal = () => setImageSelectedToDelete(null)
|
||||
|
||||
const upload = async (acceptedFiles: File[]) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
uploadingImages: [...prevState.uploadingImages, ...acceptedFiles],
|
||||
}))
|
||||
|
||||
const uploads = acceptedFiles.map(async (file) => {
|
||||
const compressedFile = await imageCompression(file, compressionOptions)
|
||||
const { width, height } = await getImageDimensions(compressedFile)
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
asset: await appState.assetsApi.upload(compressedFile, file.name),
|
||||
name: file.name,
|
||||
}
|
||||
})
|
||||
|
||||
for await (const upload of uploads) {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
uploadingImages: prevState.uploadingImages.filter((f) => f.name !== upload.name),
|
||||
gallery: [
|
||||
...prevState.gallery,
|
||||
{
|
||||
directus_files_id: {
|
||||
id: upload.asset.id,
|
||||
width: upload.width,
|
||||
height: upload.height,
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onDrop: upload,
|
||||
accept: {
|
||||
'image/jpeg': [],
|
||||
},
|
||||
})
|
||||
|
||||
const images = state.gallery
|
||||
.map((image) => ({
|
||||
src: appState.assetsApi.url + `${image.directus_files_id.id}.jpg`,
|
||||
state: 'uploaded',
|
||||
}))
|
||||
.concat(
|
||||
state.uploadingImages.map((file) => ({
|
||||
src: URL.createObjectURL(file),
|
||||
state: 'uploading',
|
||||
})),
|
||||
)
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
gallery: prevState.gallery.filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='tw:grid tw:grid-cols-2 tw:@md:grid-cols-3 tw:@lg:grid-cols-4 tw:gap-4 tw:my-4'>
|
||||
{images.map((image, index) => (
|
||||
<div key={index} className='tw:relative'>
|
||||
<img
|
||||
src={image.src}
|
||||
alt={`Gallery image ${index + 1}`}
|
||||
className={`tw:w-full tw:h-full tw:object-cover tw:rounded-lg ${
|
||||
image.state === 'uploading' ? 'tw:opacity-50' : ''
|
||||
}`}
|
||||
/>
|
||||
{image.state === 'uploading' && (
|
||||
<span className='tw:loading tw:loading-spinner tw:absolute tw:inset-0 tw:m-auto'></span>
|
||||
)}
|
||||
{image.state === 'uploaded' && (
|
||||
<button
|
||||
className='tw:m-2 tw:bg-red-500 tw:text-white tw:p-2 tw:rounded-full tw:absolute tw:top-0 tw:right-0 tw:hover:bg-red-600 tw:cursor-pointer'
|
||||
onClick={() => setImageSelectedToDelete(index)}
|
||||
type='button'
|
||||
>
|
||||
<TrashIcon className='tw:h-5 tw:w-5' data-testid='trash' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className='tw:flex tw:flex-col tw:items-center tw:justify-center tw:text-base-content/50 tw:w-full tw:h-full tw:cursor-pointer tw:card tw:card-body tw:border tw:border-current/50 tw:border-dashed tw:bg-base-200'
|
||||
>
|
||||
<input {...getInputProps()} data-testid='gallery-upload-input' />
|
||||
<div>
|
||||
<BiSolidImage className='tw:h-16 tw:w-16 tw:m-auto tw:mb-2' />
|
||||
<span className='tw:text-center'>Upload Image</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogModal
|
||||
isOpened={imageSelectedToDelete !== null}
|
||||
title='Are you sure?'
|
||||
showCloseButton={false}
|
||||
onClose={closeModal}
|
||||
>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<span>Do you want to delete this image?</span>
|
||||
<div className='tw:grid'>
|
||||
<div className='tw:flex tw:justify-between'>
|
||||
<label
|
||||
className='tw:btn tw:mt-4 tw:btn-error'
|
||||
onClick={() => {
|
||||
if (imageSelectedToDelete !== null) {
|
||||
removeImage(imageSelectedToDelete)
|
||||
}
|
||||
closeModal()
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</label>
|
||||
<label className='tw:btn tw:mt-4' onClick={closeModal}>
|
||||
No
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import ComboBoxInput from '#components/Input/ComboBoxInput'
|
||||
@ -24,7 +22,7 @@ export const GroupSubheaderForm = ({
|
||||
groupTypes,
|
||||
}: {
|
||||
state: FormState
|
||||
setState: React.Dispatch<React.SetStateAction<any>>
|
||||
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||
item: Item
|
||||
groupStates?: string[]
|
||||
groupTypes?: groupType[]
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
import { PopupStartEndInput } from '#components/Map/Subcomponents/ItemPopupComponents'
|
||||
|
||||
import type { FormState } from '#types/FormState'
|
||||
import type { Item } from '#types/Item'
|
||||
|
||||
export const ProfileStartEndForm = ({
|
||||
@ -9,7 +8,7 @@ export const ProfileStartEndForm = ({
|
||||
setState,
|
||||
}: {
|
||||
item: Item
|
||||
setState: React.Dispatch<React.SetStateAction<any>>
|
||||
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||
}) => {
|
||||
return (
|
||||
<PopupStartEndInput
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { RichTextEditor } from '#components/Input/RichTextEditor'
|
||||
@ -20,7 +20,7 @@ export const ProfileTextForm = ({
|
||||
hideInputLabel,
|
||||
}: {
|
||||
state: FormState
|
||||
setState: React.Dispatch<React.SetStateAction<any>>
|
||||
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||
dataField?: string
|
||||
heading: string
|
||||
size: string
|
||||
|
||||
@ -0,0 +1,227 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`GalleryForm > with previous images > renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="tw:grid tw:grid-cols-2 tw:@md:grid-cols-3 tw:@lg:grid-cols-4 tw:gap-4 tw:my-4"
|
||||
>
|
||||
<div
|
||||
class="tw:relative"
|
||||
>
|
||||
<img
|
||||
alt="Gallery image 1"
|
||||
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg "
|
||||
src="undefined1.jpg"
|
||||
/>
|
||||
<button
|
||||
class="tw:m-2 tw:bg-red-500 tw:text-white tw:p-2 tw:rounded-full tw:absolute tw:top-0 tw:right-0 tw:hover:bg-red-600 tw:cursor-pointer"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="tw:h-5 tw:w-5"
|
||||
data-slot="icon"
|
||||
data-testid="trash"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M16.5 4.478v.227a48.816 48.816 0 0 1 3.878.512.75.75 0 1 1-.256 1.478l-.209-.035-1.005 13.07a3 3 0 0 1-2.991 2.77H8.084a3 3 0 0 1-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 0 1-.256-1.478A48.567 48.567 0 0 1 7.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 0 1 3.369 0c1.603.051 2.815 1.387 2.815 2.951Zm-6.136-1.452a51.196 51.196 0 0 1 3.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 0 0-6 0v-.113c0-.794.609-1.428 1.364-1.452Zm-.355 5.945a.75.75 0 1 0-1.5.058l.347 9a.75.75 0 1 0 1.499-.058l-.346-9Zm5.48.058a.75.75 0 1 0-1.498-.058l-.347 9a.75.75 0 0 0 1.5.058l.345-9Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="tw:relative"
|
||||
>
|
||||
<img
|
||||
alt="Gallery image 2"
|
||||
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg "
|
||||
src="undefined2.jpg"
|
||||
/>
|
||||
<button
|
||||
class="tw:m-2 tw:bg-red-500 tw:text-white tw:p-2 tw:rounded-full tw:absolute tw:top-0 tw:right-0 tw:hover:bg-red-600 tw:cursor-pointer"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="tw:h-5 tw:w-5"
|
||||
data-slot="icon"
|
||||
data-testid="trash"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M16.5 4.478v.227a48.816 48.816 0 0 1 3.878.512.75.75 0 1 1-.256 1.478l-.209-.035-1.005 13.07a3 3 0 0 1-2.991 2.77H8.084a3 3 0 0 1-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 0 1-.256-1.478A48.567 48.567 0 0 1 7.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 0 1 3.369 0c1.603.051 2.815 1.387 2.815 2.951Zm-6.136-1.452a51.196 51.196 0 0 1 3.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 0 0-6 0v-.113c0-.794.609-1.428 1.364-1.452Zm-.355 5.945a.75.75 0 1 0-1.5.058l.347 9a.75.75 0 1 0 1.499-.058l-.346-9Zm5.48.058a.75.75 0 1 0-1.498-.058l-.347 9a.75.75 0 0 0 1.5.058l.345-9Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:text-base-content/50 tw:w-full tw:h-full tw:cursor-pointer tw:card tw:card-body tw:border tw:border-current/50 tw:border-dashed tw:bg-base-200"
|
||||
role="presentation"
|
||||
tabindex="0"
|
||||
>
|
||||
<input
|
||||
accept="image/jpeg"
|
||||
data-testid="gallery-upload-input"
|
||||
multiple=""
|
||||
style="border: 0px; clip: rect(0, 0, 0, 0); clip-path: inset(50%); height: 1px; margin: 0px -1px -1px 0px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
|
||||
tabindex="-1"
|
||||
type="file"
|
||||
/>
|
||||
<div>
|
||||
<svg
|
||||
class="tw:h-16 tw:w-16 tw:m-auto tw:mb-2"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
stroke="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.999 4h-16c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-13.5 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm5.5 10h-7l4-5 1.5 2 3-4 5.5 7h-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="tw:text-center"
|
||||
>
|
||||
Upload Image
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`GalleryForm > with uploading images > renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="tw:grid tw:grid-cols-2 tw:@md:grid-cols-3 tw:@lg:grid-cols-4 tw:gap-4 tw:my-4"
|
||||
>
|
||||
<div
|
||||
class="tw:relative"
|
||||
>
|
||||
<img
|
||||
alt="Gallery image 1"
|
||||
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg tw:opacity-50"
|
||||
src="blob-url-placeholder"
|
||||
/>
|
||||
<span
|
||||
class="tw:loading tw:loading-spinner tw:absolute tw:inset-0 tw:m-auto"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="tw:relative"
|
||||
>
|
||||
<img
|
||||
alt="Gallery image 2"
|
||||
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg tw:opacity-50"
|
||||
src="blob-url-placeholder"
|
||||
/>
|
||||
<span
|
||||
class="tw:loading tw:loading-spinner tw:absolute tw:inset-0 tw:m-auto"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="tw:relative"
|
||||
>
|
||||
<img
|
||||
alt="Gallery image 3"
|
||||
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg tw:opacity-50"
|
||||
src="blob-url-placeholder"
|
||||
/>
|
||||
<span
|
||||
class="tw:loading tw:loading-spinner tw:absolute tw:inset-0 tw:m-auto"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:text-base-content/50 tw:w-full tw:h-full tw:cursor-pointer tw:card tw:card-body tw:border tw:border-current/50 tw:border-dashed tw:bg-base-200"
|
||||
role="presentation"
|
||||
tabindex="0"
|
||||
>
|
||||
<input
|
||||
accept="image/jpeg"
|
||||
data-testid="gallery-upload-input"
|
||||
multiple=""
|
||||
style="border: 0px; clip: rect(0, 0, 0, 0); clip-path: inset(50%); height: 1px; margin: 0px -1px -1px 0px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
|
||||
tabindex="-1"
|
||||
type="file"
|
||||
/>
|
||||
<div>
|
||||
<svg
|
||||
class="tw:h-16 tw:w-16 tw:m-auto tw:mb-2"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
stroke="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.999 4h-16c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-13.5 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm5.5 10h-7l4-5 1.5 2 3-4 5.5 7h-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="tw:text-center"
|
||||
>
|
||||
Upload Image
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`GalleryForm > without previous images > renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="tw:grid tw:grid-cols-2 tw:@md:grid-cols-3 tw:@lg:grid-cols-4 tw:gap-4 tw:my-4"
|
||||
>
|
||||
<div
|
||||
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:text-base-content/50 tw:w-full tw:h-full tw:cursor-pointer tw:card tw:card-body tw:border tw:border-current/50 tw:border-dashed tw:bg-base-200"
|
||||
role="presentation"
|
||||
tabindex="0"
|
||||
>
|
||||
<input
|
||||
accept="image/jpeg"
|
||||
data-testid="gallery-upload-input"
|
||||
multiple=""
|
||||
style="border: 0px; clip: rect(0, 0, 0, 0); clip-path: inset(50%); height: 1px; margin: 0px -1px -1px 0px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
|
||||
tabindex="-1"
|
||||
type="file"
|
||||
/>
|
||||
<div>
|
||||
<svg
|
||||
class="tw:h-16 tw:w-16 tw:m-auto tw:mb-2"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
stroke="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.999 4h-16c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-13.5 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm5.5 10h-7l4-5 1.5 2 3-4 5.5 7h-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="tw:text-center"
|
||||
>
|
||||
Upload Image
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -1,8 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
import { ContactInfoForm } from '#components/Profile/Subcomponents/ContactInfoForm'
|
||||
import { CrowdfundingForm } from '#components/Profile/Subcomponents/CrowdfundingForm'
|
||||
import { GalleryForm } from '#components/Profile/Subcomponents/GalleryForm'
|
||||
import { GroupSubheaderForm } from '#components/Profile/Subcomponents/GroupSubheaderForm'
|
||||
import { ProfileStartEndForm } from '#components/Profile/Subcomponents/ProfileStartEndForm'
|
||||
import { ProfileTextForm } from '#components/Profile/Subcomponents/ProfileTextForm'
|
||||
@ -16,6 +16,7 @@ const componentMap = {
|
||||
contactInfos: ContactInfoForm,
|
||||
startEnd: ProfileStartEndForm,
|
||||
crowdfundings: CrowdfundingForm,
|
||||
gallery: GalleryForm,
|
||||
// weitere Komponenten hier
|
||||
}
|
||||
|
||||
@ -25,7 +26,7 @@ export const FlexForm = ({
|
||||
setState,
|
||||
}: {
|
||||
state: FormState
|
||||
setState: React.Dispatch<React.SetStateAction<any>>
|
||||
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||
item: Item
|
||||
}) => {
|
||||
return (
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
import { TextAreaInput } from '#components/Input'
|
||||
import { ContactInfoForm } from '#components/Profile/Subcomponents/ContactInfoForm'
|
||||
import { GroupSubheaderForm } from '#components/Profile/Subcomponents/GroupSubheaderForm'
|
||||
@ -13,7 +11,7 @@ export const OnepagerForm = ({
|
||||
setState,
|
||||
}: {
|
||||
state: FormState
|
||||
setState: React.Dispatch<React.SetStateAction<any>>
|
||||
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||
item: Item
|
||||
}) => {
|
||||
return (
|
||||
|
||||
@ -15,6 +15,7 @@ import { encodeTag } from '#utils/FormatTags'
|
||||
import { hashTagRegex } from '#utils/HashTagRegex'
|
||||
import { randomColor } from '#utils/RandomColor'
|
||||
|
||||
import type { FormState } from '#types/FormState'
|
||||
import type { Item } from '#types/Item'
|
||||
|
||||
// eslint-disable-next-line promise/avoid-new
|
||||
@ -77,8 +78,8 @@ export const submitNewItem = async (
|
||||
setAddItemPopupType('')
|
||||
}
|
||||
|
||||
export const linkItem = async (id: string, item, updateItem) => {
|
||||
const newRelations = item.relations || []
|
||||
export const linkItem = async (id: string, item: Item, updateItem) => {
|
||||
const newRelations = item.relations ?? []
|
||||
newRelations?.push({ items_id: item.id, related_items_id: id })
|
||||
const updatedItem = { id: item.id, relations: newRelations }
|
||||
|
||||
@ -96,7 +97,7 @@ export const linkItem = async (id: string, item, updateItem) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const unlinkItem = async (id: string, item, updateItem) => {
|
||||
export const unlinkItem = async (id: string, item: Item, updateItem) => {
|
||||
const newRelations = item.relations?.filter((r) => r.related_items_id !== id)
|
||||
const updatedItem = { id: item.id, relations: newRelations }
|
||||
|
||||
@ -116,7 +117,7 @@ export const unlinkItem = async (id: string, item, updateItem) => {
|
||||
|
||||
export const handleDelete = async (
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
item,
|
||||
item: Item,
|
||||
setLoading,
|
||||
removeItem,
|
||||
map,
|
||||
@ -144,8 +145,8 @@ export const handleDelete = async (
|
||||
}
|
||||
|
||||
export const onUpdateItem = async (
|
||||
state,
|
||||
item,
|
||||
state: FormState,
|
||||
item: Item,
|
||||
tags,
|
||||
addTag,
|
||||
setLoading,
|
||||
@ -159,19 +160,20 @@ export const onUpdateItem = async (
|
||||
|
||||
const offerUpdates: any[] = []
|
||||
// check for new offers
|
||||
await state.offers?.map((o) => {
|
||||
state.offers?.map((o) => {
|
||||
const existingOffer = item?.offers?.find((t) => t.tags_id === o.id)
|
||||
existingOffer && offerUpdates.push(existingOffer.id)
|
||||
if (!existingOffer && !tags.some((t) => t.id === o.id)) addTag({ ...o, offer_or_need: true })
|
||||
existingOffer && offerUpdates.push(existingOffer.tags_id)
|
||||
if (!existingOffer && !tags.some((t: { id: string }) => t.id === o.id))
|
||||
addTag({ ...o, offer_or_need: true })
|
||||
!existingOffer && offerUpdates.push({ items_id: item?.id, tags_id: o.id })
|
||||
return null
|
||||
})
|
||||
|
||||
const needsUpdates: any[] = []
|
||||
|
||||
await state.needs?.map((n) => {
|
||||
state.needs?.map((n) => {
|
||||
const existingNeed = item?.needs?.find((t) => t.tags_id === n.id)
|
||||
existingNeed && needsUpdates.push(existingNeed.id)
|
||||
existingNeed && needsUpdates.push(existingNeed.tags_id)
|
||||
!existingNeed && needsUpdates.push({ items_id: item?.id, tags_id: n.id })
|
||||
!existingNeed && !tags.some((t) => t.id === n.id) && addTag({ ...n, offer_or_need: true })
|
||||
return null
|
||||
@ -197,6 +199,7 @@ export const onUpdateItem = async (
|
||||
...(state.offers.length > 0 && { offers: offerUpdates }),
|
||||
...(state.needs.length > 0 && { needs: needsUpdates }),
|
||||
...(state.openCollectiveSlug && { openCollectiveSlug: state.openCollectiveSlug }),
|
||||
gallery: state.gallery,
|
||||
}
|
||||
|
||||
const offersState: any[] = []
|
||||
@ -216,7 +219,7 @@ export const onUpdateItem = async (
|
||||
|
||||
setLoading(true)
|
||||
|
||||
await state.text
|
||||
state.text
|
||||
.toLocaleLowerCase()
|
||||
.match(hashTagRegex)
|
||||
?.map((tag) => {
|
||||
@ -234,7 +237,7 @@ export const onUpdateItem = async (
|
||||
await sleep(200)
|
||||
|
||||
if (!item.new) {
|
||||
item?.layer?.api?.updateItem &&
|
||||
await (item?.layer?.api?.updateItem &&
|
||||
toast
|
||||
.promise(item?.layer?.api?.updateItem(changedItem), {
|
||||
pending: 'updating Item ...',
|
||||
@ -251,10 +254,10 @@ export const onUpdateItem = async (
|
||||
setLoading(false)
|
||||
navigate(`/item/${item.id}${params && '?' + params}`)
|
||||
return null
|
||||
})
|
||||
}))
|
||||
} else {
|
||||
item.new = false
|
||||
item.layer?.api?.createItem &&
|
||||
await (item.layer?.api?.createItem &&
|
||||
toast
|
||||
.promise(item.layer?.api?.createItem(changedItem), {
|
||||
pending: 'updating Item ...',
|
||||
@ -280,6 +283,6 @@ export const onUpdateItem = async (
|
||||
setLoading(false)
|
||||
navigate(`/${params && '?' + params}`)
|
||||
return null
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
33
src/Utils/getImageDimensions.spec.ts
Normal file
33
src/Utils/getImageDimensions.spec.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/* Currently this test suite is skipped due to the need for a browser environment. We could set
|
||||
up a headless browser test environment in the future, e.g. https://vitest.dev/guide/browser/ */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { getImageDimensions } from './getImageDimensions'
|
||||
|
||||
const testImagePaths = ['./tests/image1.jpg', './tests/image2.jpg', './tests/image3.jpg']
|
||||
|
||||
const testImages = testImagePaths.map((imagePath) =>
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
readFileSync(path.join(__dirname, '../../', imagePath)),
|
||||
)
|
||||
|
||||
describe('getImageDimensions', () => {
|
||||
it.skip('returns the correct dimensions for a valid image file', async () => {
|
||||
const file = new File([testImages[0]], 'image1.jpg', { type: 'image/jpeg' })
|
||||
const dimensions = await getImageDimensions(file)
|
||||
expect(dimensions).toEqual({ width: 800, height: 600 }) // Adjust expected values based on actual test image dimensions
|
||||
})
|
||||
|
||||
it.skip('throws an error for an invalid file type', async () => {
|
||||
const file = new File(['not an image'], 'invalid.txt', { type: 'text/plain' })
|
||||
await expect(getImageDimensions(file)).rejects.toThrow('Error reading image file')
|
||||
})
|
||||
|
||||
it.skip('throws an error if the image cannot be loaded', async () => {
|
||||
const file = new File([''], 'empty.jpg', { type: 'image/jpeg' })
|
||||
await expect(getImageDimensions(file)).rejects.toThrow('Error loading image')
|
||||
})
|
||||
})
|
||||
30
src/Utils/getImageDimensions.ts
Normal file
30
src/Utils/getImageDimensions.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export const getImageDimensions = (
|
||||
file: File,
|
||||
): Promise<{
|
||||
width: number
|
||||
height: number
|
||||
}> =>
|
||||
// eslint-disable-next-line promise/avoid-new
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const fileReader = new FileReader()
|
||||
|
||||
fileReader.onload = () => {
|
||||
try {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => resolve({ width: img.width, height: img.height })
|
||||
|
||||
img.src = fileReader.result as string // is the data URL because called with readAsDataURL
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
throw new Error('Error loading image')
|
||||
}
|
||||
}
|
||||
|
||||
fileReader.readAsDataURL(file)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
throw new Error('Error reading image file')
|
||||
}
|
||||
})
|
||||
@ -14,5 +14,5 @@ input[type="file"] {
|
||||
transition: .5s ease;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
transform: translate(8px, 8px);
|
||||
transform: translate(16px, 16px);
|
||||
}
|
||||
10
src/assets/image-placeholder.svg
Normal file
10
src/assets/image-placeholder.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg version="1.1" id="_x32_" fill="#000000" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800px" height="800px" viewBox="0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<path class="st0" d="M99.281,399.469h320.094c6.172,0,11.844-3.422,14.719-8.875c2.844-5.469,2.438-12.078-1.063-17.141 l-69.156-100.094c-6.313-9.125-16.781-14.516-27.906-14.297s-21.406,5.969-27.375,15.359l-19.719,30.984l-54.828-79.359 c-6.313-9.172-16.797-14.531-27.922-14.328s-21.406,5.969-27.375,15.359L85.281,373.984c-3.25,5.109-3.469,11.578-0.531,16.875 C87.656,396.172,93.219,399.469,99.281,399.469z"/>
|
||||
<path class="st0" d="M322.672,223.906c23.688,0,42.922-19.219,42.922-42.922c0-23.688-19.234-42.906-42.922-42.906 c-23.703,0-42.922,19.219-42.922,42.906C279.75,204.688,298.969,223.906,322.672,223.906z"/>
|
||||
<path class="st0" d="M0,19.703v472.594h512v-25.313V19.703H0z M461.375,441.672H50.625V70.328h410.75V441.672z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
src/types/FormState.d.ts
vendored
4
src/types/FormState.d.ts
vendored
@ -1,5 +1,5 @@
|
||||
import type { markerIcon } from '#utils/MarkerIconFactory'
|
||||
import type { Item } from './Item'
|
||||
import type { GalleryItem, Item } from './Item'
|
||||
import type { Tag } from './Tag'
|
||||
|
||||
export interface FormState {
|
||||
@ -21,4 +21,6 @@ export interface FormState {
|
||||
start: string
|
||||
end: string
|
||||
openCollectiveSlug: string
|
||||
gallery: GalleryItem[]
|
||||
uploadingImages: File[]
|
||||
}
|
||||
|
||||
2
src/types/Item.d.ts
vendored
2
src/types/Item.d.ts
vendored
@ -9,7 +9,7 @@ type TagIds = { tags_id: string }[]
|
||||
|
||||
interface GalleryItem {
|
||||
directus_files_id: {
|
||||
id: number
|
||||
id: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
BIN
tests/image1.jpg
Normal file
BIN
tests/image1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
BIN
tests/image2.jpg
Normal file
BIN
tests/image2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
BIN
tests/image3.jpg
Normal file
BIN
tests/image3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 MiB |
Loading…
x
Reference in New Issue
Block a user