feat(source): tip tap version 2 (#231)

* tip tap version 2

* youtube

* menu-bar

* refactorng layout

* fixed flex layout

* fixed flex layout

* a lot of ui fixes

* optimizing flex layout & styling inputs

* markdown styling

* fix linting

* updated snapshots

* layout optimization

* flex layout optimizations, text editor fine tuning and markdown rendering

* updated snapshots

---------

Co-authored-by: Anton Tranelis <mail@antontranelis.de>
Co-authored-by: Anton Tranelis <31516529+antontranelis@users.noreply.github.com>
This commit is contained in:
Ulf Gebhardt 2025-06-10 10:46:33 +02:00 committed by GitHub
parent 5927ba8c16
commit 2c50d66edc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1394 additions and 281 deletions

888
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,7 @@
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.4.1",
"@rollup/plugin-alias": "^5.1.1",
"@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-typescript": "^12.1.2",
"@tailwindcss/postcss": "^4.0.14",
@ -98,12 +99,23 @@
"dependencies": {
"@heroicons/react": "^2.0.17",
"@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",
"date-fns": "^3.3.1",
"leaflet": "^1.9.4",
"leaflet.locatecontrol": "^0.79.0",
"radash": "^12.1.0",
"react-colorful": "^5.6.1",
"react-icons": "^5.5.0",
"react-image-crop": "^10.1.8",
"react-inlinesvg": "^4.2.0",
"react-leaflet": "^4.2.1",
@ -113,6 +125,7 @@
"react-router-dom": "^6.16.0",
"react-toastify": "^9.1.3",
"remark-breaks": "^4.0.0",
"tiptap-markdown": "^0.8.10",
"yet-another-react-lightbox": "^3.21.7"
},
"imports": {

View File

@ -2,6 +2,7 @@ import path from 'path'
import { fileURLToPath } from 'url'
import alias from '@rollup/plugin-alias'
import commonjs from '@rollup/plugin-commonjs'
import resolve from '@rollup/plugin-node-resolve'
import typescript from '@rollup/plugin-typescript'
import { dts } from 'rollup-plugin-dts'
@ -40,6 +41,10 @@ export default [
resolve({
extensions: ['.ts', '.tsx'],
}),
commonjs({
include: /node_modules/,
requireReturnsDefault: 'auto',
}),
postcss({
plugins: [],
}),

View File

@ -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:truncate tw:grid tw:grid-flow-col'}
style={{ maxWidth: nameWidth + 60 }}
style={{ maxWidth: nameWidth + 62 }}
>
<Link
className='tw:btn tw:btn-ghost tw:px-2 tw:normal-case tw:text-xl tw:flex-1 tw:truncate'

View File

@ -13,7 +13,7 @@ const ComboBoxInput = ({ id, options, value, onValueChange }: ComboBoxProps) =>
return (
<select
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}
value={value} // ← hier controlled statt defaultValue
>

View File

@ -1,15 +1,24 @@
import { useEffect, useRef, useState } from 'react'
/* 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'
interface TextAreaProps {
import { TextEditorMenu } from './TextEditorMenu'
interface RichTextEditorProps {
labelTitle?: string
labelStyle?: string
containerStyle?: string
dataField?: string
inputStyle?: string
defaultValue: string
placeholder?: string
required?: boolean
size?: string
showMenu?: boolean
updateFormValue?: (value: string) => void
}
@ -18,48 +27,84 @@ interface TextAreaProps {
*/
export function RichTextEditor({
labelTitle,
dataField,
labelStyle,
containerStyle,
inputStyle,
defaultValue,
placeholder,
required = true,
showMenu = true,
updateFormValue,
}: TextAreaProps) {
const ref = useRef<HTMLTextAreaElement>(null)
const [inputValue, setInputValue] = useState<string>(defaultValue)
}: RichTextEditorProps) {
const handleChange = () => {
let newValue: string | undefined = editor?.storage.markdown.getMarkdown()
useEffect(() => {
setInputValue(defaultValue)
}, [defaultValue])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
setInputValue(newValue)
if (updateFormValue) {
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 ${containerStyle ?? ''}`}>
<div
className={`tw:form-control tw:w-full tw:flex tw:flex-col tw:min-h-0 ${containerStyle ?? ''}`}
>
{labelTitle ? (
<label className='tw:label'>
<label className='tw:label tw:pb-1'>
<span className={`tw:label-text tw:text-base-content ${labelStyle ?? ''}`}>
{labelTitle}
</span>
</label>
) : null}
<textarea
required={required}
ref={ref}
value={inputValue}
name={dataField}
className={`tw:textarea tw:textarea-bordered tw:w-full tw:leading-5 ${inputStyle ?? ''}`}
placeholder={placeholder ?? ''}
onChange={handleChange}
></textarea>
<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>
)
}

View 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>
</>
)
}

View File

@ -2,7 +2,7 @@
exports[`<ComboBoxInput /> > renders properly 1`] = `
<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
value="Option 1"

View File

@ -6,6 +6,7 @@ import type { Item } from '#types/Item'
export interface StartEndInputProps {
item?: Item
showLabels?: boolean
labelStyle?: string
updateStartValue?: (value: string) => void
updateEndValue?: (value: string) => void
}
@ -16,6 +17,7 @@ export interface StartEndInputProps {
export const PopupStartEndInput = ({
item,
showLabels = true,
labelStyle,
updateStartValue,
updateEndValue,
}: StartEndInputProps) => {
@ -26,7 +28,8 @@ export const PopupStartEndInput = ({
placeholder='start'
dataField='start'
inputStyle='tw:text-sm tw:px-2'
labelTitle={showLabels ? 'start' : ''}
labelTitle={showLabels ? 'Start' : ''}
labelStyle={labelStyle}
defaultValue={item && item.start ? item.start.substring(0, 10) : ''}
autocomplete='one-time-code'
updateFormValue={updateStartValue}
@ -36,7 +39,8 @@ export const PopupStartEndInput = ({
placeholder='end'
dataField='end'
inputStyle='tw:text-sm tw:px-2'
labelTitle={showLabels ? 'end' : ''}
labelTitle={showLabels ? 'End' : ''}
labelStyle={labelStyle}
defaultValue={item && item.end ? item.end.substring(0, 10) : ''}
autocomplete='one-time-code'
updateFormValue={updateEndValue}

View File

@ -23,7 +23,7 @@ export const PopupTextInput = ({
placeholder={placeholder}
inputStyle={style}
type='text'
containerStyle={'tw:mt-4'}
containerStyle={'tw:mt-4 tw:mb-2'}
></TextInput>
)
}

View File

@ -53,22 +53,6 @@ export const TextView = ({
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) {
replacedText = replacedText.replace(mailRegex, (url) => {
return `[${url}](mailto:${url})`

View File

@ -151,11 +151,11 @@ export function ProfileForm() {
<>
<MapOverlayPage
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
className='tw:h-full'
className='tw:flex tw:flex-col tw:flex-1 tw:min-h-0'
onSubmit={(e) => {
e.preventDefault()
void onUpdateItem(
@ -172,7 +172,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} />
{template === 'onepager' && (
@ -198,7 +198,7 @@ export function ProfileForm() {
></TabsForm>
)}
<div className='tw:mt-4 tw:flex-none'>
<div className='tw:mb-4 tw:mt-6 tw:flex-none'>
<button
className={`${loading ? ' tw:loading tw:btn tw:float-right' : 'tw:btn tw:float-right'}`}
type='submit'

View File

@ -174,7 +174,7 @@ export function ProfileView({ attestationApi }: { attestationApi?: ItemsApi<any>
{item && (
<MapOverlayPage
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'}>

View File

@ -12,7 +12,7 @@ export const ContactInfoForm = ({
setState: React.Dispatch<React.SetStateAction<any>>
}) => {
return (
<div className='tw:mt-4 tw:space-y-4'>
<div className='tw:mt-2 tw:space-y-2'>
<div>
<label
htmlFor='email'

View File

@ -51,7 +51,7 @@ export const GroupSubheaderForm = ({
}, [state.group_type, groupTypes])
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>
<label
htmlFor='status'

View File

@ -18,7 +18,6 @@ export const ProfileTextForm = ({
heading,
size,
hideInputLabel,
required,
}: {
state: FormState
setState: React.Dispatch<React.SetStateAction<any>>
@ -26,7 +25,6 @@ export const ProfileTextForm = ({
heading: string
size: string
hideInputLabel: boolean
required?: boolean
}) => {
const [field, setField] = useState<string>(dataField || 'text')
@ -37,11 +35,13 @@ export const ProfileTextForm = ({
}, [dataField])
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'>
<label
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'}:
</label>
@ -57,10 +57,9 @@ export const ProfileTextForm = ({
[field]: v,
}))
}
showMenu={size === 'full'}
labelStyle={hideInputLabel ? 'tw:hidden' : ''}
containerStyle={size === 'full' ? 'tw:grow tw:h-full' : ''}
inputStyle={size === 'full' ? 'tw:h-full' : 'tw:h-24'}
required={required}
containerStyle={size === 'full' ? 'tw:flex-1' : 'tw:h-24 tw:flex-none'}
/>
</div>
)

View File

@ -33,6 +33,9 @@ export const TagsWidget = ({ placeholder, containerStyle, defaultTags, onUpdate
}
const onKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
}
const { key } = e
const trimmedInput = input.trim()
@ -42,7 +45,6 @@ export const TagsWidget = ({ placeholder, containerStyle, defaultTags, onUpdate
// eslint-disable-next-line react/prop-types
!defaultTags.some((tag) => tag.name.toLocaleLowerCase() === trimmedInput.toLocaleLowerCase())
) {
e.preventDefault()
const newTag = tags.find((t) => t.name === trimmedInput.toLocaleLowerCase())
newTag && onUpdate([...currentTags, newTag])
!newTag &&

View File

@ -29,7 +29,7 @@ export const FlexForm = ({
item: Item
}) => {
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) => {
const TemplateComponent = componentMap[templateItem.collection]
return TemplateComponent ? (
@ -41,7 +41,7 @@ export const FlexForm = ({
{...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
</div>
)

View File

@ -6,7 +6,6 @@
/* eslint-disable @typescript-eslint/restrict-plus-operands */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable react/prop-types */
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { RichTextEditor } from '#components/Input/RichTextEditor'
@ -15,6 +14,7 @@ import { PopupStartEndInput, TextView } from '#components/Map/Subcomponents/Item
import { ActionButton } from '#components/Profile/Subcomponents/ActionsButton'
import { LinkedItemsHeaderView } from '#components/Profile/Subcomponents/LinkedItemsHeaderView'
import { TagsWidget } from '#components/Profile/Subcomponents/TagsWidget'
import { Tabs } from '#components/Templates/Tabs'
export const TabsForm = ({
item,
@ -26,114 +26,73 @@ export const TabsForm = ({
loading,
setUrlParams,
}) => {
const [activeTab, setActiveTab] = useState<number>(1)
const navigate = useNavigate()
const updateItem = useUpdateItem()
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])
const navigate = useNavigate()
return (
<div className='tw:grow'>
<div role='tablist' className='tw:tabs tw:h-full tw:tabs-lift tw:mt-3'>
<input
type='radio'
name='my_tabs_2'
role='tab'
className={'tw:tab '}
aria-label='Info'
checked={activeTab === 1 && true}
onChange={() => updateActiveTab(1)}
/>
<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:flex tw:flex-col tw:h-full ${item.layer.itemType.show_start_end_input && 'tw:pt-4'}`}
>
{item.layer.itemType.show_start_end_input && (
<PopupStartEndInput
item={item}
showLabels={false}
updateEndValue={(e) =>
setState((prevState) => ({
...prevState,
end: e,
}))
}
updateStartValue={(s) =>
setState((prevState) => ({
...prevState,
start: s,
}))
}
></PopupStartEndInput>
)}
<div className='tw:flex tw:flex-col tw:flex-1 tw:min-h-0 tw:mt-4'>
<Tabs
setUrlParams={setUrlParams}
items={[
{
title: 'Info',
component: (
<div
className={`tw:flex tw:flex-col tw:flex-1 tw:min-h-0 ${item.layer.itemType.show_start_end_input && 'tw:pt-4'}`}
>
{item.layer.itemType.show_start_end_input && (
<PopupStartEndInput
item={item}
showLabels={true}
labelStyle={'tw:text-base-content/50'}
updateEndValue={(e) =>
setState((prevState) => ({
...prevState,
end: e,
}))
}
updateStartValue={(s) =>
setState((prevState) => ({
...prevState,
start: s,
}))
}
></PopupStartEndInput>
)}
<RichTextEditor
labelTitle='About me'
placeholder='about ...'
defaultValue={item?.text ? item.text : ''}
updateFormValue={(v) =>
setState((prevState) => ({
...prevState,
text: v,
}))
}
containerStyle='tw:grow'
inputStyle={`tw:h-full ${!item.layer.itemType.show_start_end_input && 'tw:border-t-0 tw:rounded-tl-none'}`}
/>
<RichTextEditor
labelTitle='Contact Info'
placeholder='contact info ...'
defaultValue={state.contact || ''}
updateFormValue={(c) =>
setState((prevState) => ({
...prevState,
contact: c,
}))
}
inputStyle=''
containerStyle='tw:pt-4 tw:h-24 tw:flex-none'
required={false}
/>
</div>
</div>
{item.layer?.itemType.offers_and_needs && (
<>
<input
type='radio'
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'
>
<RichTextEditor
labelTitle='About'
labelStyle={'tw:text-base-content/50'}
placeholder='about ...'
defaultValue={item?.text ? item.text : ''}
updateFormValue={(v) =>
setState((prevState) => ({
...prevState,
text: v,
}))
}
containerStyle='tw:pt-2 tw:flex-1 tw:min-h-36 tw:max-h-136'
/>
<RichTextEditor
labelTitle='Contact Info'
labelStyle={'tw:text-base-content/50'}
placeholder='contact info ...'
defaultValue={state.contact || ''}
updateFormValue={(c) =>
setState((prevState) => ({
...prevState,
contact: c,
}))
}
containerStyle='tw:pt-2 tw:h-36 tw:flex-none'
showMenu={false}
/>
</div>
),
},
{
title: 'Offers & Needs',
component: (
<div className='tw:h-full'>
<div className='tw:w-full tw:h-[calc(50%-0.75em)] tw:mb-4'>
<TagsWidget
@ -162,24 +121,11 @@ export const TabsForm = ({
/>
</div>
</div>
</div>
</>
)}
{item.layer?.itemType.relations && (
<>
<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'
>
),
},
{
title: 'Links',
component: (
<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'>
{state.relations &&
@ -211,10 +157,10 @@ export const TabsForm = ({
)}
</div>
</div>
</div>
</>
)}
</div>
),
},
]}
></Tabs>
</div>
)
}

View File

@ -43,7 +43,7 @@ export const TabsView = ({
unlinkItem: (id: string) => Promise<void>
}) => {
const addFilterTag = useAddFilterTag()
const [activeTab, setActiveTab] = useState<number>()
const [activeTab, setActiveTab] = useState<number>(0)
const navigate = useNavigate()
const [addItemPopupType] = useState<string>('')
@ -80,7 +80,7 @@ export const TabsView = ({
useEffect(() => {
const params = new URLSearchParams(location.search)
const urlTab = params.get('tab')
setActiveTab(urlTab ? Number(urlTab) : 1)
setActiveTab(urlTab ? Number(urlTab) : 0)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search])
@ -91,9 +91,9 @@ export const TabsView = ({
name='my_tabs_2'
role='tab'
className={'tw:tab tw:font-bold tw:ps-2! tw:pe-2! '}
aria-label={`${item.layer?.itemType.icon_as_labels && activeTab !== 1 ? '📝' : '📝\u00A0Info'}`}
checked={activeTab === 1 && true}
onChange={() => updateActiveTab(1)}
aria-label={`${item.layer?.itemType.icon_as_labels && activeTab !== 0 ? '📝' : '📝\u00A0Info'}`}
checked={activeTab === 0 && true}
onChange={() => updateActiveTab(0)}
/>
<div
role='tabpanel'
@ -115,9 +115,9 @@ export const TabsView = ({
name='my_tabs_2'
role='tab'
className={'tw:tab tw:font-bold tw:ps-2! tw:pe-2!'}
aria-label={`${item.layer.itemType.icon_as_labels && activeTab !== 2 ? '❤️' : '❤️\u00A0Trust'}`}
checked={activeTab === 2 && true}
onChange={() => updateActiveTab(2)}
aria-label={`${item.layer.itemType.icon_as_labels && activeTab !== 3 ? '❤️' : '❤️\u00A0Trust'}`}
checked={activeTab === 3 && true}
onChange={() => updateActiveTab(3)}
/>
<div
role='tabpanel'
@ -197,10 +197,10 @@ export const TabsView = ({
type='radio'
name='my_tabs_2'
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]'} `}
aria-label={`${item.layer.itemType.icon_as_labels && activeTab !== 3 ? '♻️' : '♻️\u00A0Offers & Needs'}`}
checked={activeTab === 3 && true}
onChange={() => updateActiveTab(3)}
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 !== 1 ? '♻️' : '♻️\u00A0Offers & Needs'}`}
checked={activeTab === 1 && true}
onChange={() => updateActiveTab(1)}
/>
<div
role='tabpanel'
@ -251,9 +251,9 @@ export const TabsView = ({
name='my_tabs_2'
role='tab'
className='tw:tab tw:font-bold tw:ps-2! tw:pe-2! '
aria-label={`${item.layer.itemType.icon_as_labels && activeTab !== 7 ? '🔗' : '🔗\u00A0Links'}`}
checked={activeTab === 7 && true}
onChange={() => updateActiveTab(7)}
aria-label={`${item.layer.itemType.icon_as_labels && activeTab !== 2 ? '🔗' : '🔗\u00A0Links'}`}
checked={activeTab === 2 && true}
onChange={() => updateActiveTab(2)}
/>
<div
role='tabpanel'

View 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>
)
}

View File

@ -1,63 +1,63 @@
@import 'tailwindcss' prefix(tw);
@layer markdown {
.markdown h1 {
@apply tw:text-xl;
@apply tw:font-bold;
}
.markdown {
h1 {
@apply tw:my-4 tw:text-2xl tw:font-bold;
}
.markdown h2 {
@apply tw:text-lg;
@apply tw:font-bold;
}
h2 {
@apply tw:my-3 tw:text-xl tw:font-bold;
}
.markdown h3, .markdown h4 {
@apply tw:text-base;
@apply tw:font-bold;
}
h3, h4 {
@apply tw:my-2 tw:text-lg tw:font-bold;
}
.markdown h5, .markdown h6 {
@apply tw:text-sm;
@apply tw:font-bold;
}
h5, h6 {
@apply tw:my-2 tw:text-base tw:font-bold;
}
.markdown p {
@apply tw:my-2!;
}
p {
@apply tw:my-1 tw:leading-relaxed;
}
.markdown ul {
@apply tw:list-disc;
@apply tw:list-inside;
}
ul, ol {
@apply tw:pl-6 tw:my-2;
}
.markdown ol {
@apply tw:list-decimal;
@apply tw:list-inside;
}
ul li {
@apply tw:list-disc tw:list-outside;
}
.markdown hl {
@apply tw:border-current;
}
ol li {
@apply tw:list-decimal tw:list-outside;
}
.markdown img {
@apply tw:max-w-full;
@apply tw:rounded;
@apply tw:shadow;
}
li > p {
@apply tw:inline-block tw:my-0;
}
.markdown a {
@apply tw:font-bold;
@apply tw:underline;
}
hr {
@apply tw:my-4 tw:border-current;
}
.markdown .hashtag {
color: #faa;
font-weight: bold;
cursor: pointer;
text-decoration: none;
}
img {
@apply tw:max-w-full tw:rounded tw:shadow;
}
.markdown iframe {
@apply tw:w-full;
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
View 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);
}
}
}
}

View File

@ -14,3 +14,4 @@ import '#assets/css/marker-icons.css'
import '#assets/css/leaflet.css'
import '#assets/css/color-picker.css'
import '#assets/css/markdown.css'
import '#assets/css/tiptap.css'