'use client'; import React, { FC, Fragment, MouseEventHandler, useCallback, useEffect, useMemo, ClipboardEvent, useState, memo, } from 'react'; import dayjs from 'dayjs'; import { Integrations } from '@gitroom/frontend/components/launches/calendar.context'; import clsx from 'clsx'; import { usePreventWindowUnload } from '@gitroom/react/helpers/use.prevent.window.unload'; import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; import { useModals } from '@mantine/modals'; import { useHideTopEditor } from '@gitroom/frontend/components/launches/helpers/use.hide.top.editor'; import { Button } from '@gitroom/react/form/button'; // @ts-ignore import useKeypress from 'react-use-keypress'; import { getValues, resetValues, } from '@gitroom/frontend/components/launches/helpers/use.values'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { useMoveToIntegration } from '@gitroom/frontend/components/launches/helpers/use.move.to.integration'; import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data'; import { MultiMediaComponent } from '@gitroom/frontend/components/media/media.component'; import { useExpend } from '@gitroom/frontend/components/launches/helpers/use.expend'; import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; import { PickPlatforms } from '@gitroom/frontend/components/launches/helpers/pick.platform.component'; import { ProvidersOptions } from '@gitroom/frontend/components/launches/providers.options'; import useSWR from 'swr'; import { useToaster } from '@gitroom/react/toaster/toaster'; import { UpDownArrow } from '@gitroom/frontend/components/launches/up.down.arrow'; import { DatePicker } from '@gitroom/frontend/components/launches/helpers/date.picker'; import { arrayMoveImmutable } from 'array-move'; import { Information, PostToOrganization, } from '@gitroom/frontend/components/launches/post.to.organization'; import { Submitted } from '@gitroom/frontend/components/launches/submitted'; import { supportEmitter } from '@gitroom/frontend/components/layout/support'; import { Editor } from '@gitroom/frontend/components/launches/editor'; import { AddPostButton } from '@gitroom/frontend/components/launches/add.post.button'; import { useStateCallback } from '@gitroom/react/helpers/use.state.callback'; import { CopilotPopup } from '@copilotkit/react-ui'; import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import Image from 'next/image'; import { weightedLength } from '@gitroom/helpers/utils/count.length'; import { useClickOutside } from '@gitroom/frontend/components/layout/click.outside'; import { useUppyUploader } from '@gitroom/frontend/components/media/new.uploader'; import { LoadingComponent } from '@gitroom/frontend/components/layout/loading'; import { DropFiles } from '@gitroom/frontend/components/layout/drop.files'; import { SelectCustomer } from '@gitroom/frontend/components/launches/select.customer'; import { TagsComponent } from './tags.component'; import { RepeatComponent } from '@gitroom/frontend/components/launches/repeat.component'; import { MergePost } from '@gitroom/frontend/components/launches/merge.post'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { CreatePostDto } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto'; import { uniq } from 'lodash'; import { SetContext } from '@gitroom/frontend/components/launches/set.context'; function countCharacters(text: string, type: string): number { if (type !== 'x') { return text.length; } return weightedLength(text); } export const AddEditModal: FC<{ date: dayjs.Dayjs; integrations: Integrations[]; allIntegrations?: Integrations[]; set?: CreatePostDto; addEditSets?: (data: any) => void; reopenModal: () => void; mutate: () => void; padding?: string; customClose?: () => void; onlyValues?: Array<{ content: string; id?: string; image?: Array<{ id: string; path: string; }>; }>; }> = memo((props) => { const { date, integrations: ints, reopenModal, mutate, onlyValues, padding, customClose, addEditSets, set, } = props; const [customer, setCustomer] = useState(''); const [loading, setLoading] = useState(false); const [uploading, setUploading] = useState(false); const [canUseClose, setCanUseClose] = useState(true); const t = useT(); // selected integrations to allow edit const [selectedIntegrations, setSelectedIntegrations] = useStateCallback< Integrations[] >([]); const integrations = useMemo(() => { if (!customer) { return ints; } const list = ints.filter((f) => f?.customer?.id === customer); if (list.length === 1 && !set) { setSelectedIntegrations([list[0]]); } return list; }, [customer, ints, set]); const [dateState, setDateState] = useState(date); // hook to open a new modal const modal = useModals(); const selectIntegrationsDefault = useMemo(() => { if (!set) { return []; } const keepReference: Integrations[] = []; const neededIntegrations = uniq(set.posts.flatMap((p) => p.integration.id)); for (const i of ints) { if (neededIntegrations.indexOf(i.id) > -1) { keepReference.push(i); } } return keepReference; }, [set]); useEffect(() => { if (set?.posts) { setSelectedIntegrations(selectIntegrationsDefault); } }, [selectIntegrationsDefault]); // value of each editor const [value, setValue] = useState< Array<{ content: string; id?: string; image?: Array<{ id: string; path: string; }>; }> >( onlyValues ? onlyValues : set ? set?.posts?.[0]?.value || [ { content: '', }, ] : [ { content: '', }, ] ); const fetch = useFetch(); const user = useUser(); const updateOrder = useCallback(() => { modal.closeAll(); reopenModal(); }, [reopenModal, modal]); // prevent the window exit by mistake usePreventWindowUnload(true); // hook to move the settings in the right place to fix missing fields const moveToIntegration = useMoveToIntegration(); // hook to test if the top editor should be hidden const showHide = useHideTopEditor(); // merge all posts and delete all the comments const merge = useCallback(() => { setValue( value.reduce( (all, current) => { all[0].content = all[0].content + current.content + '\n'; all[0].image = [...all[0].image, ...(current.image || [])]; return all; }, [ { content: '', id: value[0].id, image: [] as { id: string; path: string; }[], }, ] ) ); }, [value]); const [showError, setShowError] = useState(false); // are we in edit mode? const existingData = useExistingData(); const [inter, setInter] = useState(existingData?.posts?.[0]?.intervalInDays); const [tags, setTags] = useState( // @ts-ignore existingData?.posts?.[0]?.tags?.map((p: any) => ({ label: p.tag.name, value: p.tag.name, })) || [] ); // Post for const [postFor, setPostFor] = useState(); const expend = useExpend(); const toaster = useToaster(); // if it's edit just set the current integration useEffect(() => { if (existingData.integration) { setSelectedIntegrations([ integrations.find((p) => p.id === existingData.integration)!, ]); } else if (integrations.length === 1) { setSelectedIntegrations([integrations[0]]); } }, [existingData.integration]); // if the user exit the popup we reset the global variable with all the values useEffect(() => { supportEmitter.emit('change', false); return () => { supportEmitter.emit('change', true); resetValues(); }; }, []); // Change the value of the global editor const changeValue = useCallback( (index: number) => (newValue: string) => { return setValue((prev) => { prev[index].content = newValue; return [...prev]; }); }, [value] ); const changeImage = useCallback( (index: number) => (newValue: { target: { name: string; value?: Array<{ id: string; path: string; }>; }; }) => { return setValue((prev) => { return prev.map((p, i) => { if (i === index) { return { ...p, image: newValue.target.value, }; } return p; }); }); }, [] ); // Add another editor const addValue = useCallback( (index: number) => () => { setValue((prev) => { return prev.reduce( (acc, p, i) => { acc.push(p); if (i === index) { acc.push({ content: '', }); } return acc; }, [] as Array<{ content: string; }> ); }); }, [] ); const changePosition = useCallback( (index: number) => (type: 'up' | 'down') => { if (type === 'up' && index !== 0) { setValue((prev) => { return arrayMoveImmutable(prev, index, index - 1); }); } else if (type === 'down') { setValue((prev) => { return arrayMoveImmutable(prev, index, index + 1); }); } }, [] ); // Delete post const deletePost = useCallback( (index: number) => async () => { if ( !(await deleteDialog( 'Are you sure you want to delete this post?', 'Yes, delete it!' )) ) { return; } setValue((prev) => { prev.splice(index, 1); return [...prev]; }); }, [value] ); // override the close modal to ask the user if he is sure to close const askClose = useCallback(async () => { if (!canUseClose) { return; } if ( await deleteDialog( t( 'are_you_sure_you_want_to_close_this_modal_all_data_will_be_lost', 'Are you sure you want to close this modal? (all data will be lost)' ), t('yes_close_it', 'Yes, close it!') ) ) { if (customClose) { customClose(); return; } modal.closeAll(); } }, [canUseClose]); // sometimes it's easier to click escape to close useKeypress('Escape', askClose); const postNow = useCallback( ((e) => { e.stopPropagation(); e.preventDefault(); return schedule('now')(); }) as MouseEventHandler, [] ); // function to send to the server and save const schedule = useCallback( (type: 'draft' | 'now' | 'schedule' | 'delete') => async () => { if (type === 'delete') { setLoading(true); if ( !(await deleteDialog( 'Are you sure you want to delete this post?', 'Yes, delete it!' )) ) { setLoading(false); return; } await fetch(`/posts/${existingData.group}`, { method: 'DELETE', }); mutate(); modal.closeAll(); return; } const values = getValues(); const allKeys = Object.keys(values).map((v) => ({ integration: integrations.find((p) => p.id === v), value: values[v].posts, valid: values[v].isValid, group: existingData?.group, trigger: values[v].trigger, settings: values[v].settings(), checkValidity: values[v].checkValidity, maximumCharacters: values[v].maximumCharacters, })); if (type !== 'draft') { for (const key of allKeys) { if (key.checkValidity) { const check = await key.checkValidity( key?.value.map((p: any) => p.image || []), key.settings ); if (typeof check === 'string') { toaster.show(check, 'warning'); return; } } if ( key.value.some((p) => { return ( countCharacters(p.content, key?.integration?.identifier || '') > (key.maximumCharacters || 1000000) ); }) ) { if ( !(await deleteDialog( `${key?.integration?.name} post is too long, it will be cropped, do you want to continue?`, 'Yes, continue' )) ) { await key.trigger(); moveToIntegration({ identifier: key?.integration?.id!, toPreview: true, }); return; } } if (key.value.some((p) => !p.content || p.content.length < 6)) { setShowError(true); return; } if (!key.valid) { await key.trigger(); moveToIntegration({ identifier: key?.integration?.id!, }); return; } } } const shortLinkUrl = await ( await fetch('/posts/should-shortlink', { method: 'POST', body: JSON.stringify({ messages: allKeys.flatMap((p) => p.value.flatMap((a) => a.content.slice(0, p.maximumCharacters || 1000000) ) ), }), }) ).json(); const shortLink = !shortLinkUrl.ask ? false : await deleteDialog( 'Do you want to shortlink the URLs? it will let you get statistics over clicks', 'Yes, shortlink it!' ); setLoading(true); const data = { ...(postFor ? { order: postFor.id, } : {}), type, inter, tags, shortLink, date: dateState.utc().format('YYYY-MM-DDTHH:mm:ss'), posts: allKeys.map((p) => ({ ...p, value: p.value.map((a) => ({ ...a, content: a.content.slice(0, p.maximumCharacters || 1000000), })), })), }; addEditSets ? addEditSets(data) : await fetch('/posts', { method: 'POST', body: JSON.stringify(data), }); existingData.group = makeId(10); if (!addEditSets) { mutate(); toaster.show( !existingData.integration ? 'Added successfully' : 'Updated successfully' ); } if (customClose) { setTimeout(() => { customClose(); }, 2000); } if (!addEditSets) { modal.closeAll(); } }, [ inter, postFor, dateState, value, integrations, existingData, selectedIntegrations, tags, addEditSets, ] ); const uppy = useUppyUploader({ onUploadSuccess: () => { /**empty**/ }, allowedFileTypes: 'image/*,video/mp4', }); const pasteImages = useCallback( (index: number, currentValue: any[], isFile?: boolean) => { return async (event: ClipboardEvent | File[]) => { // @ts-ignore const clipboardItems = isFile ? // @ts-ignore event.map((p) => ({ kind: 'file', getAsFile: () => p, })) : // @ts-ignore event.clipboardData?.items; // Ensure clipboardData is available if (!clipboardItems) { return; } const files: File[] = []; // @ts-ignore for (const item of clipboardItems) { console.log(item); if (item.kind === 'file') { const file = item.getAsFile(); if (file) { const isImage = file.type.startsWith('image/'); const isVideo = file.type.startsWith('video/'); if (isImage || isVideo) { files.push(file); // Collect images or videos } } } } if (files.length === 0) { return; } setUploading(true); const lastValues = [...currentValue]; for (const file of files) { uppy.addFile(file); const upload = await uppy.upload(); uppy.clear(); if (upload?.successful?.length) { lastValues.push(upload?.successful[0]?.response?.body?.saved!); changeImage(index)({ target: { name: 'image', value: [...lastValues], }, }); } } setUploading(false); }; }, [changeImage] ); const getPostsMarketplace = useCallback(async () => { return ( await fetch(`/posts/marketplace/${existingData?.posts?.[0]?.id}`) ).json(); }, []); const { data } = useSWR( `/posts/marketplace/${existingData?.posts?.[0]?.id}`, getPostsMarketplace ); const canSendForPublication = useMemo(() => { if (!postFor) { return true; } return selectedIntegrations.every((integration) => { const find = postFor.missing.find( (p) => p.integration.integration.id === integration.id ); if (!find) { return false; } return find.missing !== 0; }); }, [data, postFor, selectedIntegrations]); useClickOutside(askClose); return ( {user?.tier?.ai && ( )}
{uploading && (
)}
{ setCustomer(val); setSelectedIntegrations([]); }} /> {!selectedIntegrations.length && ( )}
{!existingData.integration && integrations.length > 1 ? (
!f.disabled)} selectedIntegrations={selectIntegrationsDefault} singleSelect={false} onChange={setSelectedIntegrations} isMain={true} />
) : (
{selectedIntegrations?.[0]?.identifier} {selectedIntegrations?.[0]?.identifier === 'youtube' ? ( ) : ( {selectedIntegrations?.[0]?.identifier} )}
)}
{!existingData.integration && !showHide.hideTopEditor ? ( <>
{t( 'you_are_in_global_editing_mode', 'You are in global editing mode' )}
{value.map((p, index) => (
1 ? 150 : 250} value={p.content} totalPosts={value.length} preview="edit" onPaste={pasteImages(index, p.image || [])} // @ts-ignore onChange={changeValue(index)} /> {showError && (!p.content || p.content.length < 6) && (
{t( 'the_post_should_be_at_least_6_characters_long', 'The post should be at least 6 characters long' )}
)}
setCanUseClose(false)} onClose={() => setCanUseClose(true)} />
{value.length > 1 && (
{t('delete_post', 'Delete Post')}
)}
))} {value.length > 1 && (
)} ) : null}
{!!existingData.integration && ( )} {addEditSets && ( )} {!addEditSets && ( )} {!addEditSets && ( )}
setTags(e.target.value)} />
{!!selectedIntegrations.length && (
)}
); });