mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2025-12-13 07:46:10 +00:00
Merge branch 'main' into onboarding
This commit is contained in:
commit
03f81cdd36
958
package-lock.json
generated
958
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "utopia-ui",
|
"name": "utopia-ui",
|
||||||
"version": "3.0.94",
|
"version": "3.0.97",
|
||||||
"description": "Reuseable React Components to build mapping apps for real life communities and networks",
|
"description": "Reuseable React Components to build mapping apps for real life communities and networks",
|
||||||
"repository": "https://github.com/utopia-os/utopia-ui",
|
"repository": "https://github.com/utopia-os/utopia-ui",
|
||||||
"homepage": "https://utopia-os.org/",
|
"homepage": "https://utopia-os.org/",
|
||||||
@ -12,6 +12,11 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.esm.js",
|
"import": "./dist/index.esm.js",
|
||||||
"require": "./dist/index.cjs"
|
"require": "./dist/index.cjs"
|
||||||
|
},
|
||||||
|
"./Profile": {
|
||||||
|
"types": "./dist/Profile.d.ts",
|
||||||
|
"import": "./dist/Profile.esm.js",
|
||||||
|
"require": "./dist/Profile.cjs.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -37,6 +42,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.4.1",
|
"@eslint-community/eslint-plugin-eslint-comments": "^4.4.1",
|
||||||
"@rollup/plugin-alias": "^5.1.1",
|
"@rollup/plugin-alias": "^5.1.1",
|
||||||
|
"@rollup/plugin-commonjs": "^28.0.3",
|
||||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||||
"@rollup/plugin-typescript": "^12.1.2",
|
"@rollup/plugin-typescript": "^12.1.2",
|
||||||
"@tailwindcss/postcss": "^4.0.14",
|
"@tailwindcss/postcss": "^4.0.14",
|
||||||
@ -93,12 +99,26 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.0.17",
|
"@heroicons/react": "^2.0.17",
|
||||||
"@tanstack/react-query": "^5.17.8",
|
"@tanstack/react-query": "^5.17.8",
|
||||||
|
"@tiptap/core": "^2.14.0",
|
||||||
|
"@tiptap/extension-bubble-menu": "^2.14.0",
|
||||||
|
"@tiptap/extension-color": "^2.12.0",
|
||||||
|
"@tiptap/extension-image": "^2.14.0",
|
||||||
|
"@tiptap/extension-link": "^2.14.0",
|
||||||
|
"@tiptap/extension-placeholder": "^2.14.0",
|
||||||
|
"@tiptap/extension-youtube": "^2.12.0",
|
||||||
|
"@tiptap/pm": "^2.12.0",
|
||||||
|
"@tiptap/react": "^2.12.0",
|
||||||
|
"@tiptap/starter-kit": "^2.12.0",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.locatecontrol": "^0.79.0",
|
"leaflet.locatecontrol": "^0.79.0",
|
||||||
"radash": "^12.1.0",
|
"radash": "^12.1.0",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-image-crop": "^10.1.8",
|
"react-image-crop": "^10.1.8",
|
||||||
"react-inlinesvg": "^4.2.0",
|
"react-inlinesvg": "^4.2.0",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
@ -108,6 +128,7 @@
|
|||||||
"react-router-dom": "^6.16.0",
|
"react-router-dom": "^6.16.0",
|
||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^9.1.3",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
|
"tiptap-markdown": "^0.8.10",
|
||||||
"yet-another-react-lightbox": "^3.21.7"
|
"yet-another-react-lightbox": "^3.21.7"
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
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,7 @@ import path from 'path'
|
|||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
import alias from '@rollup/plugin-alias'
|
import alias from '@rollup/plugin-alias'
|
||||||
|
import commonjs from '@rollup/plugin-commonjs'
|
||||||
import resolve from '@rollup/plugin-node-resolve'
|
import resolve from '@rollup/plugin-node-resolve'
|
||||||
import typescript from '@rollup/plugin-typescript'
|
import typescript from '@rollup/plugin-typescript'
|
||||||
import { dts } from 'rollup-plugin-dts'
|
import { dts } from 'rollup-plugin-dts'
|
||||||
@ -17,17 +18,22 @@ const aliasConfig = alias({
|
|||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
input: 'src/index.tsx',
|
input: {
|
||||||
|
index: 'src/index.tsx',
|
||||||
|
Profile: 'src/Components/Profile/index.tsx',
|
||||||
|
},
|
||||||
output: [
|
output: [
|
||||||
{
|
{
|
||||||
file: 'dist/index.esm.js',
|
dir: 'dist/',
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
entryFileNames: '[name].esm.js',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
file: 'dist/index.cjs',
|
dir: 'dist/',
|
||||||
format: 'cjs',
|
format: 'cjs',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
entryFileNames: '[name].cjs.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
@ -35,6 +41,10 @@ export default [
|
|||||||
resolve({
|
resolve({
|
||||||
extensions: ['.ts', '.tsx'],
|
extensions: ['.ts', '.tsx'],
|
||||||
}),
|
}),
|
||||||
|
commonjs({
|
||||||
|
include: /node_modules/,
|
||||||
|
requireReturnsDefault: 'auto',
|
||||||
|
}),
|
||||||
postcss({
|
postcss({
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}),
|
}),
|
||||||
@ -78,8 +88,15 @@ export default [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: 'dist/types/src/index.d.ts',
|
input: {
|
||||||
output: [{ file: 'dist/index.d.ts', format: 'es' }],
|
index: path.resolve(__dirname, 'dist/types/src/index.d.ts'),
|
||||||
|
Profile: path.resolve(__dirname, 'dist/types/src/Components/Profile/index.d.ts'),
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
dir: path.resolve(__dirname, 'dist'),
|
||||||
|
format: 'es',
|
||||||
|
entryFileNames: '[name].d.ts',
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
aliasConfig,
|
aliasConfig,
|
||||||
dts({
|
dts({
|
||||||
@ -88,7 +105,7 @@ export default [
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
external: [/\.css$/], //, /\.d\.ts$/
|
external: [/\.css$/],
|
||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export default function NavBar({ appName }: { appName: string }) {
|
|||||||
<div className='tw:flex-1 tw:mr-2'>
|
<div className='tw:flex-1 tw:mr-2'>
|
||||||
<div
|
<div
|
||||||
className={'tw:flex-1 tw:truncate tw:grid tw:grid-flow-col'}
|
className={'tw:flex-1 tw:truncate tw:grid tw:grid-flow-col'}
|
||||||
style={{ maxWidth: nameWidth + 60 }}
|
style={{ maxWidth: nameWidth + 62 }}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
className='tw:btn tw:btn-ghost tw:px-2 tw:normal-case tw:text-xl tw:flex-1 tw:truncate'
|
className='tw:btn tw:btn-ghost tw:px-2 tw:normal-case tw:text-xl tw:flex-1 tw:truncate'
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export const UserControl = () => {
|
|||||||
user && items.find((i) => i.user_created?.id === user.id && i.layer?.userProfileLayer)
|
user && items.find((i) => i.user_created?.id === user.id && i.layer?.userProfileLayer)
|
||||||
profile
|
profile
|
||||||
? setUserProfile(profile)
|
? setUserProfile(profile)
|
||||||
: setUserProfile({ id: crypto.randomUUID(), name: user?.first_name ?? '', text: '' })
|
: setUserProfile({ id: 'new', name: user?.first_name ?? '', text: '' })
|
||||||
}, [user, items])
|
}, [user, items])
|
||||||
|
|
||||||
const onLogout = async () => {
|
const onLogout = async () => {
|
||||||
|
|||||||
@ -13,7 +13,7 @@ const ComboBoxInput = ({ id, options, value, onValueChange }: ComboBoxProps) =>
|
|||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
id={id}
|
id={id}
|
||||||
className='tw:form-select tw:block tw:w-full tw:py-2 tw:px-4 tw:border tw:border-gray-300 rounded-md tw:shadow-sm tw:text-sm tw:focus:outline-hidden tw:focus:ring-indigo-500 tw:focus:border-indigo-500 tw:sm:text-sm'
|
className='tw:select tw:w-full'
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
value={value} // ← hier controlled statt defaultValue
|
value={value} // ← hier controlled statt defaultValue
|
||||||
>
|
>
|
||||||
|
|||||||
110
src/Components/Input/RichTextEditor.tsx
Normal file
110
src/Components/Input/RichTextEditor.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
|
import { Color } from '@tiptap/extension-color'
|
||||||
|
import { Image } from '@tiptap/extension-image'
|
||||||
|
import { Link } from '@tiptap/extension-link'
|
||||||
|
import { Placeholder } from '@tiptap/extension-placeholder'
|
||||||
|
import { EditorContent, useEditor } from '@tiptap/react'
|
||||||
|
import { StarterKit } from '@tiptap/starter-kit'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Markdown } from 'tiptap-markdown'
|
||||||
|
|
||||||
|
import { TextEditorMenu } from './TextEditorMenu'
|
||||||
|
|
||||||
|
interface RichTextEditorProps {
|
||||||
|
labelTitle?: string
|
||||||
|
labelStyle?: string
|
||||||
|
containerStyle?: string
|
||||||
|
defaultValue: string
|
||||||
|
placeholder?: string
|
||||||
|
showMenu?: boolean
|
||||||
|
updateFormValue?: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @category Input
|
||||||
|
*/
|
||||||
|
export function RichTextEditor({
|
||||||
|
labelTitle,
|
||||||
|
labelStyle,
|
||||||
|
containerStyle,
|
||||||
|
defaultValue,
|
||||||
|
placeholder,
|
||||||
|
showMenu = true,
|
||||||
|
updateFormValue,
|
||||||
|
}: RichTextEditorProps) {
|
||||||
|
const handleChange = () => {
|
||||||
|
let newValue: string | undefined = editor?.storage.markdown.getMarkdown()
|
||||||
|
|
||||||
|
const regex = /!\[.*?\]\(.*?\)/g
|
||||||
|
newValue = newValue?.replace(regex, (match: string) => match + '\n\n')
|
||||||
|
if (updateFormValue && newValue) {
|
||||||
|
updateFormValue(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
Color.configure({ types: ['textStyle', 'listItem'] }),
|
||||||
|
StarterKit.configure({
|
||||||
|
bulletList: {
|
||||||
|
keepMarks: true,
|
||||||
|
keepAttributes: false,
|
||||||
|
},
|
||||||
|
orderedList: {
|
||||||
|
keepMarks: true,
|
||||||
|
keepAttributes: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Markdown.configure({
|
||||||
|
linkify: true,
|
||||||
|
transformCopiedText: true,
|
||||||
|
transformPastedText: true,
|
||||||
|
}),
|
||||||
|
Image,
|
||||||
|
Link,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder,
|
||||||
|
emptyEditorClass: 'is-editor-empty',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: defaultValue,
|
||||||
|
onUpdate: handleChange,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: `tw:h-full markdown tw:max-h-full tw:p-2 tw:overflow-y-auto`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor?.storage.markdown.getMarkdown() === '' || !editor?.storage.markdown.getMarkdown()) {
|
||||||
|
editor?.commands.setContent(defaultValue)
|
||||||
|
}
|
||||||
|
}, [defaultValue, editor])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`tw:form-control tw:w-full tw:flex tw:flex-col tw:min-h-0 ${containerStyle ?? ''}`}
|
||||||
|
>
|
||||||
|
{labelTitle ? (
|
||||||
|
<label className='tw:label tw:pb-1'>
|
||||||
|
<span className={`tw:label-text tw:text-base-content ${labelStyle ?? ''}`}>
|
||||||
|
{labelTitle}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={`editor-wrapper tw:border-base-content/20 tw:rounded-box tw:border tw:flex tw:flex-col tw:flex-1 tw:min-h-0`}
|
||||||
|
>
|
||||||
|
{editor ? (
|
||||||
|
<>
|
||||||
|
{showMenu ? <TextEditorMenu editor={editor} /> : null}
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
160
src/Components/Input/TextEditorMenu.tsx
Normal file
160
src/Components/Input/TextEditorMenu.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import BoldIcon from '@heroicons/react/24/solid/BoldIcon'
|
||||||
|
import H1Icon from '@heroicons/react/24/solid/H1Icon'
|
||||||
|
import H2Icon from '@heroicons/react/24/solid/H2Icon'
|
||||||
|
import H3Icon from '@heroicons/react/24/solid/H3Icon'
|
||||||
|
import ItalicIcon from '@heroicons/react/24/solid/ItalicIcon'
|
||||||
|
import ListBulletIcon from '@heroicons/react/24/solid/ListBulletIcon'
|
||||||
|
import NumberedListIcon from '@heroicons/react/24/solid/NumberedListIcon'
|
||||||
|
import { useEditorState } from '@tiptap/react'
|
||||||
|
import { MdUndo, MdRedo, MdHorizontalRule } from 'react-icons/md'
|
||||||
|
|
||||||
|
import type { Editor } from '@tiptap/react'
|
||||||
|
|
||||||
|
export const TextEditorMenu = ({ editor }: { editor: Editor }) => {
|
||||||
|
const editorState = useEditorState({
|
||||||
|
editor,
|
||||||
|
selector: (ctx) => {
|
||||||
|
return {
|
||||||
|
isBold: ctx.editor.isActive('bold'),
|
||||||
|
canBold: ctx.editor.can().chain().focus().toggleBold().run(),
|
||||||
|
isItalic: ctx.editor.isActive('italic'),
|
||||||
|
canItalic: ctx.editor.can().chain().focus().toggleItalic().run(),
|
||||||
|
isStrike: ctx.editor.isActive('strike'),
|
||||||
|
canStrike: ctx.editor.can().chain().focus().toggleStrike().run(),
|
||||||
|
isCode: ctx.editor.isActive('code'),
|
||||||
|
canCode: ctx.editor.can().chain().focus().toggleCode().run(),
|
||||||
|
canClearMarks: ctx.editor.can().chain().focus().unsetAllMarks().run(),
|
||||||
|
isParagraph: ctx.editor.isActive('paragraph'),
|
||||||
|
isHeading1: ctx.editor.isActive('heading', { level: 1 }),
|
||||||
|
isHeading2: ctx.editor.isActive('heading', { level: 2 }),
|
||||||
|
isHeading3: ctx.editor.isActive('heading', { level: 3 }),
|
||||||
|
isHeading4: ctx.editor.isActive('heading', { level: 4 }),
|
||||||
|
isHeading5: ctx.editor.isActive('heading', { level: 5 }),
|
||||||
|
isHeading6: ctx.editor.isActive('heading', { level: 6 }),
|
||||||
|
isHeading: ctx.editor.isActive('heading'),
|
||||||
|
isBulletList: ctx.editor.isActive('bulletList'),
|
||||||
|
isOrderedList: ctx.editor.isActive('orderedList'),
|
||||||
|
isCodeBlock: ctx.editor.isActive('codeBlock'),
|
||||||
|
isBlockquote: ctx.editor.isActive('blockquote'),
|
||||||
|
canUndo: ctx.editor.can().chain().focus().undo().run(),
|
||||||
|
canRedo: ctx.editor.can().chain().focus().redo().run(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ul
|
||||||
|
className={
|
||||||
|
'tw:menu tw:overflow-x-hidden tw:@sm:overflow-visible tw:menu-horizontal tw:flex-nowrap tw:flex-none tw:bg-base-200 tw:rounded-box tw:w-full tw:rounded-b-none'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className={`tw:@sm:tooltip tw:px-1.5 tw:mx-0.5 ${editorState.isHeading1 ? 'tw:bg-base-content/10' : ''}`}
|
||||||
|
data-tip='Heading 1'
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
>
|
||||||
|
<H1Icon className='tw:w-5 tw:h-5' />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className={`tw:@sm:tooltip tw:px-1.5 tw:mx-0.5 ${editorState.isHeading2 ? 'tw:bg-base-content/10' : ''}`}
|
||||||
|
data-tip='Heading 2'
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
>
|
||||||
|
<H2Icon className='tw:w-5 tw:h-5' />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className={`tw:@sm:tooltip tw:px-1.5 tw:mx-0.5 ${editorState.isHeading3 ? 'tw:bg-base-content/10' : ''}`}
|
||||||
|
data-tip='Heading 3'
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
|
>
|
||||||
|
<H3Icon className='tw:w-5 tw:h-5' />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div className='tw:w-[1px] tw:p-0 tw:mx-1 tw:bg-base-content/10 tw:my-1' />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className={`tw:@sm:tooltip tw:px-1.5 tw:mx-0.5 ${editorState.isBold ? 'tw:bg-base-content/10' : ''}`}
|
||||||
|
data-tip='Bold'
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
>
|
||||||
|
<BoldIcon className='tw:w-5 tw:h-5' />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className={`tw:@sm:tooltip tw:px-1.5 tw:mx-0.5 ${editorState.isItalic ? 'tw:bg-base-content/10' : ''}`}
|
||||||
|
data-tip='Italic'
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
>
|
||||||
|
<ItalicIcon className='tw:w-5 tw:h-5' />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div className='tw:w-[1px] tw:p-0 tw:mx-1 tw:bg-base-content/10 tw:my-1' />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className={`tw:@sm:tooltip tw:px-1.5 tw:mx-0.5 ${editorState.isBulletList ? 'tw:bg-base-content/10' : ''}`}
|
||||||
|
data-tip='List'
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
>
|
||||||
|
<ListBulletIcon className='tw:w-5 tw:h-5' />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className={`tw:@sm:tooltip tw:px-1.5 tw:mx-0.5 ${editorState.isOrderedList ? 'tw:bg-base-content/10' : ''}`}
|
||||||
|
data-tip='List'
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
>
|
||||||
|
<NumberedListIcon className='tw:w-5 tw:h-5' />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div className='tw:w-[1px] tw:p-0 tw:mx-1 tw:bg-base-content/10 tw:my-1' />
|
||||||
|
</li>
|
||||||
|
{/* <li>
|
||||||
|
<div className='tw:@sm:tooltip tw:px-1.5 tw:mx-0.5' data-tip='Image' onClick={addImage}>
|
||||||
|
<PhotoIcon className='tw:w-5 tw:h-5' />
|
||||||
|
</div>
|
||||||
|
</li> */}
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className='tw:@sm:tooltip tw:px-1'
|
||||||
|
data-tip='Horizontal Line'
|
||||||
|
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||||
|
>
|
||||||
|
<MdHorizontalRule className='tw:w-5 tw:h-5' />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<div className='tw:flex-grow'></div>
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className={`tw:@sm:tooltip tw:px-1.5 tw:mx-0.5 tw:hidden tw:@sm:block ${editorState.canUndo ? '' : 'tw:opacity-50'}`}
|
||||||
|
data-tip='Undo'
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
>
|
||||||
|
<MdUndo className='tw:w-5 tw:h-5' />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className={`tw:@sm:tooltip tw:px-1.5 tw:mx-0.5 tw:hidden tw:@sm:block ${editorState.canRedo ? '' : 'tw:opacity-50'}`}
|
||||||
|
data-tip='Redo'
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
>
|
||||||
|
<MdRedo className='tw:w-5 tw:h-5' />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
exports[`<ComboBoxInput /> > renders properly 1`] = `
|
exports[`<ComboBoxInput /> > renders properly 1`] = `
|
||||||
<select
|
<select
|
||||||
class="tw:form-select tw:block tw:w-full tw:py-2 tw:px-4 tw:border tw:border-gray-300 rounded-md tw:shadow-sm tw:text-sm tw:focus:outline-hidden tw:focus:ring-indigo-500 tw:focus:border-indigo-500 tw:sm:text-sm"
|
class="tw:select tw:w-full"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
value="Option 1"
|
value="Option 1"
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { Item } from '#types/Item'
|
|||||||
export interface StartEndInputProps {
|
export interface StartEndInputProps {
|
||||||
item?: Item
|
item?: Item
|
||||||
showLabels?: boolean
|
showLabels?: boolean
|
||||||
|
labelStyle?: string
|
||||||
updateStartValue?: (value: string) => void
|
updateStartValue?: (value: string) => void
|
||||||
updateEndValue?: (value: string) => void
|
updateEndValue?: (value: string) => void
|
||||||
}
|
}
|
||||||
@ -16,6 +17,7 @@ export interface StartEndInputProps {
|
|||||||
export const PopupStartEndInput = ({
|
export const PopupStartEndInput = ({
|
||||||
item,
|
item,
|
||||||
showLabels = true,
|
showLabels = true,
|
||||||
|
labelStyle,
|
||||||
updateStartValue,
|
updateStartValue,
|
||||||
updateEndValue,
|
updateEndValue,
|
||||||
}: StartEndInputProps) => {
|
}: StartEndInputProps) => {
|
||||||
@ -26,7 +28,8 @@ export const PopupStartEndInput = ({
|
|||||||
placeholder='start'
|
placeholder='start'
|
||||||
dataField='start'
|
dataField='start'
|
||||||
inputStyle='tw:text-sm tw:px-2'
|
inputStyle='tw:text-sm tw:px-2'
|
||||||
labelTitle={showLabels ? 'start' : ''}
|
labelTitle={showLabels ? 'Start' : ''}
|
||||||
|
labelStyle={labelStyle}
|
||||||
defaultValue={item && item.start ? item.start.substring(0, 10) : ''}
|
defaultValue={item && item.start ? item.start.substring(0, 10) : ''}
|
||||||
autocomplete='one-time-code'
|
autocomplete='one-time-code'
|
||||||
updateFormValue={updateStartValue}
|
updateFormValue={updateStartValue}
|
||||||
@ -36,7 +39,8 @@ export const PopupStartEndInput = ({
|
|||||||
placeholder='end'
|
placeholder='end'
|
||||||
dataField='end'
|
dataField='end'
|
||||||
inputStyle='tw:text-sm tw:px-2'
|
inputStyle='tw:text-sm tw:px-2'
|
||||||
labelTitle={showLabels ? 'end' : ''}
|
labelTitle={showLabels ? 'End' : ''}
|
||||||
|
labelStyle={labelStyle}
|
||||||
defaultValue={item && item.end ? item.end.substring(0, 10) : ''}
|
defaultValue={item && item.end ? item.end.substring(0, 10) : ''}
|
||||||
autocomplete='one-time-code'
|
autocomplete='one-time-code'
|
||||||
updateFormValue={updateEndValue}
|
updateFormValue={updateEndValue}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export const PopupTextInput = ({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
inputStyle={style}
|
inputStyle={style}
|
||||||
type='text'
|
type='text'
|
||||||
containerStyle={'tw:mt-4'}
|
containerStyle={'tw:mt-4 tw:mb-2'}
|
||||||
></TextInput>
|
></TextInput>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
import { memo } from 'react'
|
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
import remarkBreaks from 'remark-breaks'
|
import remarkBreaks from 'remark-breaks'
|
||||||
|
|
||||||
@ -26,14 +25,12 @@ export const TextView = ({
|
|||||||
text,
|
text,
|
||||||
truncate = false,
|
truncate = false,
|
||||||
rawText,
|
rawText,
|
||||||
itemTextField,
|
|
||||||
}: {
|
}: {
|
||||||
item?: Item
|
item?: Item
|
||||||
itemId?: string
|
itemId?: string
|
||||||
text?: string
|
text?: string
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
rawText?: string
|
rawText?: string
|
||||||
itemTextField?: string
|
|
||||||
}) => {
|
}) => {
|
||||||
if (item) {
|
if (item) {
|
||||||
text = item.text
|
text = item.text
|
||||||
@ -41,8 +38,6 @@ export const TextView = ({
|
|||||||
}
|
}
|
||||||
const tags = useTags()
|
const tags = useTags()
|
||||||
const addFilterTag = useAddFilterTag()
|
const addFilterTag = useAddFilterTag()
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const itemTextFieldDummy = itemTextField
|
|
||||||
|
|
||||||
let innerText = ''
|
let innerText = ''
|
||||||
let replacedText = ''
|
let replacedText = ''
|
||||||
@ -58,22 +53,6 @@ export const TextView = ({
|
|||||||
|
|
||||||
if (innerText) replacedText = fixUrls(innerText)
|
if (innerText) replacedText = fixUrls(innerText)
|
||||||
|
|
||||||
if (replacedText) {
|
|
||||||
replacedText = replacedText.replace(/(?<!\]?\()https?:\/\/[^\s)]+(?!\))/g, (url) => {
|
|
||||||
let shortUrl = url
|
|
||||||
|
|
||||||
if (url.match('^https://')) {
|
|
||||||
shortUrl = url.split('https://')[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.match('^http://')) {
|
|
||||||
shortUrl = url.split('http://')[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
return `[${shortUrl}](${url})`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replacedText) {
|
if (replacedText) {
|
||||||
replacedText = replacedText.replace(mailRegex, (url) => {
|
replacedText = replacedText.replace(mailRegex, (url) => {
|
||||||
return `[${url}](mailto:${url})`
|
return `[${url}](mailto:${url})`
|
||||||
@ -86,53 +65,15 @@ export const TextView = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomH1 = ({ children }) => <h1 className='tw:text-xl tw:font-bold'>{children}</h1>
|
const HashTag = ({ children, tag, itemId }: { children: string; tag: Tag; itemId?: string }) => {
|
||||||
|
|
||||||
const CustomH2 = ({ children }) => <h2 className='tw:text-lg tw:font-bold'>{children}</h2>
|
|
||||||
|
|
||||||
const CustomH3 = ({ children }) => <h3 className='tw:text-base tw:font-bold'>{children}</h3>
|
|
||||||
|
|
||||||
const CustomH4 = ({ children }) => <h4 className='tw:text-base tw:font-bold'>{children}</h4>
|
|
||||||
|
|
||||||
const CustomH5 = ({ children }) => <h5 className='tw:text-sm tw:font-bold'>{children}</h5>
|
|
||||||
|
|
||||||
const CustomH6 = ({ children }) => <h6 className='tw:text-sm tw:font-bold'>{children}</h6>
|
|
||||||
|
|
||||||
const CustomParagraph = ({ children }) => <p className='tw:my-2!'>{children}</p>
|
|
||||||
|
|
||||||
const CustomUnorderdList = ({ children }) => (
|
|
||||||
<ul className='tw:list-disc tw:list-inside'>{children}</ul>
|
|
||||||
)
|
|
||||||
|
|
||||||
const CustomOrderdList = ({ children }) => (
|
|
||||||
<ol className='tw:list-decimal tw:list-inside'>{children}</ol>
|
|
||||||
)
|
|
||||||
|
|
||||||
const CustomHorizontalRow = ({ children }) => <hr className='tw:border-current'>{children}</hr>
|
|
||||||
// eslint-disable-next-line react/prop-types
|
|
||||||
const CustomImage = ({ alt, src, title }) => (
|
|
||||||
<img className='tw:max-w-full tw:rounded tw:shadow' src={src} alt={alt} title={title} />
|
|
||||||
)
|
|
||||||
|
|
||||||
const CustomExternalLink = ({ href, children }) => (
|
|
||||||
<a className='tw:font-bold tw:underline' href={href} target='_blank' rel='noreferrer'>
|
|
||||||
{' '}
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
|
|
||||||
const CustomHashTagLink = ({
|
|
||||||
children,
|
|
||||||
tag,
|
|
||||||
itemId,
|
|
||||||
}: {
|
|
||||||
children: string
|
|
||||||
tag: Tag
|
|
||||||
itemId?: string
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
style={{ color: tag ? tag.color : '#faa', fontWeight: 'bold', cursor: 'pointer' }}
|
className='hashtag'
|
||||||
|
style={
|
||||||
|
tag && {
|
||||||
|
color: tag.color,
|
||||||
|
}
|
||||||
|
}
|
||||||
key={tag ? tag.name + itemId : itemId}
|
key={tag ? tag.name + itemId : itemId}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@ -144,62 +85,48 @@ export const TextView = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react/display-name
|
const Link = ({ href, children }: { href: string; children: string }) => {
|
||||||
const MemoizedVideoEmbed = memo(({ url }: { url: string }) => (
|
// Youtube
|
||||||
<iframe
|
if (href.startsWith('https://www.youtube.com/watch?v=')) {
|
||||||
className='tw:w-full'
|
|
||||||
src={url}
|
|
||||||
allow='fullscreen; picture-in-picture'
|
|
||||||
allowFullScreen
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Markdown
|
|
||||||
className={'tw:text-map tw:leading-map tw:text-sm'}
|
|
||||||
remarkPlugins={[remarkBreaks]}
|
|
||||||
components={{
|
|
||||||
p: CustomParagraph,
|
|
||||||
a: ({ href, children }: { href: string; children: string }) => {
|
|
||||||
const isYouTubeVideo = href?.startsWith('https://www.youtube.com/watch?v=')
|
|
||||||
|
|
||||||
const isRumbleVideo = href?.startsWith('https://rumble.com/embed/')
|
|
||||||
|
|
||||||
if (isYouTubeVideo) {
|
|
||||||
const videoId = href?.split('v=')[1].split('&')[0]
|
const videoId = href?.split('v=')[1].split('&')[0]
|
||||||
const youtubeEmbedUrl = `https://www.youtube-nocookie.com/embed/${videoId}`
|
const youtubeEmbedUrl = `https://www.youtube-nocookie.com/embed/${videoId}`
|
||||||
|
|
||||||
return <MemoizedVideoEmbed url={youtubeEmbedUrl}></MemoizedVideoEmbed>
|
return (
|
||||||
}
|
<iframe src={youtubeEmbedUrl} allow='fullscreen; picture-in-picture' allowFullScreen />
|
||||||
if (isRumbleVideo) {
|
)
|
||||||
return <MemoizedVideoEmbed url={href}></MemoizedVideoEmbed>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (href?.startsWith('#')) {
|
// Rumble
|
||||||
const tag = tags.find(
|
if (href.startsWith('https://rumble.com/embed/')) {
|
||||||
(t) => t.name.toLowerCase() === decodeURI(href).slice(1).toLowerCase(),
|
return <iframe src={href} allow='fullscreen; picture-in-picture' allowFullScreen />
|
||||||
)
|
}
|
||||||
|
|
||||||
|
// HashTag
|
||||||
|
if (href.startsWith('#')) {
|
||||||
|
const tag = tags.find((t) => t.name.toLowerCase() === decodeURI(href).slice(1).toLowerCase())
|
||||||
if (tag)
|
if (tag)
|
||||||
return (
|
return (
|
||||||
<CustomHashTagLink tag={tag} itemId={itemId}>
|
<HashTag tag={tag} itemId={itemId}>
|
||||||
{children}
|
{children}
|
||||||
</CustomHashTagLink>
|
</HashTag>
|
||||||
)
|
)
|
||||||
else return children
|
else return children
|
||||||
} else {
|
|
||||||
return <CustomExternalLink href={href}>{children}</CustomExternalLink>
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
ul: CustomUnorderdList,
|
// Default: Link
|
||||||
ol: CustomOrderdList,
|
return (
|
||||||
img: CustomImage,
|
<a href={href} target='_blank' rel='noreferrer'>
|
||||||
hr: CustomHorizontalRow,
|
{children}
|
||||||
h1: CustomH1,
|
</a>
|
||||||
h2: CustomH2,
|
)
|
||||||
h3: CustomH3,
|
}
|
||||||
h4: CustomH4,
|
|
||||||
h5: CustomH5,
|
return (
|
||||||
h6: CustomH6,
|
<Markdown
|
||||||
|
className={'markdown tw:text-map tw:leading-map tw:text-sm'}
|
||||||
|
remarkPlugins={[remarkBreaks]}
|
||||||
|
components={{
|
||||||
|
a: Link,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{replacedText}
|
{replacedText}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { describe, it, expect, vi } from 'vitest'
|
|||||||
|
|
||||||
import { linkItem } from './itemFunctions'
|
import { linkItem } from './itemFunctions'
|
||||||
|
|
||||||
|
import type { Item } from '#types/Item'
|
||||||
|
|
||||||
const toastErrorMock: (t: string) => void = vi.fn()
|
const toastErrorMock: (t: string) => void = vi.fn()
|
||||||
const toastSuccessMock: (t: string) => void = vi.fn()
|
const toastSuccessMock: (t: string) => void = vi.fn()
|
||||||
|
|
||||||
@ -14,8 +16,45 @@ vi.mock('react-toastify', () => ({
|
|||||||
|
|
||||||
describe('linkItem', () => {
|
describe('linkItem', () => {
|
||||||
const id = 'some-id'
|
const id = 'some-id'
|
||||||
let updateApi: () => void = vi.fn()
|
let updateApi: (item: Partial<Item>) => Promise<Item> = vi.fn()
|
||||||
const item = { layer: { api: { updateItem: () => updateApi() } } }
|
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()
|
const updateItem = vi.fn()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
import classNames from 'classnames'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { toast } from 'react-toastify'
|
|
||||||
|
|
||||||
import { useAuth } from '#components/Auth/useAuth'
|
import { useAuth } from '#components/Auth/useAuth'
|
||||||
import { useItems, useUpdateItem, useAddItem } from '#components/Map/hooks/useItems'
|
import { useItems, useUpdateItem, useAddItem } from '#components/Map/hooks/useItems'
|
||||||
@ -47,11 +46,13 @@ export function ProfileForm() {
|
|||||||
start: '',
|
start: '',
|
||||||
end: '',
|
end: '',
|
||||||
openCollectiveSlug: '',
|
openCollectiveSlug: '',
|
||||||
|
gallery: [],
|
||||||
|
uploadingImages: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const [updatePermission, setUpdatePermission] = useState<boolean>(false)
|
const [updatePermission, setUpdatePermission] = useState<boolean>(false)
|
||||||
const [loading, setLoading] = useState<boolean>(false)
|
const [loading, setLoading] = useState<boolean>(false)
|
||||||
const [item, setItem] = useState<Item>({} as Item)
|
const [item, setItem] = useState<Item>()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const updateItem = useUpdateItem()
|
const updateItem = useUpdateItem()
|
||||||
const addItem = useAddItem()
|
const addItem = useAddItem()
|
||||||
@ -72,15 +73,7 @@ export function ProfileForm() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const itemId = location.pathname.split('/')[2]
|
const itemId = location.pathname.split('/')[2]
|
||||||
|
if (itemId === 'new') {
|
||||||
const item = items.find((i) => i.id === itemId)
|
|
||||||
item && setItem(item)
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
if (items.some((i) => i.user_created?.id === user?.id && i.layer?.userProfileLayer)) {
|
|
||||||
navigate('/')
|
|
||||||
toast.error('Item does not exist')
|
|
||||||
} else {
|
|
||||||
const layer = layers.find((l) => l.userProfileLayer)
|
const layer = layers.find((l) => l.userProfileLayer)
|
||||||
setItem({
|
setItem({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@ -89,13 +82,14 @@ export function ProfileForm() {
|
|||||||
layer,
|
layer,
|
||||||
new: true,
|
new: true,
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
const item = items.find((i) => i.id === itemId)
|
||||||
|
if (item) setItem(item)
|
||||||
}
|
}
|
||||||
}
|
}, [items, location.pathname, layers, user?.first_name])
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [items])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!item) return
|
||||||
const newColor =
|
const newColor =
|
||||||
item.color ??
|
item.color ??
|
||||||
(getItemTags(item) && getItemTags(item)[0]?.color
|
(getItemTags(item) && getItemTags(item)[0]?.color
|
||||||
@ -140,6 +134,8 @@ export function ProfileForm() {
|
|||||||
start: item.start ?? '',
|
start: item.start ?? '',
|
||||||
end: item.end ?? '',
|
end: item.end ?? '',
|
||||||
openCollectiveSlug: item.openCollectiveSlug ?? '',
|
openCollectiveSlug: item.openCollectiveSlug ?? '',
|
||||||
|
gallery: item.gallery ?? [],
|
||||||
|
uploadingImages: [],
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [item, tags, items])
|
}, [item, tags, items])
|
||||||
@ -147,6 +143,7 @@ export function ProfileForm() {
|
|||||||
const [template, setTemplate] = useState<string>('')
|
const [template, setTemplate] = useState<string>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!item) return
|
||||||
if (item.layer?.itemType.template) setTemplate(item.layer?.itemType.template)
|
if (item.layer?.itemType.template) setTemplate(item.layer?.itemType.template)
|
||||||
else {
|
else {
|
||||||
const userLayer = layers.find((l) => l.userProfileLayer === true)
|
const userLayer = layers.find((l) => l.userProfileLayer === true)
|
||||||
@ -154,14 +151,17 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
}, [item, layers])
|
}, [item, layers])
|
||||||
|
|
||||||
|
const isUpdatingGallery = state.uploadingImages.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MapOverlayPage
|
<MapOverlayPage
|
||||||
backdrop
|
backdrop
|
||||||
className='tw:mx-4 tw:mt-4 tw:mb-4 tw:overflow-x-hidden tw:w-[calc(100%-32px)] tw:md:w-[calc(50%-32px)] tw:max-w-3xl tw:left-auto! tw:top-0 tw:bottom-0'
|
className='tw:mx-4 tw:mt-4 tw:mb-4 tw:@container tw:overflow-x-hidden tw:w-[calc(100%-32px)] tw:md:w-[calc(50%-32px)] tw:max-w-3xl tw:left-auto! tw:top-0 tw:bottom-0 tw:flex tw:flex-col'
|
||||||
>
|
>
|
||||||
|
{item ? (
|
||||||
<form
|
<form
|
||||||
className='tw:h-full'
|
className='tw:flex tw:flex-col tw:flex-1 tw:min-h-0'
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void onUpdateItem(
|
void onUpdateItem(
|
||||||
@ -178,7 +178,7 @@ export function ProfileForm() {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className='tw:flex tw:flex-col tw:h-full'>
|
<div className='tw:flex tw:flex-col tw:flex-1 pb-4'>
|
||||||
<FormHeader item={item} state={state} setState={setState} />
|
<FormHeader item={item} state={state} setState={setState} />
|
||||||
|
|
||||||
{template === 'onepager' && (
|
{template === 'onepager' && (
|
||||||
@ -198,15 +198,20 @@ export function ProfileForm() {
|
|||||||
state={state}
|
state={state}
|
||||||
setState={setState}
|
setState={setState}
|
||||||
updatePermission={updatePermission}
|
updatePermission={updatePermission}
|
||||||
linkItem={(id) => linkItem(id, item, updateItem)}
|
linkItem={(id: string) => linkItem(id, item, updateItem)}
|
||||||
unlinkItem={(id) => unlinkItem(id, item, updateItem)}
|
unlinkItem={(id: string) => unlinkItem(id, item, updateItem)}
|
||||||
setUrlParams={setUrlParams}
|
setUrlParams={setUrlParams}
|
||||||
></TabsForm>
|
></TabsForm>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='tw:mt-4 tw:flex-none'>
|
<div className='tw:mb-4 tw:mt-6 tw:flex-none'>
|
||||||
<button
|
<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'
|
type='submit'
|
||||||
style={{
|
style={{
|
||||||
// We could refactor this, it is used several times at different locations
|
// We could refactor this, it is used several times at different locations
|
||||||
@ -219,6 +224,11 @@ export function ProfileForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className='tw:h-full tw:flex tw:flex-col tw:items-center tw:justify-center'>
|
||||||
|
<div className='tw:loading tw:loading-spinner'></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</MapOverlayPage>
|
</MapOverlayPage>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -174,7 +174,7 @@ export function ProfileView({ attestationApi }: { attestationApi?: ItemsApi<any>
|
|||||||
{item && (
|
{item && (
|
||||||
<MapOverlayPage
|
<MapOverlayPage
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`tw:p-0! tw:overflow-scroll tw:m-4! tw:md:w-[calc(50%-32px)] tw:w-[calc(100%-32px)] tw:min-w-80 tw:max-w-3xl tw:left-0! tw:sm:left-auto! tw:top-0 tw:bottom-0 tw:transition-opacity tw:duration-500 ${!selectPosition ? 'tw:opacity-100 tw:pointer-events-auto' : 'tw:opacity-0 tw:pointer-events-none'}`}
|
className={`tw:p-0! tw:overflow-scroll tw:m-4! tw:md:w-[calc(50%-32px)] tw:w-[calc(100%-32px)] tw:min-w-80 tw:max-w-3xl tw:left-0! tw:sm:left-auto! tw:top-0 tw:bottom-0 tw:transition-opacity tw:duration-500 ${!selectPosition ? 'tw:opacity-100 tw:pointer-events-auto' : 'tw:opacity-0 tw:pointer-events-none'} tw:max-h-[1000px]`}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<div className={'tw:px-6 tw:pt-6'}>
|
<div className={'tw:px-6 tw:pt-6'}>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import DialogModal from '#components/Templates/DialogModal'
|
|||||||
import type { Crop } from 'react-image-crop'
|
import type { Crop } from 'react-image-crop'
|
||||||
|
|
||||||
interface AvatarWidgetProps {
|
interface AvatarWidgetProps {
|
||||||
avatar: string
|
avatar?: string
|
||||||
setAvatar: React.Dispatch<React.SetStateAction<any>>
|
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 { TextInput } from '#components/Input'
|
||||||
|
|
||||||
import type { FormState } from '#types/FormState'
|
import type { FormState } from '#types/FormState'
|
||||||
@ -9,10 +7,10 @@ export const ContactInfoForm = ({
|
|||||||
setState,
|
setState,
|
||||||
}: {
|
}: {
|
||||||
state: FormState
|
state: FormState
|
||||||
setState: React.Dispatch<React.SetStateAction<any>>
|
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className='tw:mt-4 tw:space-y-4'>
|
<div className='tw:mt-2 tw:space-y-2'>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor='email'
|
htmlFor='email'
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
||||||
import { TextInput } from '#components/Input'
|
import { TextInput } from '#components/Input'
|
||||||
|
|
||||||
import type { FormState } from '#types/FormState'
|
import type { FormState } from '#types/FormState'
|
||||||
@ -9,7 +7,7 @@ export const CrowdfundingForm = ({
|
|||||||
setState,
|
setState,
|
||||||
}: {
|
}: {
|
||||||
state: FormState
|
state: FormState
|
||||||
setState: React.Dispatch<React.SetStateAction<any>>
|
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className='tw:mt-4 tw:space-y-4'>
|
<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-assignment */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
import { TextInput } from '#components/Input'
|
import { TextInput } from '#components/Input'
|
||||||
|
|
||||||
import { AvatarWidget } from './AvatarWidget'
|
import { AvatarWidget } from './AvatarWidget'
|
||||||
import { ColorPicker } from './ColorPicker'
|
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 (
|
return (
|
||||||
<div className='tw:flex-none'>
|
<div className='tw:flex-none'>
|
||||||
<div className='tw:flex'>
|
<div className='tw:flex'>
|
||||||
@ -34,7 +40,7 @@ export const FormHeader = ({ item, state, setState }) => {
|
|||||||
<div className='tw:grow tw:mr-4 tw:pt-1'>
|
<div className='tw:grow tw:mr-4 tw:pt-1'>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder='Name'
|
placeholder='Name'
|
||||||
defaultValue={item?.name ? item.name : ''}
|
defaultValue={item.name ? item.name : ''}
|
||||||
updateFormValue={(v) =>
|
updateFormValue={(v) =>
|
||||||
setState((prevState) => ({
|
setState((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
@ -47,7 +53,7 @@ export const FormHeader = ({ item, state, setState }) => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
placeholder='Subtitle'
|
placeholder='Subtitle'
|
||||||
required={false}
|
required={false}
|
||||||
defaultValue={item?.subname ? item.subname : ''}
|
defaultValue={item.subname ? item.subname : ''}
|
||||||
updateFormValue={(v) =>
|
updateFormValue={(v) =>
|
||||||
setState((prevState) => ({
|
setState((prevState) => ({
|
||||||
...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/prefer-nullish-coalescing */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
import ComboBoxInput from '#components/Input/ComboBoxInput'
|
import ComboBoxInput from '#components/Input/ComboBoxInput'
|
||||||
@ -24,7 +22,7 @@ export const GroupSubheaderForm = ({
|
|||||||
groupTypes,
|
groupTypes,
|
||||||
}: {
|
}: {
|
||||||
state: FormState
|
state: FormState
|
||||||
setState: React.Dispatch<React.SetStateAction<any>>
|
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||||
item: Item
|
item: Item
|
||||||
groupStates?: string[]
|
groupStates?: string[]
|
||||||
groupTypes?: groupType[]
|
groupTypes?: groupType[]
|
||||||
@ -51,7 +49,7 @@ export const GroupSubheaderForm = ({
|
|||||||
}, [state.group_type, groupTypes])
|
}, [state.group_type, groupTypes])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='tw:grid tw:grid-cols-1 tw:md:grid-cols-2 tw:gap-6'>
|
<div className='tw:grid tw:grid-cols-1 tw:@sm:grid-cols-2 tw:gap-2'>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor='status'
|
htmlFor='status'
|
||||||
|
|||||||
@ -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 { PopupStartEndInput } from '#components/Map/Subcomponents/ItemPopupComponents'
|
||||||
|
|
||||||
|
import type { FormState } from '#types/FormState'
|
||||||
import type { Item } from '#types/Item'
|
import type { Item } from '#types/Item'
|
||||||
|
|
||||||
export const ProfileStartEndForm = ({
|
export const ProfileStartEndForm = ({
|
||||||
@ -9,7 +8,7 @@ export const ProfileStartEndForm = ({
|
|||||||
setState,
|
setState,
|
||||||
}: {
|
}: {
|
||||||
item: Item
|
item: Item
|
||||||
setState: React.Dispatch<React.SetStateAction<any>>
|
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<PopupStartEndInput
|
<PopupStartEndInput
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
/* 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-unsafe-assignment */
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { TextAreaInput } from '#components/Input'
|
import { RichTextEditor } from '#components/Input/RichTextEditor'
|
||||||
|
|
||||||
import { MarkdownHint } from './MarkdownHint'
|
import { MarkdownHint } from './MarkdownHint'
|
||||||
|
|
||||||
@ -18,15 +18,13 @@ export const ProfileTextForm = ({
|
|||||||
heading,
|
heading,
|
||||||
size,
|
size,
|
||||||
hideInputLabel,
|
hideInputLabel,
|
||||||
required,
|
|
||||||
}: {
|
}: {
|
||||||
state: FormState
|
state: FormState
|
||||||
setState: React.Dispatch<React.SetStateAction<any>>
|
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||||
dataField?: string
|
dataField?: string
|
||||||
heading: string
|
heading: string
|
||||||
size: string
|
size: string
|
||||||
hideInputLabel: boolean
|
hideInputLabel: boolean
|
||||||
required?: boolean
|
|
||||||
}) => {
|
}) => {
|
||||||
const [field, setField] = useState<string>(dataField || 'text')
|
const [field, setField] = useState<string>(dataField || 'text')
|
||||||
|
|
||||||
@ -37,17 +35,19 @@ export const ProfileTextForm = ({
|
|||||||
}, [dataField])
|
}, [dataField])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='tw:h-full tw:flex tw:flex-col tw:mt-4'>
|
<div
|
||||||
|
className={`tw:max-h-124 tw:md:max-h-full tw:flex tw:flex-col tw:mt-2 ${size === 'full' ? 'tw:flex-1 tw:min-h-42' : 'tw:h-28 tw:flex-none'}`}
|
||||||
|
>
|
||||||
<div className='tw:flex tw:justify-between tw:items-center'>
|
<div className='tw:flex tw:justify-between tw:items-center'>
|
||||||
<label
|
<label
|
||||||
htmlFor='nextAppointment'
|
htmlFor='nextAppointment'
|
||||||
className='tw:block tw:text-sm tw:font-medium tw:text-gray-500 tw:mb-1'
|
className='tw:block tw:text-sm tw:font-medium tw:text-base-content/50 tw:mb-1'
|
||||||
>
|
>
|
||||||
{heading || 'Text'}:
|
{heading || 'Text'}:
|
||||||
</label>
|
</label>
|
||||||
<MarkdownHint />
|
<MarkdownHint />
|
||||||
</div>
|
</div>
|
||||||
<TextAreaInput
|
<RichTextEditor
|
||||||
placeholder={'...'}
|
placeholder={'...'}
|
||||||
// eslint-disable-next-line security/detect-object-injection
|
// eslint-disable-next-line security/detect-object-injection
|
||||||
defaultValue={state[field]}
|
defaultValue={state[field]}
|
||||||
@ -57,10 +57,9 @@ export const ProfileTextForm = ({
|
|||||||
[field]: v,
|
[field]: v,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
showMenu={size === 'full'}
|
||||||
labelStyle={hideInputLabel ? 'tw:hidden' : ''}
|
labelStyle={hideInputLabel ? 'tw:hidden' : ''}
|
||||||
containerStyle={size === 'full' ? 'tw:grow tw:h-full' : ''}
|
containerStyle={size === 'full' ? 'tw:flex-1' : 'tw:h-24 tw:flex-none'}
|
||||||
inputStyle={size === 'full' ? 'tw:h-full' : 'tw:h-24'}
|
|
||||||
required={required}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -33,6 +33,9 @@ export const TagsWidget = ({ placeholder, containerStyle, defaultTags, onUpdate
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onKeyDown = (e) => {
|
const onKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
const { key } = e
|
const { key } = e
|
||||||
const trimmedInput = input.trim()
|
const trimmedInput = input.trim()
|
||||||
|
|
||||||
@ -42,7 +45,6 @@ export const TagsWidget = ({ placeholder, containerStyle, defaultTags, onUpdate
|
|||||||
// eslint-disable-next-line react/prop-types
|
// eslint-disable-next-line react/prop-types
|
||||||
!defaultTags.some((tag) => tag.name.toLocaleLowerCase() === trimmedInput.toLocaleLowerCase())
|
!defaultTags.some((tag) => tag.name.toLocaleLowerCase() === trimmedInput.toLocaleLowerCase())
|
||||||
) {
|
) {
|
||||||
e.preventDefault()
|
|
||||||
const newTag = tags.find((t) => t.name === trimmedInput.toLocaleLowerCase())
|
const newTag = tags.find((t) => t.name === trimmedInput.toLocaleLowerCase())
|
||||||
newTag && onUpdate([...currentTags, newTag])
|
newTag && onUpdate([...currentTags, newTag])
|
||||||
!newTag &&
|
!newTag &&
|
||||||
|
|||||||
@ -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 */
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
|
||||||
import { ContactInfoForm } from '#components/Profile/Subcomponents/ContactInfoForm'
|
import { ContactInfoForm } from '#components/Profile/Subcomponents/ContactInfoForm'
|
||||||
import { CrowdfundingForm } from '#components/Profile/Subcomponents/CrowdfundingForm'
|
import { CrowdfundingForm } from '#components/Profile/Subcomponents/CrowdfundingForm'
|
||||||
|
import { GalleryForm } from '#components/Profile/Subcomponents/GalleryForm'
|
||||||
import { GroupSubheaderForm } from '#components/Profile/Subcomponents/GroupSubheaderForm'
|
import { GroupSubheaderForm } from '#components/Profile/Subcomponents/GroupSubheaderForm'
|
||||||
import { ProfileStartEndForm } from '#components/Profile/Subcomponents/ProfileStartEndForm'
|
import { ProfileStartEndForm } from '#components/Profile/Subcomponents/ProfileStartEndForm'
|
||||||
import { ProfileTextForm } from '#components/Profile/Subcomponents/ProfileTextForm'
|
import { ProfileTextForm } from '#components/Profile/Subcomponents/ProfileTextForm'
|
||||||
@ -16,6 +16,7 @@ const componentMap = {
|
|||||||
contactInfos: ContactInfoForm,
|
contactInfos: ContactInfoForm,
|
||||||
startEnd: ProfileStartEndForm,
|
startEnd: ProfileStartEndForm,
|
||||||
crowdfundings: CrowdfundingForm,
|
crowdfundings: CrowdfundingForm,
|
||||||
|
gallery: GalleryForm,
|
||||||
// weitere Komponenten hier
|
// weitere Komponenten hier
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,11 +26,11 @@ export const FlexForm = ({
|
|||||||
setState,
|
setState,
|
||||||
}: {
|
}: {
|
||||||
state: FormState
|
state: FormState
|
||||||
setState: React.Dispatch<React.SetStateAction<any>>
|
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||||
item: Item
|
item: Item
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className='tw:mt-6 tw:flex tw:flex-col tw:h-full'>
|
<div className='tw:mt-6 tw:flex tw:flex-col tw:flex-1 tw:min-h-0'>
|
||||||
{item.layer?.itemType.profileTemplate.map((templateItem) => {
|
{item.layer?.itemType.profileTemplate.map((templateItem) => {
|
||||||
const TemplateComponent = componentMap[templateItem.collection]
|
const TemplateComponent = componentMap[templateItem.collection]
|
||||||
return TemplateComponent ? (
|
return TemplateComponent ? (
|
||||||
@ -41,7 +42,7 @@ export const FlexForm = ({
|
|||||||
{...templateItem.item}
|
{...templateItem.item}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className='tw:mt-2' key={templateItem.id}>
|
<div className='tw:mt-2 tw:flex-none' key={templateItem.id}>
|
||||||
{templateItem.collection} form not found
|
{templateItem.collection} form not found
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
||||||
import { TextAreaInput } from '#components/Input'
|
import { TextAreaInput } from '#components/Input'
|
||||||
import { ContactInfoForm } from '#components/Profile/Subcomponents/ContactInfoForm'
|
import { ContactInfoForm } from '#components/Profile/Subcomponents/ContactInfoForm'
|
||||||
import { GroupSubheaderForm } from '#components/Profile/Subcomponents/GroupSubheaderForm'
|
import { GroupSubheaderForm } from '#components/Profile/Subcomponents/GroupSubheaderForm'
|
||||||
@ -13,7 +11,7 @@ export const OnepagerForm = ({
|
|||||||
setState,
|
setState,
|
||||||
}: {
|
}: {
|
||||||
state: FormState
|
state: FormState
|
||||||
setState: React.Dispatch<React.SetStateAction<any>>
|
setState: React.Dispatch<React.SetStateAction<FormState>>
|
||||||
item: Item
|
item: Item
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -6,15 +6,15 @@
|
|||||||
/* eslint-disable @typescript-eslint/restrict-plus-operands */
|
/* eslint-disable @typescript-eslint/restrict-plus-operands */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||||
/* eslint-disable react/prop-types */
|
/* eslint-disable react/prop-types */
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { TextAreaInput } from '#components/Input'
|
import { RichTextEditor } from '#components/Input/RichTextEditor'
|
||||||
import { useUpdateItem } from '#components/Map/hooks/useItems'
|
import { useUpdateItem } from '#components/Map/hooks/useItems'
|
||||||
import { PopupStartEndInput, TextView } from '#components/Map/Subcomponents/ItemPopupComponents'
|
import { PopupStartEndInput, TextView } from '#components/Map/Subcomponents/ItemPopupComponents'
|
||||||
import { ActionButton } from '#components/Profile/Subcomponents/ActionsButton'
|
import { ActionButton } from '#components/Profile/Subcomponents/ActionsButton'
|
||||||
import { LinkedItemsHeaderView } from '#components/Profile/Subcomponents/LinkedItemsHeaderView'
|
import { LinkedItemsHeaderView } from '#components/Profile/Subcomponents/LinkedItemsHeaderView'
|
||||||
import { TagsWidget } from '#components/Profile/Subcomponents/TagsWidget'
|
import { TagsWidget } from '#components/Profile/Subcomponents/TagsWidget'
|
||||||
|
import { Tabs } from '#components/Templates/Tabs'
|
||||||
|
|
||||||
export const TabsForm = ({
|
export const TabsForm = ({
|
||||||
item,
|
item,
|
||||||
@ -26,55 +26,25 @@ export const TabsForm = ({
|
|||||||
loading,
|
loading,
|
||||||
setUrlParams,
|
setUrlParams,
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = useState<number>(1)
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const updateItem = useUpdateItem()
|
const updateItem = useUpdateItem()
|
||||||
|
const navigate = useNavigate()
|
||||||
const updateActiveTab = useCallback(
|
|
||||||
(id: number) => {
|
|
||||||
setActiveTab(id)
|
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
|
||||||
|
|
||||||
params.set('tab', `${id}`)
|
|
||||||
const newUrl = location.pathname + '?' + params.toString()
|
|
||||||
window.history.pushState({}, '', newUrl)
|
|
||||||
setUrlParams(params)
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[location.pathname],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
const urlTab = params.get('tab')
|
|
||||||
setActiveTab(urlTab ? Number(urlTab) : 1)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [location.search])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='tw:grow'>
|
<div className='tw:flex tw:flex-col tw:flex-1 tw:min-h-0 tw:mt-4'>
|
||||||
<div role='tablist' className='tw:tabs tw:h-full tw:tabs-lift tw:mt-3'>
|
<Tabs
|
||||||
<input
|
setUrlParams={setUrlParams}
|
||||||
type='radio'
|
items={[
|
||||||
name='my_tabs_2'
|
{
|
||||||
role='tab'
|
title: 'Info',
|
||||||
className={'tw:tab '}
|
component: (
|
||||||
aria-label='Info'
|
|
||||||
checked={activeTab === 1 && true}
|
|
||||||
onChange={() => updateActiveTab(1)}
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
role='tabpanel'
|
className={`tw:flex tw:flex-col tw:flex-1 tw:min-h-0 ${item.layer.itemType.show_start_end_input && 'tw:pt-4'}`}
|
||||||
className='tw:tab-content tw:bg-base-100 tw:border-(--fallback-bc,oklch(var(--bc)/0.2)) tw:rounded-box tw:!h-[calc(100%-48px)] tw:min-h-56 tw:border-none'
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`tw:flex tw:flex-col tw:h-full ${item.layer.itemType.show_start_end_input && 'tw:pt-4'}`}
|
|
||||||
>
|
>
|
||||||
{item.layer.itemType.show_start_end_input && (
|
{item.layer.itemType.show_start_end_input && (
|
||||||
<PopupStartEndInput
|
<PopupStartEndInput
|
||||||
item={item}
|
item={item}
|
||||||
showLabels={false}
|
showLabels={true}
|
||||||
|
labelStyle={'tw:text-base-content/50'}
|
||||||
updateEndValue={(e) =>
|
updateEndValue={(e) =>
|
||||||
setState((prevState) => ({
|
setState((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
@ -90,7 +60,9 @@ export const TabsForm = ({
|
|||||||
></PopupStartEndInput>
|
></PopupStartEndInput>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TextAreaInput
|
<RichTextEditor
|
||||||
|
labelTitle='About'
|
||||||
|
labelStyle={'tw:text-base-content/50'}
|
||||||
placeholder='about ...'
|
placeholder='about ...'
|
||||||
defaultValue={item?.text ? item.text : ''}
|
defaultValue={item?.text ? item.text : ''}
|
||||||
updateFormValue={(v) =>
|
updateFormValue={(v) =>
|
||||||
@ -99,10 +71,11 @@ export const TabsForm = ({
|
|||||||
text: v,
|
text: v,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
containerStyle='tw:grow'
|
containerStyle='tw:pt-2 tw:flex-1 tw:min-h-36 tw:max-h-136'
|
||||||
inputStyle={`tw:h-full ${!item.layer.itemType.show_start_end_input && 'tw:border-t-0 tw:rounded-tl-none'}`}
|
|
||||||
/>
|
/>
|
||||||
<TextAreaInput
|
<RichTextEditor
|
||||||
|
labelTitle='Contact Info'
|
||||||
|
labelStyle={'tw:text-base-content/50'}
|
||||||
placeholder='contact info ...'
|
placeholder='contact info ...'
|
||||||
defaultValue={state.contact || ''}
|
defaultValue={state.contact || ''}
|
||||||
updateFormValue={(c) =>
|
updateFormValue={(c) =>
|
||||||
@ -111,27 +84,15 @@ export const TabsForm = ({
|
|||||||
contact: c,
|
contact: c,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
inputStyle=''
|
containerStyle='tw:pt-2 tw:h-36 tw:flex-none'
|
||||||
containerStyle='tw:pt-4 tw:h-24 tw:flex-none'
|
showMenu={false}
|
||||||
required={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
),
|
||||||
{item.layer?.itemType.offers_and_needs && (
|
},
|
||||||
<>
|
{
|
||||||
<input
|
title: 'Offers & Needs',
|
||||||
type='radio'
|
component: (
|
||||||
name='my_tabs_2'
|
|
||||||
role='tab'
|
|
||||||
className={'tw:tab tw:min-w-[10em] '}
|
|
||||||
aria-label='Offers & Needs'
|
|
||||||
checked={activeTab === 3 && true}
|
|
||||||
onChange={() => updateActiveTab(3)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
role='tabpanel'
|
|
||||||
className='tw:tab-content tw:bg-base-100 tw:border-(--fallback-bc,oklch(var(--bc)/0.2)) tw:rounded-box tw:!h-[calc(100%-48px)] tw:min-h-56 tw:border-none'
|
|
||||||
>
|
|
||||||
<div className='tw:h-full'>
|
<div className='tw:h-full'>
|
||||||
<div className='tw:w-full tw:h-[calc(50%-0.75em)] tw:mb-4'>
|
<div className='tw:w-full tw:h-[calc(50%-0.75em)] tw:mb-4'>
|
||||||
<TagsWidget
|
<TagsWidget
|
||||||
@ -160,24 +121,11 @@ export const TabsForm = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
),
|
||||||
</>
|
},
|
||||||
)}
|
{
|
||||||
{item.layer?.itemType.relations && (
|
title: 'Links',
|
||||||
<>
|
component: (
|
||||||
<input
|
|
||||||
type='radio'
|
|
||||||
name='my_tabs_2'
|
|
||||||
role='tab'
|
|
||||||
className='tw:tab '
|
|
||||||
aria-label='Links'
|
|
||||||
checked={activeTab === 7 && true}
|
|
||||||
onChange={() => updateActiveTab(7)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
role='tabpanel'
|
|
||||||
className='tw:tab-content tw:rounded-box tw:!h-[calc(100%-48px)] tw:overflow-y-auto tw:pt-4 tw:overflow-x-hidden fade'
|
|
||||||
>
|
|
||||||
<div className='tw:h-full'>
|
<div className='tw:h-full'>
|
||||||
<div className='tw:grid tw:grid-cols-1 tw:sm:grid-cols-2 tw:md:grid-cols-1 tw:lg:grid-cols-1 tw:xl:grid-cols-1 tw:2xl:grid-cols-2 tw:mb-4'>
|
<div className='tw:grid tw:grid-cols-1 tw:sm:grid-cols-2 tw:md:grid-cols-1 tw:lg:grid-cols-1 tw:xl:grid-cols-1 tw:2xl:grid-cols-2 tw:mb-4'>
|
||||||
{state.relations &&
|
{state.relations &&
|
||||||
@ -209,10 +157,10 @@ export const TabsForm = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
),
|
||||||
</>
|
},
|
||||||
)}
|
]}
|
||||||
</div>
|
></Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export const TabsView = ({
|
|||||||
unlinkItem: (id: string) => Promise<void>
|
unlinkItem: (id: string) => Promise<void>
|
||||||
}) => {
|
}) => {
|
||||||
const addFilterTag = useAddFilterTag()
|
const addFilterTag = useAddFilterTag()
|
||||||
const [activeTab, setActiveTab] = useState<number>()
|
const [activeTab, setActiveTab] = useState<number>(0)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [addItemPopupType] = useState<string>('')
|
const [addItemPopupType] = useState<string>('')
|
||||||
@ -80,7 +80,7 @@ export const TabsView = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(location.search)
|
const params = new URLSearchParams(location.search)
|
||||||
const urlTab = params.get('tab')
|
const urlTab = params.get('tab')
|
||||||
setActiveTab(urlTab ? Number(urlTab) : 1)
|
setActiveTab(urlTab ? Number(urlTab) : 0)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [location.search])
|
}, [location.search])
|
||||||
|
|
||||||
@ -91,9 +91,9 @@ export const TabsView = ({
|
|||||||
name='my_tabs_2'
|
name='my_tabs_2'
|
||||||
role='tab'
|
role='tab'
|
||||||
className={'tw:tab tw:font-bold tw:ps-2! tw:pe-2! '}
|
className={'tw:tab tw:font-bold tw:ps-2! tw:pe-2! '}
|
||||||
aria-label={`${item.layer?.itemType.icon_as_labels && activeTab !== 1 ? '📝' : '📝\u00A0Info'}`}
|
aria-label={`${item.layer?.itemType.icon_as_labels && activeTab !== 0 ? '📝' : '📝\u00A0Info'}`}
|
||||||
checked={activeTab === 1 && true}
|
checked={activeTab === 0 && true}
|
||||||
onChange={() => updateActiveTab(1)}
|
onChange={() => updateActiveTab(0)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
role='tabpanel'
|
role='tabpanel'
|
||||||
@ -115,9 +115,9 @@ export const TabsView = ({
|
|||||||
name='my_tabs_2'
|
name='my_tabs_2'
|
||||||
role='tab'
|
role='tab'
|
||||||
className={'tw:tab tw:font-bold tw:ps-2! tw:pe-2!'}
|
className={'tw:tab tw:font-bold tw:ps-2! tw:pe-2!'}
|
||||||
aria-label={`${item.layer.itemType.icon_as_labels && activeTab !== 2 ? '❤️' : '❤️\u00A0Trust'}`}
|
aria-label={`${item.layer.itemType.icon_as_labels && activeTab !== 3 ? '❤️' : '❤️\u00A0Trust'}`}
|
||||||
checked={activeTab === 2 && true}
|
checked={activeTab === 3 && true}
|
||||||
onChange={() => updateActiveTab(2)}
|
onChange={() => updateActiveTab(3)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
role='tabpanel'
|
role='tabpanel'
|
||||||
@ -197,10 +197,10 @@ export const TabsView = ({
|
|||||||
type='radio'
|
type='radio'
|
||||||
name='my_tabs_2'
|
name='my_tabs_2'
|
||||||
role='tab'
|
role='tab'
|
||||||
className={`tw:tab tw:font-bold tw:ps-2! tw:pe-2! ${!(item.layer.itemType.icon_as_labels && activeTab !== 3) && 'tw:min-w-[10.4em]'} `}
|
className={`tw:tab tw:font-bold tw:ps-2! tw:pe-2! ${!(item.layer.itemType.icon_as_labels && activeTab !== 1) && 'tw:min-w-[10.4em]'} `}
|
||||||
aria-label={`${item.layer.itemType.icon_as_labels && activeTab !== 3 ? '♻️' : '♻️\u00A0Offers & Needs'}`}
|
aria-label={`${item.layer.itemType.icon_as_labels && activeTab !== 1 ? '♻️' : '♻️\u00A0Offers & Needs'}`}
|
||||||
checked={activeTab === 3 && true}
|
checked={activeTab === 1 && true}
|
||||||
onChange={() => updateActiveTab(3)}
|
onChange={() => updateActiveTab(1)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
role='tabpanel'
|
role='tabpanel'
|
||||||
@ -251,9 +251,9 @@ export const TabsView = ({
|
|||||||
name='my_tabs_2'
|
name='my_tabs_2'
|
||||||
role='tab'
|
role='tab'
|
||||||
className='tw:tab tw:font-bold tw:ps-2! tw:pe-2! '
|
className='tw:tab tw:font-bold tw:ps-2! tw:pe-2! '
|
||||||
aria-label={`${item.layer.itemType.icon_as_labels && activeTab !== 7 ? '🔗' : '🔗\u00A0Links'}`}
|
aria-label={`${item.layer.itemType.icon_as_labels && activeTab !== 2 ? '🔗' : '🔗\u00A0Links'}`}
|
||||||
checked={activeTab === 7 && true}
|
checked={activeTab === 2 && true}
|
||||||
onChange={() => updateActiveTab(7)}
|
onChange={() => updateActiveTab(2)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
role='tabpanel'
|
role='tabpanel'
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { encodeTag } from '#utils/FormatTags'
|
|||||||
import { hashTagRegex } from '#utils/HashTagRegex'
|
import { hashTagRegex } from '#utils/HashTagRegex'
|
||||||
import { randomColor } from '#utils/RandomColor'
|
import { randomColor } from '#utils/RandomColor'
|
||||||
|
|
||||||
|
import type { FormState } from '#types/FormState'
|
||||||
import type { Item } from '#types/Item'
|
import type { Item } from '#types/Item'
|
||||||
|
|
||||||
// eslint-disable-next-line promise/avoid-new
|
// eslint-disable-next-line promise/avoid-new
|
||||||
@ -77,8 +78,8 @@ export const submitNewItem = async (
|
|||||||
setAddItemPopupType('')
|
setAddItemPopupType('')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const linkItem = async (id: string, item, updateItem) => {
|
export const linkItem = async (id: string, item: Item, updateItem) => {
|
||||||
const newRelations = item.relations || []
|
const newRelations = item.relations ?? []
|
||||||
newRelations?.push({ items_id: item.id, related_items_id: id })
|
newRelations?.push({ items_id: item.id, related_items_id: id })
|
||||||
const updatedItem = { id: item.id, relations: newRelations }
|
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 newRelations = item.relations?.filter((r) => r.related_items_id !== id)
|
||||||
const updatedItem = { id: item.id, relations: newRelations }
|
const updatedItem = { id: item.id, relations: newRelations }
|
||||||
|
|
||||||
@ -116,7 +117,7 @@ export const unlinkItem = async (id: string, item, updateItem) => {
|
|||||||
|
|
||||||
export const handleDelete = async (
|
export const handleDelete = async (
|
||||||
event: React.MouseEvent<HTMLElement>,
|
event: React.MouseEvent<HTMLElement>,
|
||||||
item,
|
item: Item,
|
||||||
setLoading,
|
setLoading,
|
||||||
removeItem,
|
removeItem,
|
||||||
map,
|
map,
|
||||||
@ -144,8 +145,8 @@ export const handleDelete = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const onUpdateItem = async (
|
export const onUpdateItem = async (
|
||||||
state,
|
state: FormState,
|
||||||
item,
|
item: Item,
|
||||||
tags,
|
tags,
|
||||||
addTag,
|
addTag,
|
||||||
setLoading,
|
setLoading,
|
||||||
@ -159,19 +160,20 @@ export const onUpdateItem = async (
|
|||||||
|
|
||||||
const offerUpdates: any[] = []
|
const offerUpdates: any[] = []
|
||||||
// check for new offers
|
// check for new offers
|
||||||
await state.offers?.map((o) => {
|
state.offers?.map((o) => {
|
||||||
const existingOffer = item?.offers?.find((t) => t.tags_id === o.id)
|
const existingOffer = item?.offers?.find((t) => t.tags_id === o.id)
|
||||||
existingOffer && offerUpdates.push(existingOffer.id)
|
existingOffer && offerUpdates.push(existingOffer.tags_id)
|
||||||
if (!existingOffer && !tags.some((t) => t.id === o.id)) addTag({ ...o, offer_or_need: true })
|
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 })
|
!existingOffer && offerUpdates.push({ items_id: item?.id, tags_id: o.id })
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
const needsUpdates: any[] = []
|
const needsUpdates: any[] = []
|
||||||
|
|
||||||
await state.needs?.map((n) => {
|
state.needs?.map((n) => {
|
||||||
const existingNeed = item?.needs?.find((t) => t.tags_id === n.id)
|
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 && needsUpdates.push({ items_id: item?.id, tags_id: n.id })
|
||||||
!existingNeed && !tags.some((t) => t.id === n.id) && addTag({ ...n, offer_or_need: true })
|
!existingNeed && !tags.some((t) => t.id === n.id) && addTag({ ...n, offer_or_need: true })
|
||||||
return null
|
return null
|
||||||
@ -191,12 +193,13 @@ export const onUpdateItem = async (
|
|||||||
telephone: state.telephone,
|
telephone: state.telephone,
|
||||||
...(state.end && { end: state.end }),
|
...(state.end && { end: state.end }),
|
||||||
...(state.start && { start: state.start }),
|
...(state.start && { start: state.start }),
|
||||||
...(state.marker_icon && { markerIcon: state.marker_icon }),
|
...(state.marker_icon && { markerIcon: state.marker_icon.id }),
|
||||||
next_appointment: state.next_appointment,
|
next_appointment: state.next_appointment,
|
||||||
...(state.image.length > 10 && { image: state.image }),
|
...(state.image.length > 10 && { image: state.image }),
|
||||||
...(state.offers.length > 0 && { offers: offerUpdates }),
|
...(state.offers.length > 0 && { offers: offerUpdates }),
|
||||||
...(state.needs.length > 0 && { needs: needsUpdates }),
|
...(state.needs.length > 0 && { needs: needsUpdates }),
|
||||||
...(state.openCollectiveSlug && { openCollectiveSlug: state.openCollectiveSlug }),
|
...(state.openCollectiveSlug && { openCollectiveSlug: state.openCollectiveSlug }),
|
||||||
|
gallery: state.gallery,
|
||||||
}
|
}
|
||||||
|
|
||||||
const offersState: any[] = []
|
const offersState: any[] = []
|
||||||
@ -216,7 +219,7 @@ export const onUpdateItem = async (
|
|||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
await state.text
|
state.text
|
||||||
.toLocaleLowerCase()
|
.toLocaleLowerCase()
|
||||||
.match(hashTagRegex)
|
.match(hashTagRegex)
|
||||||
?.map((tag) => {
|
?.map((tag) => {
|
||||||
@ -234,7 +237,7 @@ export const onUpdateItem = async (
|
|||||||
await sleep(200)
|
await sleep(200)
|
||||||
|
|
||||||
if (!item.new) {
|
if (!item.new) {
|
||||||
item?.layer?.api?.updateItem &&
|
await (item?.layer?.api?.updateItem &&
|
||||||
toast
|
toast
|
||||||
.promise(item?.layer?.api?.updateItem(changedItem), {
|
.promise(item?.layer?.api?.updateItem(changedItem), {
|
||||||
pending: 'updating Item ...',
|
pending: 'updating Item ...',
|
||||||
@ -246,15 +249,15 @@ export const onUpdateItem = async (
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(setLoading(false))
|
.catch(setLoading(false))
|
||||||
.then(() => item && updateItem({ ...item, ...changedItem }))
|
.then(() => item && updateItem({ ...item, ...changedItem, markerIcon: state.marker_icon }))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
navigate(`/item/${item.id}${params && '?' + params}`)
|
navigate(`/item/${item.id}${params && '?' + params}`)
|
||||||
return null
|
return null
|
||||||
})
|
}))
|
||||||
} else {
|
} else {
|
||||||
item.new = false
|
item.new = false
|
||||||
item.layer?.api?.createItem &&
|
await (item.layer?.api?.createItem &&
|
||||||
toast
|
toast
|
||||||
.promise(item.layer?.api?.createItem(changedItem), {
|
.promise(item.layer?.api?.createItem(changedItem), {
|
||||||
pending: 'updating Item ...',
|
pending: 'updating Item ...',
|
||||||
@ -280,6 +283,6 @@ export const onUpdateItem = async (
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
navigate(`/${params && '?' + params}`)
|
navigate(`/${params && '?' + params}`)
|
||||||
return null
|
return null
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/Components/Templates/LoadingMapOverlay.tsx
Normal file
11
src/Components/Templates/LoadingMapOverlay.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { MapOverlayPage } from '#components/Templates/MapOverlayPage'
|
||||||
|
|
||||||
|
export const LoadingMapOverlay = () => {
|
||||||
|
return (
|
||||||
|
<MapOverlayPage backdrop className='tw:max-w-xs tw:h-64 tw:bg-transparent' card={false}>
|
||||||
|
<div className='tw:flex tw:justify-center tw:items-center tw:h-full'>
|
||||||
|
<div className='tw:loading tw:loading-spinner tw:loading-xl'></div>
|
||||||
|
</div>
|
||||||
|
</MapOverlayPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -45,7 +45,7 @@ export function MapOverlayPage({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className={`${card ? 'tw:card tw:card-body' : ''} tw:shadow-xl tw:bg-base-100 tw:p-6 ${className ?? ''} ${backdrop ? '' : 'tw:z-2000'} tw:absolute tw:top-0 tw:bottom-0 tw:right-0 tw:left-0 tw:m-auto`}
|
className={`${card ? 'tw:card tw:card-body tw:shadow-xl' : ''} tw:bg-base-100 tw:p-6 ${className ?? ''} ${backdrop ? '' : 'tw:z-2000'} tw:absolute tw:top-0 tw:bottom-0 tw:right-0 tw:left-0 tw:m-auto`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<button
|
<button
|
||||||
|
|||||||
65
src/Components/Templates/Tabs.tsx
Normal file
65
src/Components/Templates/Tabs.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
/* eslint-disable security/detect-object-injection */
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface TabItem {
|
||||||
|
title: string
|
||||||
|
component: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabsProps {
|
||||||
|
items: TabItem[]
|
||||||
|
setUrlParams: (params: URLSearchParams) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tabs: React.FC<TabsProps> = ({ items, setUrlParams }: TabsProps) => {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [activeIndex, setActiveIndex] = useState<number>(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const urlTab = params.get('tab')
|
||||||
|
if (urlTab !== null && !isNaN(Number(urlTab))) {
|
||||||
|
const index = Number(urlTab)
|
||||||
|
if (index >= 0 && index < items.length) {
|
||||||
|
setActiveIndex(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [items.length, location.search])
|
||||||
|
|
||||||
|
const updateActiveTab = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
setActiveIndex(index)
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
params.set('tab', `${index}`)
|
||||||
|
setUrlParams(params)
|
||||||
|
const newUrl = location.pathname + '?' + params.toString()
|
||||||
|
|
||||||
|
navigate(newUrl, { replace: false })
|
||||||
|
},
|
||||||
|
[location.pathname, location.search, navigate, setUrlParams],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='tw:flex tw:flex-col tw:flex-1 tw:min-h-0'>
|
||||||
|
<div role='tablist' className='tw:tabs tw:tabs-lift tw:flex-none'>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
role='tab'
|
||||||
|
className={`tw:tab ${index === activeIndex ? 'tw:tab-active' : ''}`}
|
||||||
|
onClick={() => updateActiveTab(index)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className='tw:flex-1 tw:flex tw:flex-col tw:min-h-0'>
|
||||||
|
{items[activeIndex]?.component}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -5,3 +5,4 @@ export { SelectUser } from './SelectUser'
|
|||||||
export { OverlayItemsIndexPage } from './OverlayItemsIndexPage'
|
export { OverlayItemsIndexPage } from './OverlayItemsIndexPage'
|
||||||
export { AttestationForm } from './AttestationForm'
|
export { AttestationForm } from './AttestationForm'
|
||||||
export { MarketView } from './MarketView'
|
export { MarketView } from './MarketView'
|
||||||
|
export { LoadingMapOverlay } from './LoadingMapOverlay'
|
||||||
|
|||||||
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;
|
transition: .5s ease;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: translate(8px, 8px);
|
transform: translate(16px, 16px);
|
||||||
}
|
}
|
||||||
63
src/assets/css/markdown.css
Normal file
63
src/assets/css/markdown.css
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
@import 'tailwindcss' prefix(tw);
|
||||||
|
|
||||||
|
@layer markdown {
|
||||||
|
.markdown {
|
||||||
|
h1 {
|
||||||
|
@apply tw:my-4 tw:text-2xl tw:font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
@apply tw:my-3 tw:text-xl tw:font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3, h4 {
|
||||||
|
@apply tw:my-2 tw:text-lg tw:font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
h5, h6 {
|
||||||
|
@apply tw:my-2 tw:text-base tw:font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
@apply tw:my-1 tw:leading-relaxed;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
@apply tw:pl-6 tw:my-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li {
|
||||||
|
@apply tw:list-disc tw:list-outside;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol li {
|
||||||
|
@apply tw:list-decimal tw:list-outside;
|
||||||
|
}
|
||||||
|
|
||||||
|
li > p {
|
||||||
|
@apply tw:inline-block tw:my-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
@apply tw:my-4 tw:border-current;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
@apply tw:max-w-full tw:rounded tw:shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
@apply tw:font-bold tw:underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
@apply tw:w-full tw:aspect-video;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashtag {
|
||||||
|
@apply tw:font-bold tw:cursor-pointer;
|
||||||
|
color: #faa;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/assets/css/tiptap.css
Normal file
43
src/assets/css/tiptap.css
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
|
||||||
|
|
||||||
|
.editor-wrapper div {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap p.is-editor-empty:first-child::before {
|
||||||
|
color: var(--color-base-content);
|
||||||
|
opacity: 0.5;
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bubble menu */
|
||||||
|
.bubble-menu {
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
border: 1px solid var(--color-base-200);
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
padding: 0.2rem;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: unset;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-base-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background-color: var(--color-base-200);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-base-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 |
@ -13,3 +13,5 @@ import '#assets/css/misc.css'
|
|||||||
import '#assets/css/marker-icons.css'
|
import '#assets/css/marker-icons.css'
|
||||||
import '#assets/css/leaflet.css'
|
import '#assets/css/leaflet.css'
|
||||||
import '#assets/css/color-picker.css'
|
import '#assets/css/color-picker.css'
|
||||||
|
import '#assets/css/markdown.css'
|
||||||
|
import '#assets/css/tiptap.css'
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import './css'
|
|||||||
export * from './Components/Map'
|
export * from './Components/Map'
|
||||||
export * from './Components/AppShell'
|
export * from './Components/AppShell'
|
||||||
export * from './Components/Auth'
|
export * from './Components/Auth'
|
||||||
export * from './Components/Profile'
|
|
||||||
export * from './Components/Gaming'
|
export * from './Components/Gaming'
|
||||||
export * from './Components/Templates'
|
export * from './Components/Templates'
|
||||||
export * from './Components/Input'
|
export * from './Components/Input'
|
||||||
|
|||||||
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 { markerIcon } from '#utils/MarkerIconFactory'
|
||||||
import type { Item } from './Item'
|
import type { GalleryItem, Item } from './Item'
|
||||||
import type { Tag } from './Tag'
|
import type { Tag } from './Tag'
|
||||||
|
|
||||||
export interface FormState {
|
export interface FormState {
|
||||||
@ -21,4 +21,6 @@ export interface FormState {
|
|||||||
start: string
|
start: string
|
||||||
end: string
|
end: string
|
||||||
openCollectiveSlug: 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 {
|
interface GalleryItem {
|
||||||
directus_files_id: {
|
directus_files_id: {
|
||||||
id: number
|
id: string
|
||||||
width: number
|
width: number
|
||||||
height: 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