feat: providers

This commit is contained in:
Nevo David 2024-05-24 12:07:52 +07:00
parent 818d172d88
commit e4eff984f8
35 changed files with 1317 additions and 216 deletions

View File

@ -44,9 +44,11 @@ export class IntegrationsController {
).map((p) => ({
name: p.name,
id: p.id,
internalId: p.internalId,
disabled: p.disabled,
picture: p.picture,
identifier: p.providerIdentifier,
inBetweenSteps: p.inBetweenSteps,
type: p.type,
})),
};
@ -221,7 +223,8 @@ export class IntegrationsController {
accessToken,
refreshToken,
expiresIn,
username
username,
integrationProvider.isBetweenSteps
);
}
@ -233,6 +236,24 @@ export class IntegrationsController {
return this._integrationService.disableChannel(org.id, id);
}
@Post('/instagram/:id')
async saveInstagram(
@Param('id') id: string,
@Body() body: { pageId: string, id: string },
@GetOrgFromRequest() org: Organization
) {
return this._integrationService.saveInstagram(org.id, id, body);
}
@Post('/facebook/:id')
async saveFacebook(
@Param('id') id: string,
@Body() body: { page: string },
@GetOrgFromRequest() org: Organization
) {
return this._integrationService.saveFacebook(org.id, id, body.page);
}
@Post('/enable')
enableChannel(
@GetOrgFromRequest() org: Organization,

View File

@ -29,7 +29,7 @@ export class MediaController {
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }),
new FileTypeValidator({ fileType: 'image/*' }),
new FileTypeValidator({ fileType: /^(image\/.+|video\/mp4)$/ }),
],
})
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,21 +1,33 @@
export const dynamic = 'force-dynamic';
import {internalFetch} from "@gitroom/helpers/utils/internal.fetch";
import {redirect} from "next/navigation";
import { internalFetch } from '@gitroom/helpers/utils/internal.fetch';
import { redirect } from 'next/navigation';
export default async function Page({params: {provider}, searchParams}: {params: {provider: string}, searchParams: any}) {
if (provider === 'x') {
searchParams = {
...searchParams,
state: searchParams.oauth_token || '',
code: searchParams.oauth_verifier || ''
};
}
export default async function Page({
params: { provider },
searchParams,
}: {
params: { provider: string };
searchParams: any;
}) {
if (provider === 'x') {
searchParams = {
...searchParams,
state: searchParams.oauth_token || '',
code: searchParams.oauth_verifier || '',
};
}
const { id, inBetweenSteps } = await (
await internalFetch(`/integrations/social/${provider}/connect`, {
method: 'POST',
body: JSON.stringify(searchParams)
});
method: 'POST',
body: JSON.stringify(searchParams),
})
).json();
return redirect(`/launches?added=${provider}`);
}
if (inBetweenSteps) {
return redirect(`/launches?added=${provider}&continue=${id}`);
}
return redirect(`/launches?added=${provider}`);
}

View File

