postiz/apps/frontend/src/components/new-launch/manage.modal.tsx

565 lines
19 KiB
TypeScript

'use client';
import React, {
FC,
ReactNode,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { AddEditModalProps } from '@gitroom/frontend/components/new-launch/add.edit.modal';
import clsx from 'clsx';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { PicksSocialsComponent } from '@gitroom/frontend/components/new-launch/picks.socials.component';
import { EditorWrapper } from '@gitroom/frontend/components/new-launch/editor';
import { SelectCurrent } from '@gitroom/frontend/components/new-launch/select.current';
import { ShowAllProviders } from '@gitroom/frontend/components/new-launch/providers/show.all.providers';
import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store';
import { DatePicker } from '@gitroom/frontend/components/launches/helpers/date.picker';
import { useShallow } from 'zustand/react/shallow';
import { RepeatComponent } from '@gitroom/frontend/components/launches/repeat.component';
import { TagsComponent } from '@gitroom/frontend/components/launches/tags.component';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { weightedLength } from '@gitroom/helpers/utils/count.length';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { capitalize } from 'lodash';
import { SelectCustomer } from '@gitroom/frontend/components/launches/select.customer';
import { CopilotPopup } from '@copilotkit/react-ui';
import { DummyCodeComponent } from '@gitroom/frontend/components/new-launch/dummy.code.component';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
import {
SettingsIcon,
ChevronDownIcon,
CloseIcon,
TrashIcon,
DropdownArrowSmallIcon,
} from '@gitroom/frontend/components/ui/icons';
import { useHasScroll } from '@gitroom/frontend/components/ui/is.scroll.hook';
function countCharacters(text: string, type: string): number {
if (type !== 'x') {
return text.length;
}
return weightedLength(text);
}
export const ManageModal: FC<AddEditModalProps> = (props) => {
const t = useT();
const fetch = useFetch();
const ref = useRef(null);
const existingData = useExistingData();
const [loading, setLoading] = useState(false);
const toaster = useToaster();
const modal = useModals();
const [showSettings, setShowSettings] = useState(false);
const { addEditSets, mutate, customClose, dummy } = props;
const {
selectedIntegrations,
hide,
date,
setDate,
repeater,
setRepeater,
tags,
setTags,
integrations,
setSelectedIntegrations,
locked,
current,
activateExitButton,
} = useLaunchStore(
useShallow((state) => ({
hide: state.hide,
date: state.date,
setDate: state.setDate,
current: state.current,
repeater: state.repeater,
setRepeater: state.setRepeater,
tags: state.tags,
setTags: state.setTags,
selectedIntegrations: state.selectedIntegrations,
integrations: state.integrations,
setSelectedIntegrations: state.setSelectedIntegrations,
locked: state.locked,
activateExitButton: state.activateExitButton,
}))
);
const currentIntegrationText = useMemo(() => {
if (current === 'global') {
return '';
}
const currentIntegration = integrations.find((p) => p.id === current)!;
return `${currentIntegration.name} (${capitalize(
currentIntegration.identifier.split('-').shift()
)})`;
}, [current]);
const changeCustomer = useCallback(
(customer: string) => {
const neededIntegrations = integrations.filter(
(p) => p?.customer?.id === customer
);
setSelectedIntegrations(
neededIntegrations.map((p) => ({
settings: {},
selectedIntegrations: p,
}))
);
},
[integrations]
);
const askClose = useCallback(async () => {
if (!activateExitButton || dummy) {
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();
}
}, [activateExitButton, dummy]);
const deletePost = useCallback(async () => {
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;
}, [existingData, mutate, modal]);
const schedule = useCallback(
(type: 'draft' | 'now' | 'schedule') => async () => {
setLoading(true);
const checkAllValid = await ref.current.checkAllValid();
if (type !== 'draft') {
const notEnoughChars = checkAllValid.filter((p: any) => {
return p.values.some((a: any) => {
return (
countCharacters(
stripHtmlValidation('normal', a.content, true),
p?.integration?.identifier || ''
) === 0 && a.media?.length === 0
);
});
});
for (const item of notEnoughChars) {
toaster.show(
'' +
item.integration.name +
' Your post should have at least one character or one image.',
'warning'
);
setLoading(false);
item.preview();
return;
}
for (const item of checkAllValid) {
if (item.valid === false) {
toaster.show('Please fix your settings', 'warning');
item.fix();
setLoading(false);
setShowSettings(true);
return;
}
if (item.errors !== true) {
toaster.show(
`${capitalize(item.integration.identifier.split('-')[0])} (${
item.integration.name
}): ${item.errors}`,
'warning'
);
item.preview();
setLoading(false);
setShowSettings(true);
return;
}
}
const sliceNeeded = checkAllValid.filter((p: any) => {
return p.values.some((a: any) => {
const strip = stripHtmlValidation('normal', a.content, true);
const weightedLength = countCharacters(
strip,
p?.integration?.identifier || ''
);
const totalCharacters =
weightedLength > strip.length ? weightedLength : strip.length;
return totalCharacters > (p.maximumCharacters || 1000000);
});
});
for (const item of sliceNeeded) {
toaster.show(
`${item?.integration?.name} (${item?.integration?.identifier}) post is too long, please fix it`,
'warning'
);
item.preview();
setLoading(false);
return;
}
}
const shortLinkUrl = dummy
? { ask: false }
: await (
await fetch('/posts/should-shortlink', {
method: 'POST',
body: JSON.stringify({
messages: checkAllValid.flatMap((p: any) =>
p.values.flatMap((a: any) => a.content)
),
}),
})
).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!'
);
const group = existingData.group || makeId(10);
const data = {
type,
...(repeater ? { inter: repeater } : {}),
tags,
shortLink,
date: date.utc().format('YYYY-MM-DDTHH:mm:ss'),
posts: checkAllValid.map((post: any) => ({
integration: {
id: post.integration.id,
},
group,
settings: { ...(post.settings || {}) },
value: post.values.map((value: any) => ({
...(value.id ? { id: value.id } : {}),
content: value.content,
image:
(value?.media || []).map(
({ id, path, alt, thumbnail, thumbnailTimestamp }: any) => ({
id,
path,
alt,
thumbnail,
thumbnailTimestamp,
})
) || [],
})),
})),
};
if (dummy) {
modal.openModal({
title: '',
children: <DummyCodeComponent code={data} />,
classNames: {
modal: 'w-[100%] bg-transparent text-textColor',
},
size: '100%',
withCloseButton: false,
closeOnEscape: true,
closeOnClickOutside: true,
});
setLoading(false);
}
if (!dummy) {
addEditSets
? addEditSets(data)
: await fetch('/posts', {
method: 'POST',
body: JSON.stringify(data),
});
if (!addEditSets) {
mutate();
toaster.show(
!existingData.integration
? 'Added successfully'
: 'Updated successfully'
);
}
if (customClose) {
setTimeout(() => {
customClose();
}, 2000);
}
if (!addEditSets) {
modal.closeAll();
}
}
},
[ref, repeater, tags, date, addEditSets, dummy]
);
return (
<div className="w-full h-full flex-1 p-[40px] flex relative">
<div className="flex flex-1 bg-newBgColorInner rounded-[20px] flex-col">
<div className="flex-1 flex">
<div className="flex flex-col flex-1 border-r border-newBorder">
<div className="bg-newBgColor h-[65px] rounded-tl-[20px] flex items-center px-[20px] text-[20px] font-[600]">
Create Post
</div>
<div className="flex-1 flex flex-col gap-[16px]">
<div className="flex-1 relative">
<div
id="social-content"
className="gap-[32px] flex flex-col pr-[8px] pt-[20px] pl-[20px] absolute top-0 left-0 w-full h-full overflow-x-hidden overflow-y-scroll scrollbar scrollbar-thumb-newColColor scrollbar-track-newBgColorInner"
>
<div className="flex w-full">
<div className="flex flex-1">
<PicksSocialsComponent toolTip={true} />
</div>
<div>
{!dummy && (
<SelectCustomer
onChange={changeCustomer}
integrations={integrations}
/>
)}
</div>
</div>
<div className="flex flex-1 gap-[6px] flex-col">
<div>{!existingData.integration && <SelectCurrent />}</div>
<div className="flex-1 flex">
{!hide && <EditorWrapper totalPosts={1} value="" />}
</div>
<div
id="social-empty"
className={clsx(
'pb-[16px]',
current !== 'global' && 'hidden'
)}
/>
</div>
</div>
</div>
<div
id="wrapper-settings"
className={clsx(
'pb-[20px] px-[20px] select-none',
current === 'global' && 'hidden'
)}
>
<div className="bg-newSettings flex flex-col rounded-[12px] gap-[12px]">
<div
onClick={() => setShowSettings(!showSettings)}
className={clsx(
'bg-[#612BD3] rounded-[12px] flex items-center gap-[8px] cursor-pointer p-[12px]',
showSettings ? '!rounded-b-none' : ''
)}
>
<div className="flex">
<SettingsIcon className="text-white" />
</div>
<div className="flex-1 text-[14px] font-[600] text-white">
{currentIntegrationText} Settings
</div>
<div>
<ChevronDownIcon
rotated={showSettings}
className="text-white"
/>
</div>
</div>
<div
id="social-settings"
className={clsx(
!showSettings && 'hidden',
'px-[12px] pb-[12px] text-[14px] text-textColor font-[500] max-h-[300px] overflow-x-hidden overflow-y-auto scrollbar scrollbar-thumb-newBgColorInner scrollbar-track-newColColor'
)}
/>
<style>
{`#social-settings [data-id="${current}"] {display: block !important;}`}
</style>
</div>
</div>
</div>
</div>
<div className="w-[580px] flex flex-col">
<div className="bg-newBgColor h-[65px] rounded-tr-[20px] flex items-center px-[20px] text-[20px] font-[600]">
<div className="flex-1">Post Preview</div>
<div className="cursor-pointer">
<CloseIcon onClick={askClose} className="text-[#A3A3A3]" />
</div>
</div>
<div className="flex-1 relative">
<Scrollable
scrollClasses="!pr-[20px]"
className="absolute top-0 p-[20px] pr-[8px] left-0 w-full h-full overflow-x-hidden overflow-y-scroll scrollbar scrollbar-thumb-newColColor scrollbar-track-newBgColorInner"
>
<ShowAllProviders ref={ref} />
</Scrollable>
</div>
</div>
</div>
<div className="select-none h-[84px] py-[20px] border-t border-newBorder flex items-center">
<div className="flex-1 flex pl-[20px] gap-[8px]">
{!dummy && (
<TagsComponent
name="tags"
label={t('tags', 'Tags')}
initial={tags}
onChange={(e) => {
setTags(e.target.value);
}}
/>
)}
{!dummy && (
<RepeatComponent repeat={repeater} onChange={setRepeater} />
)}
</div>
<div className="pr-[20px] flex items-center justify-end gap-[8px]">
{existingData?.integration && (
<button
onClick={deletePost}
className="cursor-pointer flex text-[#FF3F3F] gap-[8px] items-center text-[15px] font-[600]"
>
<div>
<TrashIcon />
</div>
<div>Delete Post</div>
</button>
)}
<DatePicker onChange={setDate} date={date} />
{!addEditSets && (
<button
disabled={
selectedIntegrations.length === 0 || loading || locked
}
onClick={schedule('draft')}
className="cursor-pointer disabled:cursor-not-allowed px-[20px] h-[44px] bg-btnSimple justify-center items-center flex rounded-[8px] text-[15px] font-[600]"
>
Save as Draft
</button>
)}
{addEditSets && (
<button
className="text-white text-[15px] font-[600] min-w-[180px] btnSub disabled:cursor-not-allowed disabled:opacity-80 outline-none gap-[8px] flex justify-center items-center h-[44px] rounded-[8px] bg-[#612BD3] pl-[20px] pr-[16px]"
disabled={
selectedIntegrations.length === 0 || loading || locked
}
onClick={schedule('draft')}
>
Save Set
</button>
)}
{!addEditSets && (
<div className="group cursor-pointer relative">
<button
disabled={
selectedIntegrations.length === 0 || loading || locked
}
onClick={schedule('schedule')}
className="text-white min-w-[180px] btnSub disabled:cursor-not-allowed disabled:opacity-80 outline-none gap-[8px] flex justify-center items-center h-[44px] rounded-[8px] bg-[#612BD3] pl-[20px] pr-[16px]"
>
<div className="text-[15px] font-[600]">
{selectedIntegrations.length === 0
? 'Check the circles above'
: dummy
? 'Create output'
: !existingData?.integration
? t('add_to_calendar', 'Add to calendar')
: existingData?.posts?.[0]?.state === 'DRAFT'
? t('schedule', 'Schedule')
: t('update', 'Update')}
</div>
<div className="flex justify-center items-center h-[20px] w-[20px] pt-[4px] arrow-change">
<DropdownArrowSmallIcon className="group-hover:rotate-180 text-white" />
</div>
</button>
<button
onClick={schedule('now')}
disabled={
selectedIntegrations.length === 0 || loading || locked
}
className="rounded-[8px] z-[300] disabled:cursor-not-allowed disabled:opacity-80 hidden group-hover:flex absolute bottom-[100%] -left-[12px] p-[12px] w-[206px] bg-newBgColorInner"
>
<div className="text-white rounded-[8px] bg-[#D82D7E] h-[44px] w-full flex justify-center items-center post-now">
Post Now
</div>
</button>
</div>
)}
</div>
</div>
</div>
<CopilotPopup
hitEscapeToClose={false}
clickOutsideToClose={true}
instructions={`
You are an assistant that help the user to schedule their social media posts,
Here are the things you can do:
- Add a new comment / post to the list of posts
- Delete a comment / post from the list of posts
- Add content to the comment / post
- Activate or deactivate the comment / post
Post content can be added using the addPostContentFor{num} function.
After using the addPostFor{num} it will create a new addPostContentFor{num+ 1} function.
`}
labels={{
title: 'Your Assistant',
initial: 'Hi! I can help you to refine your social media posts.',
}}
/>
</div>
);
};
const Scrollable: FC<{
className: string;
scrollClasses: string;
children: ReactNode;
}> = ({ className, scrollClasses, children }) => {
const ref = useRef();
const hasScroll = useHasScroll(ref);
return (
<div className={clsx(className, hasScroll && scrollClasses)} ref={ref}>
{children}
</div>
);
};