diff --git a/apps/frontend/src/app/(site)/preview/[id]/page.tsx b/apps/frontend/src/app/(site)/preview/[id]/page.tsx new file mode 100644 index 000000000..1d40ebbba --- /dev/null +++ b/apps/frontend/src/app/(site)/preview/[id]/page.tsx @@ -0,0 +1,16 @@ +import { Preview } from "@gitroom/frontend/components/preview/preview"; +import { isGeneralServerSide } from "@gitroom/helpers/utils/is.general.server.side"; +import { Metadata } from "next"; + +export const dynamic = 'force-dynamic'; + +export const metadata: Metadata = { + title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Preview`, + description: 'Make a preview link for your posts.', +} + +export default async function Index({ params }: { params: { id: string } }) { + return ( + + ); +} diff --git a/apps/frontend/src/components/launches/add.edit.model.tsx b/apps/frontend/src/components/launches/add.edit.model.tsx index d3ca5897a..b8a0a3456 100644 --- a/apps/frontend/src/components/launches/add.edit.model.tsx +++ b/apps/frontend/src/components/launches/add.edit.model.tsx @@ -758,7 +758,7 @@ export const AddEditModal: FC<{ } >
-
+
{!canSendForPublication ? 'Not matching order' : postFor @@ -773,7 +773,7 @@ export const AddEditModal: FC<{ : 'Update'}
{!postFor && ( -
+
+ +
diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 2ba71d420..5053058be 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -142,14 +142,14 @@ export const WeekView = () => { const { currentYear, currentWeek } = useCalendar(); return ( -
+
-
+
{days.map((day, index) => (
{day}
@@ -217,13 +217,13 @@ export const MonthView = () => { }, [currentYear, currentMonth]); return ( -
-
+
+
{days.map((day) => (
{day}
@@ -245,7 +245,7 @@ export const MonthView = () => { ); }; -export const Calendar = () => { +export const Calendar = () => { const { display } = useCalendar(); return ( diff --git a/apps/frontend/src/components/launches/general.preview.component.tsx b/apps/frontend/src/components/launches/general.preview.component.tsx index 52a348d1c..dbcb4eed9 100644 --- a/apps/frontend/src/components/launches/general.preview.component.tsx +++ b/apps/frontend/src/components/launches/general.preview.component.tsx @@ -9,6 +9,7 @@ import interClass from '@gitroom/react/helpers/inter.font'; export const GeneralPreviewComponent: FC<{maximumCharacters?: number}> = (props) => { const { value: topValue, integration } = useIntegration(); + const mediaDir = useMediaDirectory(); const newValues = useFormatting(topValue, { removeMarkdown: true, @@ -21,7 +22,7 @@ export const GeneralPreviewComponent: FC<{maximumCharacters?: number}> = (props) return (
-
+
{newValues.map((value, index) => (
+
void }> = (props) => { @@ -85,529 +79,447 @@ export const withProvider = function ( value: Array>, settings: T ) => Promise, - maximumCharacters?: number | ((settings: any) => number) + maximumCharacters?: number ) { - return memo( - (props: { - identifier: string; - id: string; - value: Array<{ - content: string; + return (props: { + identifier: string; + id: string; + value: Array<{ + content: string; + id?: string; + image?: Array<{ path: string; id: string }>; + }>; + hideMenu?: boolean; + show: boolean; + }) => { + const toast = useToaster(); + const existingData = useExistingData(); + const { integration, date } = useIntegration(); + const [showLinkedinPopUp, setShowLinkedinPopUp] = useState(false); + + useCopilotReadable({ + description: + integration?.type === 'social' + ? 'force content always in MD format' + : 'force content always to be fit to social media', + value: '', + }); + const [editInPlace, setEditInPlace] = useState(!!existingData.integration); + const [InPlaceValue, setInPlaceValue] = useState< + Array<{ id?: string; - image?: Array<{ path: string; id: string }>; - }>; - hideMenu?: boolean; - show: boolean; - }) => { - const existingData = useExistingData(); - const { allIntegrations, integration, date } = useIntegration(); - const [showLinkedinPopUp, setShowLinkedinPopUp] = useState(false); - const [uploading, setUploading] = useState(false); - const fetch = useFetch(); + content: string; + image?: Array<{ id: string; path: string }>; + }> + >( + // @ts-ignore + existingData.integration + ? existingData.posts.map((p) => ({ + id: p.id, + content: p.content, + image: p.image, + })) + : [{ content: '' }] + ); - useCopilotReadable({ - description: - integration?.type === 'social' - ? 'force content always in MD format' - : 'force content always to be fit to social media', - value: '', - }); - const [editInPlace, setEditInPlace] = useState( - !!existingData.integration - ); - const [InPlaceValue, setInPlaceValue] = useState< - Array<{ - id?: string; - content: string; - image?: Array<{ id: string; path: string }>; - }> - >( - // @ts-ignore - existingData.integration - ? existingData.posts.map((p) => ({ - id: p.id, - content: p.content, - image: p.image, - })) - : [{ content: '' }] - ); - const [showTab, setShowTab] = useState(0); + const [showTab, setShowTab] = useState(0); - const Component = useMemo(() => { - return SettingsComponent ? SettingsComponent : () => <>; - }, [SettingsComponent]); + const Component = useMemo(() => { + return SettingsComponent ? SettingsComponent : () => <>; + }, [SettingsComponent]); - // in case there is an error on submit, we change to the settings tab for the specific provider - useMoveToIntegrationListener( - [props.id], - true, - ({ identifier, toPreview }) => { - if (identifier === props.id) { - setShowTab(toPreview ? 1 : 2); - } + // in case there is an error on submit, we change to the settings tab for the specific provider + useMoveToIntegrationListener( + [props.id], + true, + ({ identifier, toPreview }) => { + if (identifier === props.id) { + setShowTab(toPreview ? 1 : 2); } - ); + } + ); - // this is a smart function, it updates the global value without updating the states (too heavy) and set the settings validation - const form = useValues( - existingData.settings, - props.id, - props.identifier, - editInPlace ? InPlaceValue : props.value, - dto, - checkValidity, - !maximumCharacters - ? undefined - : typeof maximumCharacters === 'number' - ? maximumCharacters - : maximumCharacters( - JSON.parse(integration?.additionalSettings || '[]') - ) - ); + // this is a smart function, it updates the global value without updating the states (too heavy) and set the settings validation + const form = useValues( + existingData.settings, + props.id, + props.identifier, + editInPlace ? InPlaceValue : props.value, + dto, + checkValidity, + maximumCharacters + ); + + // change editor value + const changeValue = useCallback( + (index: number) => (newValue: string) => { + return setInPlaceValue((prev) => { + prev[index].content = newValue; + return [...prev]; + }); + }, + [] + ); - // change editor value - const changeValue = useCallback( - (index: number) => (newValue: string) => { + const changeImage = useCallback( + (index: number) => + (newValue: { + target: { name: string; value?: Array<{ id: string; path: string }> }; + }) => { return setInPlaceValue((prev) => { - prev[index].content = newValue; + prev[index].image = newValue.target.value; return [...prev]; }); }, - [InPlaceValue] - ); + [] + ); - const changeImage = useCallback( - (index: number) => - (newValue: { - target: { - name: string; - value?: Array<{ id: string; path: string }>; - }; - }) => { - return setInPlaceValue((prev) => { - prev[index].image = newValue.target.value; - return [...prev]; - }); - }, - [InPlaceValue] - ); + // add another local editor + const addValue = useCallback( + (index: number) => () => { + setInPlaceValue((prev) => { + return prev.reduce((acc, p, i) => { + acc.push(p); + if (i === index) { + acc.push({ content: '' }); + } - // add another local editor - const addValue = useCallback( - (index: number) => () => { - setInPlaceValue((prev) => { - return prev.reduce((acc, p, i) => { - acc.push(p); - if (i === index) { - acc.push({ content: '' }); - } + return acc; + }, [] as Array<{ content: string }>); + }); + }, + [] + ); - return acc; - }, [] as Array<{ content: string }>); + const changePosition = useCallback( + (index: number) => (type: 'up' | 'down') => { + if (type === 'up' && index !== 0) { + setInPlaceValue((prev) => { + return arrayMoveImmutable(prev, index, index - 1); }); - }, - [] - ); - - const changePosition = useCallback( - (index: number) => (type: 'up' | 'down') => { - if (type === 'up' && index !== 0) { - setInPlaceValue((prev) => { - return arrayMoveImmutable(prev, index, index - 1); - }); - } else if (type === 'down') { - setInPlaceValue((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; - } + } else if (type === 'down') { setInPlaceValue((prev) => { - prev.splice(index, 1); - return [...prev]; + return arrayMoveImmutable(prev, index, index + 1); }); - }, - [InPlaceValue] - ); + } + }, + [] + ); - // This is a function if we want to switch from the global editor to edit in place - const changeToEditor = useCallback(async () => { + // Delete post + const deletePost = useCallback( + (index: number) => async () => { if ( !(await deleteDialog( - !editInPlace - ? 'Are you sure you want to edit only this?' - : 'Are you sure you want to revert it back to global editing?', - 'Yes, edit in place!' + 'Are you sure you want to delete this post?', + 'Yes, delete it!' )) ) { - return false; + return; } + setInPlaceValue((prev) => { + prev.splice(index, 1); + return [...prev]; + }); + }, + [InPlaceValue] + ); - setEditInPlace(!editInPlace); - setInPlaceValue( - editInPlace - ? [{ content: '' }] - : props.value.map((p) => ({ - id: p.id, - content: p.content, - image: p.image, - })) - ); - }, [props.value, editInPlace]); - - useCopilotAction({ - name: editInPlace - ? 'switchToGlobalEdit' - : `editInPlace_${integration?.identifier}`, - description: editInPlace - ? 'Switch to global editing' - : `Edit only ${integration?.identifier} this, if you want a different identifier, you have to use setSelectedIntegration first`, - handler: async () => { - await changeToEditor(); - }, - }); - - const tagPersonOrCompany = useCallback( - (integration: string, editor: (value: string) => void) => () => { - setShowLinkedinPopUp( - { - editor(tag); - }} - id={integration} - onClose={() => setShowLinkedinPopUp(false)} - /> - ); - }, - [] - ); + // Share Post + const handleShare = async () => { + if (!existingData.posts.length) { + return toast.show('No posts available to share', 'warning'); + } - const uppy = useUppyUploader({ - onUploadSuccess: () => { - /**empty**/ - }, - allowedFileTypes: 'image/*,video/mp4', - }); + const postId = existingData.posts[0].id; - 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 previewPath = new URL( + `/preview/${postId}`, + window.location.origin + ).toString(); - const files: File[] = []; + try { + if (!navigator.clipboard) { + throw new Error('Clipboard API not available'); + } + await navigator.clipboard.writeText(previewPath); + return toast.show('Link copied to clipboard.', 'success'); + } catch (err) { + if (err instanceof Error) + toast.show(`Failed to copy the link. ${err.message}`, 'warning'); + } + }; - // @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; - } + // This is a function if we want to switch from the global editor to edit in place + const changeToEditor = useCallback(async () => { + if ( + !(await deleteDialog( + !editInPlace + ? 'Are you sure you want to edit only this?' + : 'Are you sure you want to revert it back to global editing?', + 'Yes, edit in place!' + )) + ) { + return false; + } - 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] + setEditInPlace(!editInPlace); + setInPlaceValue( + editInPlace + ? [{ content: '' }] + : props.value.map((p) => ({ + id: p.id, + content: p.content, + image: p.image, + })) ); + }, [props.value, editInPlace]); - const getInternalPlugs = useCallback(async () => { - return ( - await fetch(`/integrations/${props.identifier}/internal-plugs`) - ).json(); - }, [props.identifier]); + useCopilotAction({ + name: editInPlace + ? 'switchToGlobalEdit' + : `editInPlace_${integration?.identifier}`, + description: editInPlace + ? 'Switch to global editing' + : `Edit only ${integration?.identifier} this, if you want a different identifier, you have to use setSelectedIntegration first`, + handler: async () => { + await changeToEditor(); + }, + }); - const { data } = useSWR(`internal-${props.identifier}`, getInternalPlugs); + const tagPersonOrCompany = useCallback( + (integration: string, editor: (value: string) => void) => () => { + setShowLinkedinPopUp( + { + editor(tag); + }} + id={integration} + onClose={() => setShowLinkedinPopUp(false)} + /> + ); + }, + [] + ); - // this is a trick to prevent the data from being deleted, yet we don't render the elements - if (!props.show) { - return null; - } + // this is a trick to prevent the data from being deleted, yet we don't render the elements + if (!props.show) { + return null; + } - return ( - - setShowTab(0)} /> - {showLinkedinPopUp ? showLinkedinPopUp : null} -
- {!props.hideMenu && ( -
-
+ return ( + + setShowTab(0)} /> + {showLinkedinPopUp ? showLinkedinPopUp : null} +
+ {!props.hideMenu && ( +
+
+ +
+ {!!SettingsComponent && ( +
- {(!!SettingsComponent || !!data?.internalPlugs?.length) && ( -
- -
- )} - {!existingData.integration && ( -
- -
- )} + )} +
+
- )} - {editInPlace && - createPortal( - - {uploading && ( -
- +
+ )} + {editInPlace && + createPortal( + +
+ {!existingData?.integration && ( +
+ You are now editing only {integration?.name} ( + {capitalize(integration?.identifier.replace('-', ' '))})
)} -
- {!existingData?.integration && ( -
- You are now editing only {integration?.name} ( - {capitalize(integration?.identifier.replace('-', ' '))}) -
- )} - {InPlaceValue.map((val, index) => ( - -
-
-
- {(integration?.identifier === 'linkedin' || - integration?.identifier === - 'linkedin-page') && ( - - )} - ( + +
+
+
+ {integration?.identifier === 'linkedin' && ( + + )} + 1 ? 200 : 250} + value={val.content} + commands={[ + // ...commands + // .getCommands() + // .filter((f) => f.name !== 'image'), + // newImage, + postSelector(date), + ...linkedinCompany( + integration?.identifier!, + integration?.id! + ), + ]} + preview="edit" + // @ts-ignore + onChange={changeValue(index)} + /> + {(!val.content || val.content.length < 6) && ( +
+ The post should be at least 6 characters long +
+ )} +
+
+ - - {(!val.content || val.content.length < 6) && ( -
- The post should be at least 6 characters long -
- )} -
-
- -
-
- {InPlaceValue.length > 1 && ( -
-
- - - -
-
- Delete Post -
+
+
+ {InPlaceValue.length > 1 && ( +
+
+ + + +
+
+ Delete Post
- )} -
+
+ )}
-
- -
+
+
+
-
- -
- - ))} -
- , - document.querySelector('#renderEditor')! - )} - {(showTab === 0 || showTab === 2) && ( -
- - {!!data?.internalPlugs?.length && ( - - )} -
+
+
+ + +
+ + ))} +
+ , + document.querySelector('#renderEditor')! )} - {showTab === 0 && ( -
- - {(editInPlace ? InPlaceValue : props.value) - .map((p) => p.content) - .join('').length ? ( - CustomPreviewComponent ? ( - - ) : ( - - ) + {(showTab === 0 || showTab === 2) && ( +
+ +
+ )} + {showTab === 0 && ( +
+ + {(editInPlace ? InPlaceValue : props.value) + .map((p) => p.content) + .join('').length ? ( + CustomPreviewComponent ? ( + ) : ( - <>No Content Yet - )} - -
- )} -
- - ); - } - ); + + ) + ) : ( + <>No Content Yet + )} + +
+ )} +
+ + ); + }; }; diff --git a/apps/frontend/src/components/preview/preview.tsx b/apps/frontend/src/components/preview/preview.tsx new file mode 100644 index 000000000..444ee6021 --- /dev/null +++ b/apps/frontend/src/components/preview/preview.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { GeneralPreviewComponent } from '@gitroom/frontend/components/launches/general.preview.component'; +import { IntegrationContext } from '@gitroom/frontend/components/launches/helpers/use.integration'; +import dayjs from 'dayjs'; +import { useCallback } from 'react'; +import useSWR from 'swr'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { LoadingComponent } from '../layout/loading'; + +interface PreviewProps { + id: string; +} + +export const Preview = ({ id }: PreviewProps) => { + const fetch = useFetch(); + + const getPostsMarketplace = useCallback(async () => { + return (await fetch(`/posts/${id}`)).json(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const { data, isLoading, error } = useSWR( + `/posts/${id}`, + getPostsMarketplace + ); + + if (isLoading) return ; + + if (!data?.posts || error) + return ( +
+

+ {!data?.posts ? 'No post founded.' : 'Oops! Something went wrong.'}{' '} +

+
+ ); + + const post = data?.posts?.[0]; + if (!post) return null; + + return ( + +
+ +
+
+ ); +};