@ -10,6 +10,7 @@ import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { LoginUserDto } from '@gitroom/nestjs-libraries/dtos/auth/login.user.dto';
import { GithubProvider } from '@gitroom/frontend/app/auth/providers/github.provider';
import interClass from '@gitroom/react/helpers/inter.font';
import { isGeneral } from '@gitroom/react/helpers/is.general';
type Inputs = {
email: string;
@ -58,13 +59,19 @@ export function Login() {
Sign In
</h1>
</div>
<GithubProvider />
<div className="h-[20px] mb-[24px] mt-[24px] relative">
<div className="absolute w-full h-[1px] bg-[#28344F] top-[50%] -translate-y-[50%]" />
<div className={`absolute z-[1] ${interClass} justify-center items-center w-full left-0 top-0 flex`}>
<div className="bg-[#0a0a0a] px-[16px]">OR</div>
</div>
</div>
{!isGeneral() && (
<>
<GithubProvider />
<div className="h-[20px] mb-[24px] mt-[24px] relative">
<div className="absolute w-full h-[1px] bg-[#28344F] top-[50%] -translate-y-[50%]" />
<div
className={`absolute z-[1] ${interClass} justify-center items-center w-full left-0 top-0 flex`}
>
<div className="bg-[#0a0a0a] px-[16px]">OR</div>
</div>
</div>
</>
)}
<div className="text-white">
<Input
label="Email"

View File

@ -12,6 +12,7 @@ import { GithubProvider } from '@gitroom/frontend/app/auth/providers/github.prov
import { useSearchParams } from 'next/navigation';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
import interClass from '@gitroom/react/helpers/inter.font';
import { isGeneral } from '@gitroom/react/helpers/is.general';
type Inputs = {
email: string;
@ -56,7 +57,9 @@ export function Register() {
return <LoadingComponent />;
}
return <RegisterAfter token={code} provider={provider?.toUpperCase() || 'LOCAL'} />;
return (
<RegisterAfter token={code} provider={provider?.toUpperCase() || 'LOCAL'} />
);
}
export function RegisterAfter({
@ -110,11 +113,13 @@ export function RegisterAfter({
Sign Up
</h1>
</div>
{!isAfterProvider && <GithubProvider />}
{!isAfterProvider && (
{!isAfterProvider && !isGeneral() && <GithubProvider />}
{!isAfterProvider && !isGeneral() && (
<div className="h-[20px] mb-[24px] mt-[24px] relative">
<div className="absolute w-full h-[1px] bg-[#28344F] top-[50%] -translate-y-[50%]" />
<div className={`absolute z-[1] ${interClass} justify-center items-center w-full left-0 top-0 flex`}>
<div
className={`absolute z-[1] ${interClass} justify-center items-center w-full left-0 top-0 flex`}
>
<div className="bg-[#0a0a0a] px-[16px]">OR</div>
</div>
</div>

View File

@ -190,7 +190,7 @@ export const AddProviderComponent: FC<{
</svg>
</button>
<h2 className="pt-[16px] pb-[10px]">Social</h2>
<div className="flex flex-wrap gap-[10px]">
<div className="grid grid-cols-3 gap-[10px] justify-items-center justify-center">
{social.map((item) => (
<div
key={item.identifier}
@ -210,7 +210,7 @@ export const AddProviderComponent: FC<{
</div>
<div className="flex flex-col">
<h2 className="pb-[10px]">Articles</h2>
<div className="flex flex-wrap gap-[10px]">
<div className="grid grid-cols-3 gap-[10px]">
{article.map((item) => (
<div
key={item.identifier}

View File

@ -16,6 +16,7 @@ import useSWR, { useSWRConfig } from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { Post, Integration } from '@prisma/client';
import { useRouter, useSearchParams } from 'next/navigation';
import { isGeneral } from '@gitroom/react/helpers/is.general';
const CalendarContext = createContext({
currentWeek: dayjs().week(),
@ -31,6 +32,7 @@ const CalendarContext = createContext({
export interface Integrations {
name: string;
id: string;
inBetweenSteps: boolean;
identifier: string;
type: string;
picture: string;
@ -48,6 +50,9 @@ export const CalendarWeekProvider: FC<{
useEffect(() => {
(async () => {
if (isGeneral()) {
return [];
}
setTrendings(await (await fetch('/posts/predict-trending')).json());
})();
}, []);

View File

@ -138,7 +138,7 @@ export const PickPlatforms: FC<{
>
<div className="innerComponent">
<div className="flex">
{integrations.map((integration) =>
{integrations.filter(f => !f.inBetweenSteps).map((integration) =>
!props.singleSelect ? (
<div
key={integration.id}

View File

@ -14,9 +14,12 @@ import clsx from 'clsx';
import { useUser } from '../layout/user.context';
import { Menu } from '@gitroom/frontend/components/launches/menu/menu';
import { GeneratorComponent } from '@gitroom/frontend/components/launches/generator/generator';
import { useRouter } from 'next/navigation';
export const LaunchesComponent = () => {
const fetch = useFetch();
const router = useRouter();
const [reload, setReload] = useState(false);
const load = useCallback(async (path: string) => {
return (await (await fetch(path)).json()).integrations;
@ -57,6 +60,10 @@ export const LaunchesComponent = () => {
}
}, []);
const continueIntegration = useCallback((integration: any) => async () => {
router.push(`/launches?added=${integration.identifier}&continue=${integration.id}`);
}, []);
useEffect(() => {
if (typeof window !== 'undefined' && window.opener) {
window.close();
@ -89,6 +96,14 @@ export const LaunchesComponent = () => {
integration.disabled && 'opacity-50'
)}
>
{integration.inBetweenSteps && (
<div className="absolute left-0 top-0 w-[39px] h-[46px] cursor-pointer" onClick={continueIntegration(integration)}>
<div className="bg-red-500 w-[15px] h-[15px] rounded-full -left-[5px] -top-[5px] absolute z-[200] text-[10px] flex justify-center items-center">
!
</div>
<div className="bg-black/60 w-[39px] h-[46px] left-0 top-0 absolute rounded-full z-[199]" />
</div>
)}
<img
src={integration.picture}
className="rounded-full"
@ -114,7 +129,7 @@ export const LaunchesComponent = () => {
}
: {})}
className={clsx(
'flex-1',
'flex-1 whitespace-nowrap text-ellipsis overflow-hidden',
integration.disabled && 'opacity-50'
)}
>

View File

@ -0,0 +1,88 @@
import { FC, useCallback, useMemo, useState } from 'react';
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
import useSWR from 'swr';
import clsx from 'clsx';
import { Button } from '@gitroom/react/form/button';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
export const FacebookContinue: FC<{closeModal: () => void, existingId: string[]}> = (props) => {
const { closeModal, existingId } = props;
const call = useCustomProviderFunction();
const { integration } = useIntegration();
const [page, setSelectedPage] = useState<null | string>(null);
const fetch = useFetch();
const loadPages = useCallback(() => {
return call.get('pages');
}, []);
const setPage = useCallback(
(id: string) => () => {
setSelectedPage(id);
},
[]
);
const { data } = useSWR('load-pages', loadPages, {
refreshWhenHidden: false,
refreshWhenOffline: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnMount: true,
revalidateOnReconnect: false,
refreshInterval: 0,
});
const saveInstagram = useCallback(async () => {
await fetch(`/integrations/facebook/${integration?.id}`, {
method: 'POST',
body: JSON.stringify({ page }),
});
closeModal();
}, [integration, page]);
const filteredData = useMemo(() => {
return data?.filter((p: { id: string }) => !existingId.includes(p.id)) || [];
}, [data]);
return (
<div className="flex flex-col gap-[20px]">
<div>Select Page:</div>
<div className="grid grid-cols-3 justify-items-center select-none cursor-pointer">
{filteredData?.map(
(p: {
id: string;
username: string;
name: string;
picture: { data: { url: string } };
}) => (
<div
key={p.id}
className={clsx(
'flex flex-col w-full text-center gap-[10px] border border-input p-[10px] hover:bg-seventh',
page === p.id && 'bg-seventh'
)}
onClick={setPage(p.id)}
>
<div>
<img
className="w-full"
src={p.picture.data.url}
alt="profile"
/>
</div>
<div>{p.name}</div>
</div>
)
)}
</div>
<div>
<Button disabled={!page} onClick={saveInstagram}>
Save
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,94 @@
import { FC, useCallback, useMemo, useState } from 'react';
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
import useSWR from 'swr';
import clsx from 'clsx';
import { Button } from '@gitroom/react/form/button';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
export const InstagramContinue: FC<{
closeModal: () => void;
existingId: string[];
}> = (props) => {
const { closeModal, existingId } = props;
const call = useCustomProviderFunction();
const { integration } = useIntegration();
const [page, setSelectedPage] = useState<null | {id: string, pageId: string}>(null);
const fetch = useFetch();
const loadPages = useCallback(() => {
return call.get('pages');
}, []);
const setPage = useCallback(
(param: {id: string, pageId: string}) => () => {
setSelectedPage(param);
},
[]
);
const { data } = useSWR('load-pages', loadPages, {
refreshWhenHidden: false,
refreshWhenOffline: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnMount: true,
revalidateOnReconnect: false,
refreshInterval: 0,
});
const saveInstagram = useCallback(async () => {
await fetch(`/integrations/instagram/${integration?.id}`, {
method: 'POST',
body: JSON.stringify(page),
});
closeModal();
}, [integration, page]);
const filteredData = useMemo(() => {
return (
data?.filter((p: { id: string }) => !existingId.includes(p.id)) || []
);
}, [data]);
return (
<div className="flex flex-col gap-[20px]">
<div>Select Instagram Account:</div>
<div className="grid grid-cols-3 justify-items-center select-none cursor-pointer">
{filteredData?.map(
(p: {
id: string;
pageId: string;
username: string;
name: string;
picture: { data: { url: string } };
}) => (
<div
key={p.id}
className={clsx(
'flex flex-col w-full text-center gap-[10px] border border-input p-[10px] hover:bg-seventh',
page?.id === p.id && 'bg-seventh'
)}
onClick={setPage(p)}
>
<div>
<img
className="w-full"
src={p.picture.data.url}
alt="profile"
/>
</div>
<div>{p.name}</div>
</div>
)
)}
</div>
<div>
<Button disabled={!page} onClick={saveInstagram}>
Save
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,7 @@
import { InstagramContinue } from '@gitroom/frontend/components/launches/providers/continue-provider/instagram/instagram.continue';
import { FacebookContinue } from '@gitroom/frontend/components/launches/providers/continue-provider/facebook/facebook.continue';
export const continueProviderList = {
instagram: InstagramContinue,
facebook: FacebookContinue,
}

View File

@ -3,9 +3,13 @@ import { withProvider } from '@gitroom/frontend/components/launches/providers/hi
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory';
import {afterLinkedinCompanyPreventRemove, linkedinCompanyPreventRemove} from "@gitroom/helpers/utils/linkedin.company.prevent.remove";
import {
afterLinkedinCompanyPreventRemove,
linkedinCompanyPreventRemove,
} from '@gitroom/helpers/utils/linkedin.company.prevent.remove';
import { VideoOrImage } from '@gitroom/react/helpers/video.or.image';
const LinkedinPreview: FC = (props) => {
const FacebookPreview: FC = (props) => {
const { value: topValue, integration } = useIntegration();
const mediaDir = useMediaDirectory();
const newValues = useFormatting(topValue, {
@ -43,7 +47,10 @@ const LinkedinPreview: FC = (props) => {
</div>
</div>
<div>
<pre className="font-['helvetica'] text-[14px] font-[400] text-wrap" dangerouslySetInnerHTML={{__html: firstPost?.text}} />
<pre
className="font-['helvetica'] text-[14px] font-[400] text-wrap"
dangerouslySetInnerHTML={{ __html: firstPost?.text }}
/>
{!!firstPost?.images?.length && (
<div className="-ml-[16px] -mr-[40px] flex-1 h-[555px] flex overflow-hidden mt-[12px] gap-[2px]">
@ -54,10 +61,7 @@ const LinkedinPreview: FC = (props) => {
className="flex-1"
target="_blank"
>
<img
className="w-full h-full object-cover"
src={mediaDir.set(image.path)}
/>
<VideoOrImage autoplay={true} src={mediaDir.set(image.path)} />
</a>
))}
</div>
@ -89,10 +93,12 @@ const LinkedinPreview: FC = (props) => {
href={mediaDir.set(image.path)}
target="_blank"
>
<img
className="w-[120px] h-full object-cover"
src={mediaDir.set(image.path)}
/>
<div className="w-[120px] h-full">
<VideoOrImage
autoplay={true}
src={mediaDir.set(image.path)}
/>
</div>
</a>
))}
</div>
@ -104,4 +110,4 @@ const LinkedinPreview: FC = (props) => {
);
};
export default withProvider(null, LinkedinPreview);
export default withProvider(null, FacebookPreview);

View File

@ -3,9 +3,13 @@ import { withProvider } from '@gitroom/frontend/components/launches/providers/hi
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory';
import {afterLinkedinCompanyPreventRemove, linkedinCompanyPreventRemove} from "@gitroom/helpers/utils/linkedin.company.prevent.remove";
import {
afterLinkedinCompanyPreventRemove,
linkedinCompanyPreventRemove,
} from '@gitroom/helpers/utils/linkedin.company.prevent.remove';
import { VideoOrImage } from '@gitroom/react/helpers/video.or.image';
const FacebookPreview: FC = (props) => {
const InstagramPreview: FC = (props) => {
const { value: topValue, integration } = useIntegration();
const mediaDir = useMediaDirectory();
const newValues = useFormatting(topValue, {
@ -43,8 +47,6 @@ const FacebookPreview: FC = (props) => {
</div>
</div>
<div>
<pre className="font-['helvetica'] text-[14px] font-[400] text-wrap" dangerouslySetInnerHTML={{__html: firstPost?.text}} />
{!!firstPost?.images?.length && (
<div className="-ml-[16px] -mr-[40px] flex-1 h-[555px] flex overflow-hidden mt-[12px] gap-[2px]">
{firstPost.images.map((image, index) => (
@ -54,14 +56,15 @@ const FacebookPreview: FC = (props) => {
className="flex-1"
target="_blank"
>
<img
className="w-full h-full object-cover"
src={mediaDir.set(image.path)}
/>
<VideoOrImage autoplay={true} src={mediaDir.set(image.path)} />
</a>
))}
</div>
)}
<pre
className="font-['helvetica'] text-[14px] font-[400] text-wrap"
dangerouslySetInnerHTML={{ __html: firstPost?.text }}
/>
</div>
{morePosts.map((p, index) => (
<div className="flex gap-[8px]" key={index}>
@ -89,10 +92,12 @@ const FacebookPreview: FC = (props) => {
href={mediaDir.set(image.path)}
target="_blank"
>
<img
className="w-[120px] h-full object-cover"
src={mediaDir.set(image.path)}
/>
<div className="w-[120px] h-full">
<VideoOrImage
autoplay={true}
src={mediaDir.set(image.path)}
/>
</div>
</a>
))}
</div>
@ -104,4 +109,4 @@ const FacebookPreview: FC = (props) => {
);
};
export default withProvider(null, FacebookPreview);
export default withProvider(null, InstagramPreview);

View File

@ -3,7 +3,11 @@ import { withProvider } from '@gitroom/frontend/components/launches/providers/hi
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory';
import {afterLinkedinCompanyPreventRemove, linkedinCompanyPreventRemove} from "@gitroom/helpers/utils/linkedin.company.prevent.remove";
import {
afterLinkedinCompanyPreventRemove,
linkedinCompanyPreventRemove,
} from '@gitroom/helpers/utils/linkedin.company.prevent.remove';
import { VideoOrImage } from '@gitroom/react/helpers/video.or.image';
const LinkedinPreview: FC = (props) => {
const { value: topValue, integration } = useIntegration();
@ -43,7 +47,10 @@ const LinkedinPreview: FC = (props) => {
</div>
</div>
<div>
<pre className="font-['helvetica'] text-[14px] font-[400] text-wrap" dangerouslySetInnerHTML={{__html: firstPost?.text}} />
<pre
className="font-['helvetica'] text-[14px] font-[400] text-wrap"
dangerouslySetInnerHTML={{ __html: firstPost?.text }}
/>
{!!firstPost?.images?.length && (
<div className="-ml-[16px] -mr-[40px] flex-1 h-[555px] flex overflow-hidden mt-[12px] gap-[2px]">
@ -54,10 +61,7 @@ const LinkedinPreview: FC = (props) => {
className="flex-1"
target="_blank"
>
<img
className="w-full h-full object-cover"
src={mediaDir.set(image.path)}
/>
<VideoOrImage src={mediaDir.set(image.path)} autoplay={true} />
</a>
))}
</div>
@ -89,10 +93,12 @@ const LinkedinPreview: FC = (props) => {
href={mediaDir.set(image.path)}
target="_blank"
>
<img
className="w-[120px] h-full object-cover"
src={mediaDir.set(image.path)}
/>
<div className="w-[120px] h-full">
<VideoOrImage
autoplay={true}
src={mediaDir.set(image.path)}
/>
</div>
</a>
))}
</div>

View File

@ -6,6 +6,8 @@ import LinkedinProvider from "@gitroom/frontend/components/launches/providers/li
import RedditProvider from "@gitroom/frontend/components/launches/providers/reddit/reddit.provider";
import MediumProvider from "@gitroom/frontend/components/launches/providers/medium/medium.provider";
import HashnodeProvider from "@gitroom/frontend/components/launches/providers/hashnode/hashnode.provider";
import FacebookProvider from '@gitroom/frontend/components/launches/providers/facebook/facebook.provider';
import InstagramProvider from '@gitroom/frontend/components/launches/providers/instagram/instagram.provider';
export const Providers = [
{identifier: 'devto', component: DevtoProvider},
@ -14,6 +16,8 @@ export const Providers = [
{identifier: 'reddit', component: RedditProvider},
{identifier: 'medium', component: MediumProvider},
{identifier: 'hashnode', component: HashnodeProvider},
{identifier: 'facebook', component: FacebookProvider},
{identifier: 'instagram', component: InstagramProvider},
];

View File

@ -5,6 +5,7 @@ import clsx from 'clsx';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory';
import { VideoOrImage } from '@gitroom/react/helpers/video.or.image';
const chirp = localFont({
src: [
@ -82,11 +83,13 @@ const XPreview: FC = (props) => {
{!!value?.images?.length && (
<div className="w-full h-[270px] rounded-[16px] flex overflow-hidden mt-[12px]">
{value.images.map((image, index) => (
<a key={`image_${index}`} className="flex-1" href={mediaDir.set(image.path)} target="_blank">
<img
className="w-full h-full object-cover"
src={mediaDir.set(image.path)}
/>
<a
key={`image_${index}`}
className="flex-1"
href={mediaDir.set(image.path)}
target="_blank"
>
<VideoOrImage autoplay={true} src={mediaDir.set(image.path)} />
</a>
))}
</div>

View File

@ -0,0 +1,101 @@
import React, { FC, useCallback, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { continueProviderList } from '@gitroom/frontend/components/launches/providers/continue-provider/list';
import { IntegrationContext } from '@gitroom/frontend/components/launches/helpers/use.integration';
import dayjs from 'dayjs';
import useSWR, { useSWRConfig } from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
export const Null: FC<{ closeModal: () => void; existingId: string[] }> = () =>
null;
export const ContinueProvider: FC = () => {
const { mutate } = useSWRConfig();
const fetch = useFetch();
const searchParams = useSearchParams();
const added = searchParams.get('added');
const continueId = searchParams.get('continue');
const router = useRouter();
const load = useCallback(async (path: string) => {
const list = (await (await fetch(path)).json()).integrations;
return list;
}, []);
const { data: integrations } = useSWR('/integrations/list', load, {
fallbackData: [],
});
const closeModal = useCallback(() => {
mutate('/integrations/list');
const url = new URL(window.location.href);
url.searchParams.delete('added');
url.searchParams.delete('continue');
router.push(url.toString());
}, []);
const Provider = useMemo(() => {
if (!added) {
return Null;
}
return continueProviderList[added as keyof typeof continueProviderList];
}, [added]);
if (!added || !continueId || !integrations) {
return null;
}
return (
<div
className="fixed left-0 top-0 w-full h-full bg-black/40 z-[499]"
onClick={closeModal}
>
<div
className="w-[100%] max-w-[674px] absolute left-[50%] top-[65px] bg-[#0B0F1C] z-[500] -translate-x-[50%] text-white p-[16px] !pt-0 border border-[#172034] min-h-[300px]"
onClick={(e) => e.stopPropagation()}
>
<div className="w-full h-full relative">
<TopTitle title="Configure Provider" />
<button
onClick={closeModal}
className="outline-none absolute right-0 top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
<div className="pt-[16px]">
<IntegrationContext.Provider
value={{
date: dayjs(),
value: [],
integration: {
id: continueId,
type: '',
name: '',
picture: '',
inBetweenSteps: true,
identifier: added,
},
}}
>
<Provider closeModal={closeModal} existingId={integrations.map((p: any) => p.internalId)} />
</IntegrationContext.Provider>
</div>
</div>
</div>
</div>
);
};

View File

@ -24,6 +24,8 @@ import { ShowLinkedinCompany } from '@gitroom/frontend/components/launches/helpe
import { SettingsComponent } from '@gitroom/frontend/components/layout/settings.component';
import { Onboarding } from '@gitroom/frontend/components/onboarding/onboarding';
import { Support } from '@gitroom/frontend/components/layout/support';
import { ContinueProvider } from '@gitroom/frontend/components/layout/continue.provider';
import { isGeneral } from '@gitroom/react/helpers/is.general';
dayjs.extend(utc);
dayjs.extend(weekOfYear);
@ -54,13 +56,14 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => {
<ShowPostSelector />
<Onboarding />
<Support />
<ContinueProvider />
<div className="min-h-[100vh] w-full max-w-[1440px] mx-auto bg-primary px-[12px] text-white flex flex-col">
<div className="px-[23px] flex h-[80px] items-center justify-between z-[200] sticky top-0 bg-primary">
<Link href="/" className="text-2xl flex items-center gap-[10px]">
<div>
<Image src="/logo.svg" width={55} height={53} alt="Logo" />
</div>
<div className="mt-[12px]">Gitroom</div>
<div className="mt-[12px]">{isGeneral() ? 'Postiz' : 'Gitroom'}</div>
</Link>
{user?.orgId ? <TopMenu /> : <div />}
<div className="flex items-center gap-[8px]">

View File

@ -20,7 +20,7 @@ export const menuItems = [
]
: []),
{
name: 'Launches',
name: isGeneral() ? 'Calendar' : 'Launches',
icon: 'launches',
path: '/launches',
},

View File

@ -11,6 +11,7 @@ import EventEmitter from 'events';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import clsx from 'clsx';
import interClass from '@gitroom/react/helpers/inter.font';
import { VideoFrame } from '@gitroom/react/helpers/video.frame';
const showModalEmitter = new EventEmitter();
export const ShowMediaBoxModal: FC = () => {
@ -139,10 +140,12 @@ export const MediaBox: FC<{
<input
type="file"
className="absolute left-0 top-0 w-full h-full opacity-0"
accept="image/*"
accept="image/*,video/mp4"
onChange={uploadMedia}
/>
<button className={`cursor-pointer font-[500] flex justify-center items-center gap-[4px] text-[12px] rounded-[4px] w-[107px] h-[25px] bg-[#0b0f1c] text-white ${interClass} border-[2px] border-[#506490]`}>
<button
className={`cursor-pointer font-[500] flex justify-center items-center gap-[4px] text-[12px] rounded-[4px] w-[107px] h-[25px] bg-[#0b0f1c] text-white ${interClass} border-[2px] border-[#506490]`}
>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -181,7 +184,9 @@ export const MediaBox: FC<{
accept="image/*"
onChange={uploadMedia}
/>
<button className={`cursor-pointer font-[500] flex justify-center items-center gap-[4px] text-[12px] rounded-[4px] w-[107px] h-[25px] bg-[#0b0f1c] text-white ${interClass} border-[2px] border-[#506490]`}>
<button
className={`cursor-pointer font-[500] flex justify-center items-center gap-[4px] text-[12px] rounded-[4px] w-[107px] h-[25px] bg-[#0b0f1c] text-white ${interClass} border-[2px] border-[#506490]`}
>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -208,10 +213,14 @@ export const MediaBox: FC<{
className="w-[200px] h-[200px] border-tableBorder border-2 cursor-pointer"
onClick={setNewMedia(media)}
>
<img
className="w-full h-full object-cover"
src={mediaDirectory.set(media.path)}
/>
{media.path.indexOf('mp4') > -1 ? (
<VideoFrame url={mediaDirectory.set(media.path)} />
) : (
<img
className="w-full h-full object-cover"
src={mediaDirectory.set(media.path)}
/>
)}
</div>
))}
</div>
@ -295,11 +304,18 @@ export const MultiMediaComponent: FC<{
currentMedia.map((media, index) => (
<>
<div className="cursor-pointer w-[40px] h-[40px] border-2 border-tableBorder relative">
<img
className="w-full h-full object-cover"
src={mediaDirectory.set(media.path)}
<div
onClick={() => window.open(mediaDirectory.set(media.path))}
/>
>
{media.path.indexOf('mp4') > -1 ? (
<VideoFrame url={mediaDirectory.set(media.path)} />
) : (
<img
className="w-full h-full object-cover"
src={mediaDirectory.set(media.path)}
/>
)}
</div>
<div
onClick={clearMedia(index)}
className="rounded-full w-[15px] h-[15px] bg-red-800 text-white flex justify-center items-center absolute -right-[4px] -top-[4px]"

View File

@ -1,4 +1,11 @@
import React, { FC, useCallback, useMemo, useState } from 'react';
import React, {
FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { orderBy } from 'lodash';
@ -7,10 +14,13 @@ import clsx from 'clsx';
import Image from 'next/image';
import { Menu } from '@gitroom/frontend/components/launches/menu/menu';
import { ApiModal } from '@gitroom/frontend/components/launches/add.provider.component';
import { useRouter } from 'next/navigation';
export const ConnectChannels: FC = () => {
const fetch = useFetch();
const router = useRouter();
const [identifier, setIdentifier] = useState<any>(undefined);
const [popup, setPopups] = useState<undefined | string[]>(undefined);
const getIntegrations = useCallback(async () => {
return (await fetch('/integrations')).json();
@ -30,7 +40,9 @@ export const ConnectChannels: FC = () => {
);
const load = useCallback(async (path: string) => {
return (await (await fetch(path)).json()).integrations;
const list = (await (await fetch(path)).json()).integrations;
setPopups(list.map((p: any) => p.id));
return list;
}, []);
const { data: integrations, mutate } = useSWR('/integrations/list', load, {
@ -54,6 +66,21 @@ export const ConnectChannels: FC = () => {
);
}, [integrations]);
useEffect(() => {
if (sortedIntegrations.length === 0 || !popup) {
return;
}
const betweenSteps = sortedIntegrations.find((p) => p.inBetweenSteps);
if (betweenSteps && popup.indexOf(betweenSteps.id) === -1) {
const url = new URL(window.location.href);
url.searchParams.append('added', betweenSteps.identifier);
url.searchParams.append('continue', betweenSteps.id);
router.push(url.toString());
setPopups([...popup, betweenSteps.id]);
}
}, [sortedIntegrations, popup]);
const update = useCallback(async (shouldReload: boolean) => {
if (shouldReload) {
setReload(true);
@ -65,6 +92,16 @@ export const ConnectChannels: FC = () => {
}
}, []);
const continueIntegration = useCallback(
(integration: any) => async () => {
const url = new URL(window.location.href);
url.searchParams.append('added', integration.identifier);
url.searchParams.append('continue', integration.id);
router.push(url.toString());
},
[]
);
const finishUpdate = useCallback(() => {
setIdentifier(undefined);
update(true);
@ -153,6 +190,17 @@ export const ConnectChannels: FC = () => {
integration.disabled && 'opacity-50'
)}
>
{integration.inBetweenSteps && (
<div
className="absolute left-0 top-0 w-[39px] h-[46px] cursor-pointer"
onClick={continueIntegration(integration)}
>
<div className="bg-red-500 w-[15px] h-[15px] rounded-full -left-[5px] -top-[5px] absolute z-[200] text-[10px] flex justify-center items-center">
!
</div>
<div className="bg-black/60 w-[39px] h-[46px] left-0 top-0 absolute rounded-full z-[199]" />
</div>
)}
<img
src={integration.picture}
className="rounded-full"

View File

@ -2,6 +2,8 @@ import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/pris
import { Injectable } from '@nestjs/common';
import dayjs from 'dayjs';
import * as console from 'node:console';
import { Integration } from '@prisma/client';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
@Injectable()
export class IntegrationRepository {
@ -10,6 +12,17 @@ export class IntegrationRepository {
private _posts: PrismaRepository<'post'>
) {}
updateIntegration(id: string, params: Partial<Integration>) {
return this._integration.model.integration.update({
where: {
id,
},
data: {
...params,
},
});
}
createOrUpdateIntegration(
org: string,
name: string,
@ -20,7 +33,8 @@ export class IntegrationRepository {
token: string,
refreshToken = '',
expiresIn = 999999999,
username?: string
username?: string,
isBetweenSteps = false
) {
return this._integration.model.integration.upsert({
where: {
@ -36,6 +50,7 @@ export class IntegrationRepository {
token,
profile: username,
picture,
inBetweenSteps: isBetweenSteps,
refreshToken,
...(expiresIn
? { tokenExpiration: new Date(Date.now() + expiresIn * 1000) }
@ -47,6 +62,7 @@ export class IntegrationRepository {
type: type as any,
name,
providerIdentifier: provider,
inBetweenSteps: isBetweenSteps,
token,
picture,
profile: username,
@ -67,6 +83,7 @@ export class IntegrationRepository {
tokenExpiration: {
lte: dayjs().add(1, 'day').toDate(),
},
inBetweenSteps: false,
deletedAt: null,
},
});
@ -81,7 +98,12 @@ export class IntegrationRepository {
});
}
async getIntegrationForOrder(id: string, order: string, user: string, org: string) {
async getIntegrationForOrder(
id: string,
order: string,
user: string,
org: string
) {
console.log(id, order, user, org);
const integration = await this._posts.model.post.findFirst({
where: {
@ -90,12 +112,12 @@ export class IntegrationRepository {
id: order,
messageGroup: {
OR: [
{sellerId: user},
{buyerId: user},
{buyerOrganizationId: org},
]
}
}
{ sellerId: user },
{ buyerId: user },
{ buyerOrganizationId: org },
],
},
},
},
select: {
integration: {
@ -103,10 +125,11 @@ export class IntegrationRepository {
id: true,
name: true,
picture: true,
inBetweenSteps: true,
providerIdentifier: true,
},
}
}
},
},
});
return integration?.integration;
@ -170,6 +193,21 @@ export class IntegrationRepository {
});
}
async checkForDeletedOnceAndUpdate(org: string, page: string) {
return this._integration.model.integration.updateMany({
where: {
organizationId: org,
internalId: page,
deletedAt: {
not: null,
},
},
data: {
internalId: makeId(10),
}
});
}
async disableIntegrations(org: string, totalChannels: number) {
const getChannels = await this._integration.model.integration.findMany({
where: {

View File

@ -1,6 +1,8 @@
import {HttpException, HttpStatus, Injectable} from '@nestjs/common';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { IntegrationRepository } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.repository';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { InstagramProvider } from '@gitroom/nestjs-libraries/integrations/social/instagram.provider';
import { FacebookProvider } from '@gitroom/nestjs-libraries/integrations/social/facebook.provider';
@Injectable()
export class IntegrationService {
@ -18,7 +20,8 @@ export class IntegrationService {
token: string,
refreshToken = '',
expiresIn?: number,
username?: string
username?: string,
isBetweenSteps = false
) {
return this._integrationRepository.createOrUpdateIntegration(
org,
@ -30,7 +33,8 @@ export class IntegrationService {
token,
refreshToken,
expiresIn,
username
username,
isBetweenSteps
);
}
@ -39,7 +43,12 @@ export class IntegrationService {
}
getIntegrationForOrder(id: string, order: string, user: string, org: string) {
return this._integrationRepository.getIntegrationForOrder(id, order, user, org);
return this._integrationRepository.getIntegrationForOrder(
id,
order,
user,
org
);
}
getIntegrationById(org: string, id: string) {
@ -86,9 +95,15 @@ export class IntegrationService {
}
async deleteChannel(org: string, id: string) {
const isTherePosts = await this._integrationRepository.countPostsForChannel(org, id);
const isTherePosts = await this._integrationRepository.countPostsForChannel(
org,
id
);
if (isTherePosts) {
throw new HttpException('There are posts for this channel', HttpStatus.NOT_ACCEPTABLE);
throw new HttpException(
'There are posts for this channel',
HttpStatus.NOT_ACCEPTABLE
);
}
return this._integrationRepository.deleteChannel(org, id);
@ -97,4 +112,66 @@ export class IntegrationService {
async disableIntegrations(org: string, totalChannels: number) {
return this._integrationRepository.disableIntegrations(org, totalChannels);
}
async checkForDeletedOnceAndUpdate(org: string, page: string) {
return this._integrationRepository.checkForDeletedOnceAndUpdate(org, page);
}
async saveInstagram(org: string, id: string, data: { pageId: string, id: string }) {
const getIntegration = await this._integrationRepository.getIntegrationById(
org,
id
);
if (getIntegration && !getIntegration.inBetweenSteps) {
throw new HttpException('Invalid request', HttpStatus.BAD_REQUEST);
}
const instagram = this._integrationManager.getSocialIntegration(
'instagram'
) as InstagramProvider;
const getIntegrationInformation = await instagram.fetchPageInformation(
getIntegration?.token!,
data
);
await this.checkForDeletedOnceAndUpdate(org, getIntegrationInformation.id);
await this._integrationRepository.updateIntegration(id, {
picture: getIntegrationInformation.picture,
internalId: getIntegrationInformation.id,
name: getIntegrationInformation.name,
inBetweenSteps: false,
token: getIntegrationInformation.access_token,
});
return { success: true };
}
async saveFacebook(org: string, id: string, page: string) {
const getIntegration = await this._integrationRepository.getIntegrationById(
org,
id
);
if (getIntegration && !getIntegration.inBetweenSteps) {
throw new HttpException('Invalid request', HttpStatus.BAD_REQUEST);
}
const facebook = this._integrationManager.getSocialIntegration(
'facebook'
) as FacebookProvider;
const getIntegrationInformation = await facebook.fetchPageInformation(
getIntegration?.token!,
page
);
await this.checkForDeletedOnceAndUpdate(org, getIntegrationInformation.id);
await this._integrationRepository.updateIntegration(id, {
picture: getIntegrationInformation.picture,
internalId: getIntegrationInformation.id,
name: getIntegrationInformation.name,
inBetweenSteps: false,
token: getIntegrationInformation.access_token,
});
return { success: true };
}
}

View File

@ -200,6 +200,7 @@ model Integration {
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
orderItems OrderItems[]
inBetweenSteps Boolean @default(false)
@@index([updatedAt])
@@index([deletedAt])

View File

@ -1,43 +1,53 @@
import {Injectable} from "@nestjs/common";
import {XProvider} from "@gitroom/nestjs-libraries/integrations/social/x.provider";
import {SocialProvider} from "@gitroom/nestjs-libraries/integrations/social/social.integrations.interface";
import {LinkedinProvider} from "@gitroom/nestjs-libraries/integrations/social/linkedin.provider";
import {RedditProvider} from "@gitroom/nestjs-libraries/integrations/social/reddit.provider";
import {DevToProvider} from "@gitroom/nestjs-libraries/integrations/article/dev.to.provider";
import {HashnodeProvider} from "@gitroom/nestjs-libraries/integrations/article/hashnode.provider";
import {MediumProvider} from "@gitroom/nestjs-libraries/integrations/article/medium.provider";
import {ArticleProvider} from "@gitroom/nestjs-libraries/integrations/article/article.integrations.interface";
import { Injectable } from '@nestjs/common';
import { XProvider } from '@gitroom/nestjs-libraries/integrations/social/x.provider';
import { SocialProvider } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider';
import { RedditProvider } from '@gitroom/nestjs-libraries/integrations/social/reddit.provider';
import { DevToProvider } from '@gitroom/nestjs-libraries/integrations/article/dev.to.provider';
import { HashnodeProvider } from '@gitroom/nestjs-libraries/integrations/article/hashnode.provider';
import { MediumProvider } from '@gitroom/nestjs-libraries/integrations/article/medium.provider';
import { ArticleProvider } from '@gitroom/nestjs-libraries/integrations/article/article.integrations.interface';
import { FacebookProvider } from '@gitroom/nestjs-libraries/integrations/social/facebook.provider';
import { InstagramProvider } from '@gitroom/nestjs-libraries/integrations/social/instagram.provider';
const socialIntegrationList = [
new XProvider(),
new LinkedinProvider(),
new RedditProvider()
new XProvider(),
new LinkedinProvider(),
new RedditProvider(),
new FacebookProvider(),
new InstagramProvider(),
];
const articleIntegrationList = [
new DevToProvider(),
new HashnodeProvider(),
new MediumProvider()
new DevToProvider(),
new HashnodeProvider(),
new MediumProvider(),
];
@Injectable()
export class IntegrationManager {
getAllIntegrations() {
return {
social: socialIntegrationList.map(p => ({name: p.name, identifier: p.identifier})),
article: articleIntegrationList.map(p => ({name: p.name, identifier: p.identifier})),
};
}
getAllowedSocialsIntegrations() {
return socialIntegrationList.map(p => p.identifier);
}
getSocialIntegration(integration: string): SocialProvider {
return socialIntegrationList.find(i => i.identifier === integration)!;
}
getAllowedArticlesIntegrations() {
return articleIntegrationList.map(p => p.identifier);
}
getArticlesIntegration(integration: string): ArticleProvider {
return articleIntegrationList.find(i => i.identifier === integration)!;
}
}
getAllIntegrations() {
return {
social: socialIntegrationList.map((p) => ({
name: p.name,
identifier: p.identifier,
})),
article: articleIntegrationList.map((p) => ({
name: p.name,
identifier: p.identifier,
})),
};
}
getAllowedSocialsIntegrations() {
return socialIntegrationList.map((p) => p.identifier);
}
getSocialIntegration(integration: string): SocialProvider {
return socialIntegrationList.find((i) => i.identifier === integration)!;
}
getAllowedArticlesIntegrations() {
return articleIntegrationList.map((p) => p.identifier);
}
getArticlesIntegration(integration: string): ArticleProvider {
return articleIntegrationList.find((i) => i.identifier === integration)!;
}
}

View File

@ -0,0 +1,254 @@
import {
AuthTokenDetails,
PostDetails,
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
export class FacebookProvider implements SocialProvider {
identifier = 'facebook';
name = 'Facebook Page';
isBetweenSteps = true;
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
const { access_token, expires_in, ...all } = await (
await fetch(
'https://graph.facebook.com/v19.0/oauth/access_token' +
'?grant_type=fb_exchange_token' +
`&client_id=${process.env.FACEBOOK_APP_ID}` +
`&client_secret=${process.env.FACEBOOK_APP_SECRET}` +
`&fb_exchange_token=${refresh_token}`
)
).json();
const {
id,
name,
picture: {
data: { url },
},
} = await (
await fetch(
`https://graph.facebook.com/v19.0/me?fields=id,name,picture&access_token=${access_token}`
)
).json();
return {
id,
name,
accessToken: access_token,
refreshToken: access_token,
expiresIn: expires_in,
picture: url,
username: '',
};
}
async generateAuthUrl() {
const state = makeId(6);
return {
url:
'https://www.facebook.com/v19.0/dialog/oauth' +
`?client_id=${process.env.FACEBOOK_APP_ID}` +
`&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/facebook`
)}` +
`&state=${state}` +
'&scope=pages_show_list,business_management,pages_manage_posts,publish_video,pages_manage_engagement,pages_read_engagement',
codeVerifier: makeId(10),
state,
};
}
async authenticate(params: { code: string; codeVerifier: string }) {
const getAccessToken = await (
await fetch(
'https://graph.facebook.com/v19.0/oauth/access_token' +
`?client_id=${process.env.FACEBOOK_APP_ID}` +
`&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/facebook`
)}` +
`&client_secret=${process.env.FACEBOOK_APP_SECRET}` +
`&code=${params.code}`
)
).json();
const { access_token, expires_in, ...all } = await (
await fetch(
'https://graph.facebook.com/v19.0/oauth/access_token' +
'?grant_type=fb_exchange_token' +
`&client_id=${process.env.FACEBOOK_APP_ID}` +
`&client_secret=${process.env.FACEBOOK_APP_SECRET}` +
`&fb_exchange_token=${getAccessToken.access_token}`
)
).json();
const {
id,
name,
picture: {
data: { url },
},
} = await (
await fetch(
`https://graph.facebook.com/v19.0/me?fields=id,name,picture&access_token=${access_token}`
)
).json();
return {
id,
name,
accessToken: access_token,
refreshToken: access_token,
expiresIn: expires_in,
picture: url,
username: '',
};
}
async pages(accessToken: string) {
const { data } = await (
await fetch(
`https://graph.facebook.com/v20.0/me/accounts?fields=id,username,name,picture.type(large)&access_token=${accessToken}`
)
).json();
return data;
}
async fetchPageInformation(accessToken: string, pageId: string) {
const {
id,
name,
access_token,
picture: {
data: { url },
},
} = await (
await fetch(
`https://graph.facebook.com/v20.0/${pageId}?fields=access_token,name,picture.type(large)&access_token=${accessToken}`
)
).json();
return {
id,
name,
access_token,
picture: url,
};
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails[]
): Promise<PostResponse[]> {
const [firstPost, ...comments] = postDetails;
let finalId = '';
let finalUrl = '';
if ((firstPost?.media?.[0]?.path?.indexOf('mp4') || 0) > -1) {
const { id: videoId, permalink_url } = await (
await fetch(
`https://graph.facebook.com/v20.0/${id}/videos?access_token=${accessToken}&fields=id,permalink_url`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_url: firstPost?.media?.[0]?.path!,
description: firstPost.message,
published: true,
}),
}
)
).json();
finalUrl = permalink_url;
finalId = videoId;
} else {
const uploadPhotos = !firstPost?.media?.length
? []
: await Promise.all(
firstPost.media.map(async (media) => {
const { id: photoId } = await (
await fetch(
`https://graph.facebook.com/v20.0/${id}/photos?access_token=${accessToken}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: media.url,
published: false,
}),
}
)
).json();
return { media_fbid: photoId };
})
);
const { id: postId, permalink_url } = await (
await fetch(
`https://graph.facebook.com/v20.0/${id}/feed?access_token=${accessToken}&fields=id,permalink_url`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...(uploadPhotos?.length ? { attached_media: uploadPhotos } : {}),
message: firstPost.message,
published: true,
}),
}
)
).json();
finalUrl = permalink_url;
finalId = postId;
}
const postsArray = [];
for (const comment of comments) {
const data = await (
await fetch(
`https://graph.facebook.com/v20.0/${finalId}/comments?access_token=${accessToken}&fields=id,permalink_url`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...(comment.media?.length
? { attachment_url: comment.media[0].url }
: {}),
message: comment.message,
}),
}
)
).json();
postsArray.push({
id: comment.id,
postId: data.id,
releaseURL: data.permalink_url,
status: 'success',
});
}
return [
{
id: firstPost.id,
postId: finalId,
releaseURL: finalUrl,
status: 'success',
},
...postsArray,
];
}
}

View File

@ -5,14 +5,17 @@ import {
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { timer } from '@gitroom/helpers/utils/timer';
export class InstagramProvider implements SocialProvider {
identifier = 'instagram';
name = 'Instagram';
isBetweenSteps = true;
export class FacebookProvider implements SocialProvider {
identifier = 'facebook';
name = 'Facebook';
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
const { access_token, expires_in, ...all } = await (
await fetch(
'https://graph.facebook.com/v19.0/oauth/access_token' +
'https://graph.facebook.com/v20.0/oauth/access_token' +
'?grant_type=fb_exchange_token' +
`&client_id=${process.env.FACEBOOK_APP_ID}` +
`&client_secret=${process.env.FACEBOOK_APP_SECRET}` +
@ -21,19 +24,29 @@ export class FacebookProvider implements SocialProvider {
).json();
const {
id,
name,
picture: {
data: { url },
data: {
id,
name,
picture: {
data: { url },
},
},
} = await (
await fetch(
`https://graph.facebook.com/v19.0/me?fields=id,name,picture&access_token=${access_token}`
`https://graph.facebook.com/v20.0/me/accounts?fields=id,username,name,picture&access_token=${access_token}`
)
).json();
const {
instagram_business_account: { id: instagramId },
} = await (
await fetch(
`https://graph.facebook.com/v20.0/${id}?fields=instagram_business_account&access_token=${access_token}`
)
).json();
return {
id,
id: instagramId,
name,
accessToken: access_token,
refreshToken: access_token,
@ -47,14 +60,15 @@ export class FacebookProvider implements SocialProvider {
const state = makeId(6);
return {
url:
'https://www.facebook.com/v19.0/dialog/oauth' +
'https://www.facebook.com/v20.0/dialog/oauth' +
`?client_id=${process.env.FACEBOOK_APP_ID}` +
`&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/facebook`
`${process.env.FRONTEND_URL}/integrations/social/instagram`
)}` +
`&state=${state}` +
'&scope=email,public_profile',
// '&scope=email,public_profile,pages_manage_posts,pages_read_engagement,publish_to_groups,groups_access_member_info',
`&scope=${encodeURIComponent(
'instagram_basic,pages_show_list,pages_read_engagement,business_management,instagram_content_publish,instagram_manage_comments'
)}`,
codeVerifier: makeId(10),
state,
};
@ -63,10 +77,10 @@ export class FacebookProvider implements SocialProvider {
async authenticate(params: { code: string; codeVerifier: string }) {
const getAccessToken = await (
await fetch(
'https://graph.facebook.com/v19.0/oauth/access_token' +
'https://graph.facebook.com/v20.0/oauth/access_token' +
`?client_id=${process.env.FACEBOOK_APP_ID}` +
`&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/facebook`
`${process.env.FRONTEND_URL}/integrations/social/instagram`
)}` +
`&client_secret=${process.env.FACEBOOK_APP_SECRET}` +
`&code=${params.code}`
@ -75,7 +89,7 @@ export class FacebookProvider implements SocialProvider {
const { access_token, expires_in, ...all } = await (
await fetch(
'https://graph.facebook.com/v19.0/oauth/access_token' +
'https://graph.facebook.com/v20.0/oauth/access_token' +
'?grant_type=fb_exchange_token' +
`&client_id=${process.env.FACEBOOK_APP_ID}` +
`&client_secret=${process.env.FACEBOOK_APP_SECRET}` +
@ -91,7 +105,7 @@ export class FacebookProvider implements SocialProvider {
},
} = await (
await fetch(
`https://graph.facebook.com/v19.0/me?fields=id,name,picture&access_token=${access_token}`
`https://graph.facebook.com/v20.0/me?fields=id,name,picture&access_token=${access_token}`
)
).json();
@ -106,11 +120,208 @@ export class FacebookProvider implements SocialProvider {
};
}
async pages(accessToken: string) {
const { data } = await (
await fetch(
`https://graph.facebook.com/v20.0/me/accounts?fields=id,instagram_business_account,username,name,picture.type(large)&access_token=${accessToken}&limit=500`
)
).json();
const onlyConnectedAccounts = await Promise.all(
data
.filter((f: any) => f.instagram_business_account)
.map(async (p: any) => {
return {
pageId: p.id,
...(await (
await fetch(
`https://graph.facebook.com/v20.0/${p.instagram_business_account.id}?fields=name,profile_picture_url&access_token=${accessToken}&limit=500`
)
).json()),
id: p.instagram_business_account.id,
};
})
);
return onlyConnectedAccounts.map((p: any) => ({
pageId: p.pageId,
id: p.id,
name: p.name,
picture: { data: { url: p.profile_picture_url } },
}));
}
async fetchPageInformation(
accessToken: string,
data: { pageId: string; id: string }
) {
const { access_token } = await (
await fetch(
`https://graph.facebook.com/v20.0/${data.pageId}?fields=access_token,name,picture.type(large)&access_token=${accessToken}`
)
).json();
const { id, name, profile_picture_url } = await (
await fetch(
`https://graph.facebook.com/v20.0/${data.id}?fields=name,profile_picture_url&access_token=${accessToken}`
)
).json();
return {
id,
name,
picture: profile_picture_url,
access_token,
};
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails[]
): Promise<PostResponse[]> {
return [];
const [firstPost, ...theRest] = postDetails;
const medias = await Promise.all(
firstPost?.media?.map(async (m) => {
const caption =
firstPost.media?.length === 1 ? `&caption=${firstPost.message}` : ``;
const isCarousel =
(firstPost?.media?.length || 0) > 1 ? `&is_carousel_item=true` : ``;
const mediaType =
m.path.indexOf('.mp4') > -1
? firstPost?.media?.length === 1
? `video_url=${m.url}&media_type=REELS`
: `video_url=${m.url}&media_type=VIDEO`
: `image_url=${m.url}`;
const { id: photoId } = await (
await fetch(
`https://graph.facebook.com/v20.0/${id}/media?${mediaType}${caption}${isCarousel}&access_token=${accessToken}`,
{
method: 'POST',
}
)
).json();
let status = 'IN_PROGRESS';
while (status === 'IN_PROGRESS') {
const { status_code } = await (
await fetch(
`https://graph.facebook.com/v20.0/${photoId}?access_token=${accessToken}&fields=status_code`
)
).json();
await timer(3000);
status = status_code;
}
return photoId;
}) || []
);
const arr = [];
let containerIdGlobal = '';
let linkGlobal = '';
if (medias.length === 1) {
const { id: mediaId, ...all } = await (
await fetch(
`https://graph.facebook.com/v20.0/${id}/media_publish?creation_id=${medias[0]}&access_token=${accessToken}&field=id`,
{
method: 'POST',
}
)
).json();
console.log(all);
containerIdGlobal = mediaId;
const { permalink } = await (
await fetch(
`https://graph.facebook.com/v20.0/${mediaId}?fields=permalink&access_token=${accessToken}`
)
).json();
arr.push({
id: firstPost.id,
postId: mediaId,
releaseURL: permalink,
status: 'success',
});
linkGlobal = permalink;
} else {
const { id: containerId, ...all3 } = await (
await fetch(
`https://graph.facebook.com/v20.0/${id}/media?caption=${encodeURIComponent(
firstPost?.message
)}&media_type=CAROUSEL&children=${encodeURIComponent(
medias.join(',')
)}&access_token=${accessToken}`,
{
method: 'POST',
}
)
).json();
let status = 'IN_PROGRESS';
while (status === 'IN_PROGRESS') {
const { status_code } = await (
await fetch(
`https://graph.facebook.com/v20.0/${containerId}?fields=status_code&access_token=${accessToken}`
)
).json();
await timer(3000);
status = status_code;
}
const { id: mediaId, ...all4 } = await (
await fetch(
`https://graph.facebook.com/v20.0/${id}/media_publish?creation_id=${containerId}&access_token=${accessToken}&field=id`,
{
method: 'POST',
}
)
).json();
containerIdGlobal = mediaId;
const { permalink } = await (
await fetch(
`https://graph.facebook.com/v20.0/${mediaId}?fields=permalink&access_token=${accessToken}`
)
).json();
arr.push({
id: firstPost.id,
postId: mediaId,
releaseURL: permalink,
status: 'success',
});
linkGlobal = permalink;
}
for (const post of theRest) {
const { id: commentId, ...all } = await (
await fetch(
`https://graph.facebook.com/v20.0/${containerIdGlobal}/comments?message=${encodeURIComponent(
post.message
)}&access_token=${accessToken}`,
{
method: 'POST',
}
)
).json();
arr.push({
id: firstPost.id,
postId: commentId,
releaseURL: linkGlobal,
status: 'success',
});
}
return arr;
}
}

View File

@ -13,6 +13,8 @@ import { removeMarkdown } from '@gitroom/helpers/utils/remove.markdown';
export class LinkedinProvider implements SocialProvider {
identifier = 'linkedin';
name = 'LinkedIn';
isBetweenSteps = false;
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
const { access_token: accessToken, refresh_token: refreshToken } = await (
await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
@ -165,43 +167,86 @@ export class LinkedinProvider implements SocialProvider {
}
private async uploadPicture(
fileName: string,
accessToken: string,
personId: string,
picture: any
) {
const {
value: { uploadUrl, image },
} = await (
await fetch(
'https://api.linkedin.com/rest/images?action=initializeUpload',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202402',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
initializeUploadRequest: {
owner: `urn:li:person:${personId}`,
try {
const {
value: { uploadUrl, image, video, uploadInstructions, ...all },
} = await (
await fetch(
`https://api.linkedin.com/rest/${
fileName.indexOf('mp4') > -1 ? 'videos' : 'images'
}?action=initializeUpload`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202402',
Authorization: `Bearer ${accessToken}`,
},
}),
}
)
).json();
body: JSON.stringify({
initializeUploadRequest: {
owner: `urn:li:person:${personId}`,
...(fileName.indexOf('mp4') > -1
? {
fileSizeBytes: picture.length,
uploadCaptions: false,
uploadThumbnail: false,
}
: {}),
},
}),
}
)
).json();
await fetch(uploadUrl, {
method: 'PUT',
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202402',
Authorization: `Bearer ${accessToken}`,
},
body: picture,
});
const sendUrlRequest = uploadInstructions?.[0]?.uploadUrl || uploadUrl;
const finalOutput = video || image;
return image;
const upload = await fetch(sendUrlRequest, {
method: 'PUT',
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202402',
Authorization: `Bearer ${accessToken}`,
...(fileName.indexOf('mp4') > -1
? { 'Content-Type': 'application/octet-stream' }
: {}),
},
body: picture,
});
if (fileName.indexOf('mp4') > -1) {
const etag = upload.headers.get('etag');
const a = await fetch(
'https://api.linkedin.com/rest/videos?action=finalizeUpload',
{
method: 'POST',
body: JSON.stringify({
finalizeUploadRequest: {
video,
uploadToken: '',
uploadedPartIds: [etag],
},
}),
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202402',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}
);
}
return finalOutput;
} catch (err: any) {
throw 'eerr';
}
}
async post(
@ -217,15 +262,18 @@ export class LinkedinProvider implements SocialProvider {
p?.media?.flatMap(async (m) => {
return {
id: await this.uploadPicture(
m.path,
accessToken,
id,
await sharp(await readOrFetch(m.path), {
animated: lookup(m.path) === 'image/gif',
})
.resize({
width: 1000,
})
.toBuffer()
m.path.indexOf('mp4') > -1
? Buffer.from(await readOrFetch(m.path))
: await sharp(await readOrFetch(m.path), {
animated: lookup(m.path) === 'image/gif',
})
.resize({
width: 1000,
})
.toBuffer()
),
postId: p.id,
};

View File

@ -12,6 +12,8 @@ import { groupBy } from 'lodash';
export class RedditProvider implements SocialProvider {
identifier = 'reddit';
name = 'Reddit';
isBetweenSteps = false;
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const {
access_token: accessToken,

View File

@ -53,4 +53,5 @@ export type MediaContent = {
export interface SocialProvider extends IAuthenticator, ISocialMediaIntegration {
identifier: string;
name: string;
isBetweenSteps: boolean;
}

View File

@ -13,6 +13,8 @@ import removeMd from 'remove-markdown';
export class XProvider implements SocialProvider {
identifier = 'x';
name = 'X';
isBetweenSteps = false;
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const startingClient = new TwitterApi({
clientId: process.env.TWITTER_CLIENT_ID!,
@ -47,8 +49,6 @@ export class XProvider implements SocialProvider {
async generateAuthUrl() {
const client = new TwitterApi({
// clientId: process.env.TWITTER_CLIENT_ID!,
// clientSecret: process.env.TWITTER_CLIENT_SECRET!,
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
});
@ -128,14 +128,16 @@ export class XProvider implements SocialProvider {
p?.media?.flatMap(async (m) => {
return {
id: await client.v1.uploadMedia(
await sharp(await readOrFetch(m.path), {
animated: lookup(m.path) === 'image/gif',
})
.resize({
width: 1000,
})
.gif()
.toBuffer(),
m.path.indexOf('mp4') > -1
? Buffer.from(await readOrFetch(m.path))
: await sharp(await readOrFetch(m.path), {
animated: lookup(m.path) === 'image/gif',
})
.resize({
width: 1000,
})
.gif()
.toBuffer(),
{
mimeType: lookup(m.path) || '',
}
@ -183,17 +185,4 @@ export class XProvider implements SocialProvider {
status: 'posted',
}));
}
// async analytics(accessToken: string) {
// const [accessTokenSplit, accessSecretSplit] = accessToken.split(':');
// const client = new TwitterApi({
// appKey: process.env.X_API_KEY!,
// appSecret: process.env.X_API_SECRET!,
// accessToken: accessTokenSplit,
// accessSecret: accessSecretSplit,
// });
// const {
// data: { username },
// } = await client.v2;
// }
}

View File

@ -0,0 +1,9 @@
'use client';
import { FC } from 'react';
export const VideoFrame: FC<{ url: string }> = (props) => {
const { url } = props;
return <video id="videoFrame" src={url + '#t=0.1'} preload="metadata" autoPlay={false}></video>;
};

View File

@ -0,0 +1,15 @@
import { FC } from 'react';
export const VideoOrImage: FC<{ src: string; autoplay: boolean }> = (props) => {
const { src, autoplay } = props;
if (src.indexOf('mp4') > -1) {
return <video src={src} autoPlay={autoplay} className="w-full h-full" muted={true} loop={true} />;
}
return (
<img
className="w-full h-full object-cover"
src={src}
/>
);
};