feat: slack and discord
This commit is contained in:
parent
c40039423f
commit
8d4d450fee
|
|
@ -46,21 +46,68 @@ export class IntegrationsController {
|
|||
return {
|
||||
integrations: (
|
||||
await this._integrationService.getIntegrationsList(org.id)
|
||||
).map((p) => ({
|
||||
name: p.name,
|
||||
id: p.id,
|
||||
internalId: p.internalId,
|
||||
disabled: p.disabled,
|
||||
picture: p.picture,
|
||||
identifier: p.providerIdentifier,
|
||||
inBetweenSteps: p.inBetweenSteps,
|
||||
refreshNeeded: p.refreshNeeded,
|
||||
type: p.type,
|
||||
time: JSON.parse(p.postingTimes)
|
||||
})),
|
||||
).map((p) => {
|
||||
const findIntegration = this._integrationManager.getSocialIntegration(
|
||||
p.providerIdentifier
|
||||
);
|
||||
return {
|
||||
name: p.name,
|
||||
id: p.id,
|
||||
internalId: p.internalId,
|
||||
disabled: p.disabled,
|
||||
picture: p.picture,
|
||||
identifier: p.providerIdentifier,
|
||||
inBetweenSteps: p.inBetweenSteps,
|
||||
refreshNeeded: p.refreshNeeded,
|
||||
type: p.type,
|
||||
time: JSON.parse(p.postingTimes),
|
||||
changeProfilePicture: !!findIntegration.changeProfilePicture,
|
||||
changeNickName: !!findIntegration.changeNickname,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/:id/nickname')
|
||||
async setNickname(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name: string; picture: string }
|
||||
) {
|
||||
const integration = await this._integrationService.getIntegrationById(
|
||||
org.id,
|
||||
id
|
||||
);
|
||||
if (!integration) {
|
||||
throw new Error('Invalid integration');
|
||||
}
|
||||
|
||||
const manager = this._integrationManager.getSocialIntegration(
|
||||
integration.providerIdentifier
|
||||
);
|
||||
if (!manager.changeProfilePicture && !manager.changeNickname) {
|
||||
throw new Error('Invalid integration');
|
||||
}
|
||||
|
||||
const { url } = manager.changeProfilePicture
|
||||
? await manager.changeProfilePicture(
|
||||
integration.internalId,
|
||||
integration.token,
|
||||
body.picture
|
||||
)
|
||||
: { url: '' };
|
||||
|
||||
const { name } = manager.changeNickname
|
||||
? await manager.changeNickname(
|
||||
integration.internalId,
|
||||
integration.token,
|
||||
body.name
|
||||
)
|
||||
: { name: '' };
|
||||
|
||||
return this._integrationService.updateNameAndUrl(id, name, url);
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
getSingleIntegration(
|
||||
@Param('id') id: string,
|
||||
|
|
@ -129,7 +176,11 @@ export class IntegrationsController {
|
|||
}
|
||||
|
||||
if (integrationProvider[body.name]) {
|
||||
return integrationProvider[body.name](getIntegration.token, body.data);
|
||||
return integrationProvider[body.name](
|
||||
getIntegration.token,
|
||||
body.data,
|
||||
getIntegration.internalId
|
||||
);
|
||||
}
|
||||
throw new Error('Function not found');
|
||||
}
|
||||
|
|
@ -144,7 +195,11 @@ export class IntegrationsController {
|
|||
}
|
||||
|
||||
if (integrationProvider[body.name]) {
|
||||
return integrationProvider[body.name](getIntegration.token, body.data);
|
||||
return integrationProvider[body.name](
|
||||
getIntegration.token,
|
||||
body.data,
|
||||
getIntegration.internalId
|
||||
);
|
||||
}
|
||||
throw new Error('Function not found');
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -0,0 +1,101 @@
|
|||
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
|
||||
import React, { FC, FormEventHandler, useCallback, useState } from 'react';
|
||||
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { Input } from '@gitroom/react/form/input';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
import { useToaster } from '@gitroom/react/toaster/toaster';
|
||||
import {
|
||||
MediaComponent,
|
||||
showMediaBox,
|
||||
} from '@gitroom/frontend/components/media/media.component';
|
||||
|
||||
export const BotPicture: FC<{
|
||||
integration: Integrations;
|
||||
canChangeProfilePicture: boolean;
|
||||
canChangeNickName: boolean;
|
||||
mutate: () => void;
|
||||
}> = (props) => {
|
||||
const modal = useModals();
|
||||
const toast = useToaster();
|
||||
const [nick, setNickname] = useState(props.integration.name);
|
||||
const [picture, setPicture] = useState(props.integration.picture);
|
||||
|
||||
const fetch = useFetch();
|
||||
const submitForm: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
async (e) => {
|
||||
e.preventDefault();
|
||||
await fetch(`/integrations/${props.integration.id}/nickname`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: nick, picture }),
|
||||
});
|
||||
|
||||
props.mutate();
|
||||
toast.show('Updated', 'success');
|
||||
modal.closeAll();
|
||||
},
|
||||
[nick, picture, props.mutate]
|
||||
);
|
||||
|
||||
const openMedia = useCallback(() => {
|
||||
showMediaBox((values) => {
|
||||
setPicture(values.path);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-[4px] border border-customColor6 bg-sixth px-[16px] pb-[16px] relative w-full">
|
||||
<TopTitle title={`Change Bot Picture`} />
|
||||
<button
|
||||
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
|
||||
type="button"
|
||||
onClick={() => modal.closeAll()}
|
||||
>
|
||||
<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="mt-[16px]">
|
||||
<form onSubmit={submitForm} className="gap-[50px] flex flex-col">
|
||||
{props.canChangeProfilePicture && (
|
||||
<div className="flex items-center gap-[20px]">
|
||||
<img
|
||||
src={picture}
|
||||
alt="Bot Picture"
|
||||
className="w-[100px] h-[100px] rounded-full"
|
||||
/>
|
||||
<Button type="button" onClick={openMedia}>Upload</Button>
|
||||
</div>
|
||||
)}
|
||||
{props.canChangeNickName && (
|
||||
<Input
|
||||
value={nick}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
name="Nickname"
|
||||
label="Nickname"
|
||||
placeholder=""
|
||||
disableForm={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-[50px]">
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -57,6 +57,8 @@ export interface Integrations {
|
|||
identifier: string;
|
||||
type: string;
|
||||
picture: string;
|
||||
changeProfilePicture: boolean;
|
||||
changeNickName: boolean;
|
||||
time: { time: number }[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export const LaunchesComponent = () => {
|
|||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return ;
|
||||
return;
|
||||
}
|
||||
if (search.get('scope') === 'missing') {
|
||||
toast.show('You have to approve all the channel permissions', 'warning');
|
||||
|
|
@ -117,7 +117,7 @@ export const LaunchesComponent = () => {
|
|||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-1 relative">
|
||||
<div className="outline-none w-full h-full grid grid-cols-[220px_minmax(0,1fr)] gap-[30px] scrollbar scrollbar-thumb-tableBorder scrollbar-track-secondary">
|
||||
<div className="w-[220px] bg-third p-[16px] flex flex-col gap-[24px] min-h-[100%]">
|
||||
<div className="w-[220px] bg-third p-[16px] flex flex-col gap-[24px] min-h-[100%]">
|
||||
<h2 className="text-[20px]">Channels</h2>
|
||||
<div className="gap-[16px] flex flex-col">
|
||||
{sortedIntegrations.length === 0 && (
|
||||
|
|
@ -196,6 +196,8 @@ export const LaunchesComponent = () => {
|
|||
{integration.name}
|
||||
</div>
|
||||
<Menu
|
||||
canChangeProfilePicture={integration.changeProfilePicture}
|
||||
canChangeNickName={integration.changeNickName}
|
||||
mutate={mutate}
|
||||
onChange={update}
|
||||
id={integration.id}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,26 @@ import interClass from '@gitroom/react/helpers/inter.font';
|
|||
import { useModals } from '@mantine/modals';
|
||||
import { TimeTable } from '@gitroom/frontend/components/launches/time.table';
|
||||
import { useCalendar } from '@gitroom/frontend/components/launches/calendar.context';
|
||||
import { BotPicture } from '@gitroom/frontend/components/launches/bot.picture';
|
||||
|
||||
export const Menu: FC<{
|
||||
canEnable: boolean;
|
||||
canDisable: boolean;
|
||||
canChangeProfilePicture: boolean;
|
||||
canChangeNickName: boolean;
|
||||
id: string;
|
||||
mutate: () => void,
|
||||
mutate: () => void;
|
||||
onChange: (shouldReload: boolean) => void;
|
||||
}> = (props) => {
|
||||
const { canEnable, canDisable, id, onChange, mutate } = props;
|
||||
const {
|
||||
canEnable,
|
||||
canDisable,
|
||||
id,
|
||||
onChange,
|
||||
mutate,
|
||||
canChangeProfilePicture,
|
||||
canChangeNickName,
|
||||
} = props;
|
||||
const fetch = useFetch();
|
||||
const { integrations } = useCalendar();
|
||||
const toast = useToaster();
|
||||
|
|
@ -98,8 +109,30 @@ export const Menu: FC<{
|
|||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
closeOnClickOutside: false,
|
||||
children: <TimeTable integration={findIntegration!} mutate={mutate} />,
|
||||
});
|
||||
setShow(false);
|
||||
}, [integrations]);
|
||||
|
||||
const changeBotPicture = useCallback(() => {
|
||||
const findIntegration = integrations.find(
|
||||
(integration) => integration.id === id
|
||||
);
|
||||
modal.openModal({
|
||||
classNames: {
|
||||
modal: 'w-[100%] max-w-[600px] bg-transparent text-textColor',
|
||||
},
|
||||
size: '100%',
|
||||
withCloseButton: false,
|
||||
closeOnEscape: true,
|
||||
closeOnClickOutside: true,
|
||||
children: (
|
||||
<TimeTable integration={findIntegration!} mutate={mutate} />
|
||||
<BotPicture
|
||||
canChangeProfilePicture={canChangeProfilePicture}
|
||||
canChangeNickName={canChangeNickName}
|
||||
integration={findIntegration!}
|
||||
mutate={mutate}
|
||||
/>
|
||||
),
|
||||
});
|
||||
setShow(false);
|
||||
|
|
@ -128,6 +161,36 @@ export const Menu: FC<{
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
className={`absolute top-[100%] left-0 p-[8px] px-[20px] bg-fifth flex flex-col gap-[16px] z-[100] rounded-[8px] border border-tableBorder ${interClass} text-nowrap`}
|
||||
>
|
||||
{(canChangeProfilePicture || canChangeNickName) && (
|
||||
<div
|
||||
className="flex gap-[12px] items-center"
|
||||
onClick={changeBotPicture}
|
||||
>
|
||||
<div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M26 4H10C9.46957 4 8.96086 4.21071 8.58579 4.58579C8.21071 4.96086 8 5.46957 8 6V8H6C5.46957 8 4.96086 8.21071 4.58579 8.58579C4.21071 8.96086 4 9.46957 4 10V26C4 26.5304 4.21071 27.0391 4.58579 27.4142C4.96086 27.7893 5.46957 28 6 28H22C22.5304 28 23.0391 27.7893 23.4142 27.4142C23.7893 27.0391 24 26.5304 24 26V24H26C26.5304 24 27.0391 23.7893 27.4142 23.4142C27.7893 23.0391 28 22.5304 28 22V6C28 5.46957 27.7893 4.96086 27.4142 4.58579C27.0391 4.21071 26.5304 4 26 4ZM10 6H26V14.6725L23.9125 12.585C23.5375 12.2102 23.029 11.9997 22.4988 11.9997C21.9685 11.9997 21.46 12.2102 21.085 12.585L11.6713 22H10V6ZM22 26H6V10H8V22C8 22.5304 8.21071 23.0391 8.58579 23.4142C8.96086 23.7893 9.46957 24 10 24H22V26ZM26 22H14.5L22.5 14L26 17.5V22ZM15 14C15.5933 14 16.1734 13.8241 16.6667 13.4944C17.1601 13.1648 17.5446 12.6962 17.7716 12.1481C17.9987 11.5999 18.0581 10.9967 17.9424 10.4147C17.8266 9.83279 17.5409 9.29824 17.1213 8.87868C16.7018 8.45912 16.1672 8.1734 15.5853 8.05764C15.0033 7.94189 14.4001 8.0013 13.8519 8.22836C13.3038 8.45542 12.8352 8.83994 12.5056 9.33329C12.1759 9.82664 12 10.4067 12 11C12 11.7956 12.3161 12.5587 12.8787 13.1213C13.4413 13.6839 14.2044 14 15 14ZM15 10C15.1978 10 15.3911 10.0586 15.5556 10.1685C15.72 10.2784 15.8482 10.4346 15.9239 10.6173C15.9996 10.8 16.0194 11.0011 15.9808 11.1951C15.9422 11.3891 15.847 11.5673 15.7071 11.7071C15.5673 11.847 15.3891 11.9422 15.1951 11.9808C15.0011 12.0194 14.8 11.9996 14.6173 11.9239C14.4346 11.8482 14.2784 11.72 14.1685 11.5556C14.0586 11.3911 14 11.1978 14 11C14 10.7348 14.1054 10.4804 14.2929 10.2929C14.4804 10.1054 14.7348 10 15 10Z"
|
||||
fill="lightgreen"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-[12px]">
|
||||
Change Bot{' '}
|
||||
{[
|
||||
canChangeProfilePicture && 'Picture',
|
||||
canChangeNickName && 'Nickname',
|
||||
]
|
||||
.filter((f) => f)
|
||||
.join(' / ')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-[12px] items-center" onClick={editTimeTable}>
|
||||
<div>
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { FC, useEffect, useState } from 'react';
|
||||
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
|
||||
import { Select } from '@gitroom/react/form/select';
|
||||
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
|
||||
|
||||
export const DiscordChannelSelect: FC<{
|
||||
name: string;
|
||||
onChange: (event: { target: { value: string; name: string } }) => void;
|
||||
}> = (props) => {
|
||||
const { onChange, name } = props;
|
||||
const customFunc = useCustomProviderFunction();
|
||||
const [publications, setOrgs] = useState([]);
|
||||
const { getValues } = useSettings();
|
||||
const [currentMedia, setCurrentMedia] = useState<string|undefined>();
|
||||
|
||||
const onChangeInner = (event: { target: { value: string, name: string } }) => {
|
||||
setCurrentMedia(event.target.value);
|
||||
onChange(event);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
customFunc.get('channels').then((data) => setOrgs(data));
|
||||
const settings = getValues()[props.name];
|
||||
if (settings) {
|
||||
setCurrentMedia(settings);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
if (!publications.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select name={name} label="Select Channel" onChange={onChangeInner} value={currentMedia}>
|
||||
<option value="">--Select--</option>
|
||||
{publications.map((publication: any) => (
|
||||
<option key={publication.id} value={publication.id}>
|
||||
{publication.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
|
||||
import { FC } from 'react';
|
||||
import { DiscordDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/discord.dto';
|
||||
import { DiscordChannelSelect } from '@gitroom/frontend/components/launches/providers/discord/discord.channel.select';
|
||||
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
|
||||
|
||||
const Empty: FC = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const DiscordComponent: FC = () => {
|
||||
const form = useSettings();
|
||||
return (
|
||||
<div>
|
||||
<DiscordChannelSelect {...form.register('channel')} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default withProvider(
|
||||
DiscordComponent,
|
||||
Empty,
|
||||
DiscordDto,
|
||||
undefined,
|
||||
280
|
||||
);
|
||||
|
|
@ -13,6 +13,8 @@ import TiktokProvider from '@gitroom/frontend/components/launches/providers/tikt
|
|||
import PinterestProvider from '@gitroom/frontend/components/launches/providers/pinterest/pinterest.provider';
|
||||
import DribbbleProvider from '@gitroom/frontend/components/launches/providers/dribbble/dribbble.provider';
|
||||
import ThreadsProvider from '@gitroom/frontend/components/launches/providers/threads/threads.provider';
|
||||
import DiscordProvider from '@gitroom/frontend/components/launches/providers/discord/discord.provider';
|
||||
import SlackProvider from '@gitroom/frontend/components/launches/providers/slack/slack.provider';
|
||||
|
||||
export const Providers = [
|
||||
{identifier: 'devto', component: DevtoProvider},
|
||||
|
|
@ -29,6 +31,8 @@ export const Providers = [
|
|||
{identifier: 'pinterest', component: PinterestProvider},
|
||||
{identifier: 'dribbble', component: DribbbleProvider},
|
||||
{identifier: 'threads', component: ThreadsProvider},
|
||||
{identifier: 'discord', component: DiscordProvider},
|
||||
{identifier: 'slack', component: SlackProvider},
|
||||
];
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { FC, useEffect, useState } from 'react';
|
||||
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
|
||||
import { Select } from '@gitroom/react/form/select';
|
||||
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
|
||||
|
||||
export const SlackChannelSelect: FC<{
|
||||
name: string;
|
||||
onChange: (event: { target: { value: string; name: string } }) => void;
|
||||
}> = (props) => {
|
||||
const { onChange, name } = props;
|
||||
const customFunc = useCustomProviderFunction();
|
||||
const [publications, setOrgs] = useState([]);
|
||||
const { getValues } = useSettings();
|
||||
const [currentMedia, setCurrentMedia] = useState<string|undefined>();
|
||||
|
||||
const onChangeInner = (event: { target: { value: string, name: string } }) => {
|
||||
setCurrentMedia(event.target.value);
|
||||
onChange(event);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
customFunc.get('channels').then((data) => setOrgs(data));
|
||||
const settings = getValues()[props.name];
|
||||
if (settings) {
|
||||
setCurrentMedia(settings);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
if (!publications.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select name={name} label="Select Channel" onChange={onChangeInner} value={currentMedia}>
|
||||
<option value="">--Select--</option>
|
||||
{publications.map((publication: any) => (
|
||||
<option key={publication.id} value={publication.id}>
|
||||
{publication.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
|
||||
import { FC } from 'react';
|
||||
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
|
||||
import { SlackChannelSelect } from '@gitroom/frontend/components/launches/providers/slack/slack.channel.select';
|
||||
import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/slack.dto';
|
||||
|
||||
const Empty: FC = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const SlackComponent: FC = () => {
|
||||
const form = useSettings();
|
||||
return (
|
||||
<div>
|
||||
<SlackChannelSelect {...form.register('channel')} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default withProvider(
|
||||
SlackComponent,
|
||||
Empty,
|
||||
SlackDto,
|
||||
undefined,
|
||||
280
|
||||
);
|
||||
|
|
@ -242,6 +242,8 @@ export const ConnectChannels: FC = () => {
|
|||
{integration.name}
|
||||
</div>
|
||||
<Menu
|
||||
canChangeProfilePicture={integration.changeProfile}
|
||||
canChangeNickName={integration.changeNickName}
|
||||
mutate={mutate}
|
||||
onChange={update}
|
||||
id={integration.id}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,6 @@ export class IntegrationRepository {
|
|||
inBetweenSteps: isBetweenSteps,
|
||||
}
|
||||
: {}),
|
||||
name,
|
||||
picture,
|
||||
profile: username,
|
||||
providerIdentifier: provider,
|
||||
|
|
@ -163,6 +162,18 @@ export class IntegrationRepository {
|
|||
});
|
||||
}
|
||||
|
||||
updateNameAndUrl(id: string, name: string, url: string) {
|
||||
return this._integration.model.integration.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
...(name ? { name } : {}),
|
||||
...(url ? { picture: url } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getIntegrationById(org: string, id: string) {
|
||||
return this._integration.model.integration.findFirst({
|
||||
where: {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@ export class IntegrationService {
|
|||
);
|
||||
}
|
||||
|
||||
updateNameAndUrl(id: string, name: string, url: string) {
|
||||
return this._integrationRepository.updateNameAndUrl(id, name, url);
|
||||
}
|
||||
|
||||
getIntegrationById(org: string, id: string) {
|
||||
return this._integrationRepository.getIntegrationById(org, id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ export class PostsService {
|
|||
err
|
||||
);
|
||||
|
||||
return ;
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
|
|
@ -278,7 +278,8 @@ export class PostsService {
|
|||
? process.env.UPLOAD_DIRECTORY + m.path
|
||||
: m.path,
|
||||
})),
|
||||
}))
|
||||
})),
|
||||
integration
|
||||
);
|
||||
|
||||
for (const post of publishedPosts) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { YoutubeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/provide
|
|||
import { PinterestSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/pinterest.dto';
|
||||
import { DribbbleDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dribbble.dto';
|
||||
import { TikTokDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/tiktok.dto';
|
||||
import { DiscordDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/discord.dto';
|
||||
import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/slack.dto';
|
||||
|
||||
export class EmptySettings {}
|
||||
export class Integration {
|
||||
|
|
@ -68,6 +70,8 @@ export class Post {
|
|||
{ value: PinterestSettingsDto, name: 'pinterest' },
|
||||
{ value: DribbbleDto, name: 'dribbble' },
|
||||
{ value: TikTokDto, name: 'tiktok' },
|
||||
{ value: DiscordDto, name: 'discord' },
|
||||
{ value: SlackDto, name: 'slack' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { IsDefined, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class DiscordDto {
|
||||
@MinLength(1)
|
||||
@IsDefined()
|
||||
@IsString()
|
||||
channel: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { IsDefined, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class SlackDto {
|
||||
@MinLength(1)
|
||||
@IsDefined()
|
||||
@IsString()
|
||||
channel: string;
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ import { PinterestProvider } from '@gitroom/nestjs-libraries/integrations/social
|
|||
import { DribbbleProvider } from '@gitroom/nestjs-libraries/integrations/social/dribbble.provider';
|
||||
import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.page.provider';
|
||||
import { ThreadsProvider } from '@gitroom/nestjs-libraries/integrations/social/threads.provider';
|
||||
import { DiscordProvider } from '@gitroom/nestjs-libraries/integrations/social/discord.provider';
|
||||
import { SlackProvider } from '@gitroom/nestjs-libraries/integrations/social/slack.provider';
|
||||
|
||||
const socialIntegrationList = [
|
||||
new XProvider(),
|
||||
|
|
@ -28,6 +30,8 @@ const socialIntegrationList = [
|
|||
new TiktokProvider(),
|
||||
new PinterestProvider(),
|
||||
new DribbbleProvider(),
|
||||
new DiscordProvider(),
|
||||
new SlackProvider(),
|
||||
];
|
||||
|
||||
const articleIntegrationList = [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
import {
|
||||
AuthTokenDetails,
|
||||
PostDetails,
|
||||
PostResponse,
|
||||
SocialProvider,
|
||||
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
|
||||
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
|
||||
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
|
||||
export class DiscordProvider extends SocialAbstract implements SocialProvider {
|
||||
identifier = 'discord';
|
||||
name = 'Discord';
|
||||
isBetweenSteps = false;
|
||||
scopes = ['identify', 'guilds'];
|
||||
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
|
||||
const { access_token, expires_in, refresh_token } = await (
|
||||
await this.fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
process.env.DISCORD_CLIENT_ID +
|
||||
':' +
|
||||
process.env.DISCORD_CLIENT_SECRET
|
||||
).toString('base64')}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
const { application } = await (
|
||||
await fetch('https://discord.com/api/oauth2/@me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
return {
|
||||
refreshToken: refresh_token,
|
||||
expiresIn: expires_in,
|
||||
accessToken: access_token,
|
||||
id: '',
|
||||
name: application.name,
|
||||
picture: '',
|
||||
username: '',
|
||||
};
|
||||
}
|
||||
async generateAuthUrl(refresh?: string) {
|
||||
const state = makeId(6);
|
||||
return {
|
||||
url: `https://discord.com/oauth2/authorize?client_id=${
|
||||
process.env.DISCORD_CLIENT_ID
|
||||
}&permissions=377957124096&response_type=code&redirect_uri=${encodeURIComponent(
|
||||
`${process.env.FRONTEND_URL}/integrations/social/discord${
|
||||
refresh ? `?refresh=${refresh}` : ''
|
||||
}`
|
||||
)}&integration_type=0&scope=bot+identify+guilds&state=${state}`,
|
||||
codeVerifier: makeId(10),
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(params: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
refresh?: string;
|
||||
}) {
|
||||
const [newCode, guild] = params.code.split(':');
|
||||
const { access_token, expires_in, refresh_token, scope } = await (
|
||||
await this.fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
code: newCode,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/discord`,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
process.env.DISCORD_CLIENT_ID +
|
||||
':' +
|
||||
process.env.DISCORD_CLIENT_SECRET
|
||||
).toString('base64')}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
this.checkScopes(this.scopes, scope.split(' '));
|
||||
|
||||
const { application } = await (
|
||||
await fetch('https://discord.com/api/oauth2/@me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
return {
|
||||
id: guild,
|
||||
name: application.name,
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token,
|
||||
expiresIn: expires_in,
|
||||
picture: `https://cdn.discordapp.com/avatars/${application.bot.id}/${application.bot.avatar}.png`,
|
||||
username: application.bot.username,
|
||||
};
|
||||
}
|
||||
|
||||
async channels(accessToken: string, params: any, id: string) {
|
||||
const list = await (
|
||||
await fetch(`https://discord.com/api/guilds/${id}/channels`, {
|
||||
headers: {
|
||||
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
console.log(list);
|
||||
|
||||
return list
|
||||
.filter((p: any) => p.type === 0 || p.type === 15)
|
||||
.map((p: any) => ({
|
||||
id: String(p.id),
|
||||
name: p.name,
|
||||
}));
|
||||
}
|
||||
|
||||
async post(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
postDetails: PostDetails[]
|
||||
): Promise<PostResponse[]> {
|
||||
let channel = postDetails[0].settings.channel;
|
||||
if (postDetails.length > 1) {
|
||||
const { id: threadId } = await (
|
||||
await fetch(
|
||||
`https://discord.com/api/channels/${postDetails[0].settings.channel}/threads`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: postDetails[0].message,
|
||||
auto_archive_duration: 1440,
|
||||
type: 11, // Public thread type
|
||||
}),
|
||||
}
|
||||
)
|
||||
).json();
|
||||
channel = threadId;
|
||||
}
|
||||
|
||||
const finalData = [];
|
||||
for (const post of postDetails) {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'payload_json',
|
||||
JSON.stringify({
|
||||
content: post.message,
|
||||
attachments: post.media?.map((p, index) => ({
|
||||
id: index,
|
||||
description: `Picture ${index}`,
|
||||
filename: p.url.split('/').pop(),
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
let index = 0;
|
||||
for (const media of post.media || []) {
|
||||
const loadMedia = await fetch(media.url);
|
||||
|
||||
form.append(
|
||||
`files[${index}]`,
|
||||
await loadMedia.blob(),
|
||||
media.url.split('/').pop()
|
||||
);
|
||||
index++;
|
||||
}
|
||||
|
||||
const data = await (
|
||||
await fetch(`https://discord.com/api/channels/${channel}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
|
||||
},
|
||||
body: form,
|
||||
})
|
||||
).json();
|
||||
|
||||
finalData.push({
|
||||
id: post.id,
|
||||
releaseURL: `https://discord.com/channels/${id}/${channel}/${data.id}`,
|
||||
postId: data.id,
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
return finalData;
|
||||
}
|
||||
|
||||
async changeNickname(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
name: string,
|
||||
) {
|
||||
await (await fetch(`https://discord.com/api/guilds/${id}/members/@me`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
nick: name,
|
||||
})
|
||||
})).json();
|
||||
|
||||
return {
|
||||
name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
|
||||
import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider';
|
||||
import dayjs from 'dayjs';
|
||||
import { Integration } from '@prisma/client';
|
||||
|
||||
export class LinkedinPageProvider
|
||||
extends LinkedinProvider
|
||||
|
|
@ -206,9 +207,10 @@ export class LinkedinPageProvider
|
|||
override async post(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
postDetails: PostDetails[]
|
||||
postDetails: PostDetails[],
|
||||
integration: Integration
|
||||
): Promise<PostResponse[]> {
|
||||
return super.post(id, accessToken, postDetails, 'company');
|
||||
return super.post(id, accessToken, postDetails, integration, 'company');
|
||||
}
|
||||
|
||||
async analytics(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
BadBody,
|
||||
SocialAbstract,
|
||||
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import { Integration } from '@prisma/client';
|
||||
|
||||
export class LinkedinProvider extends SocialAbstract implements SocialProvider {
|
||||
identifier = 'linkedin';
|
||||
|
|
@ -287,6 +288,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
|
|||
id: string,
|
||||
accessToken: string,
|
||||
postDetails: PostDetails[],
|
||||
integration: Integration,
|
||||
type = 'personal' as 'company' | 'personal'
|
||||
): Promise<PostResponse[]> {
|
||||
const [firstPost, ...restPosts] = postDetails;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
import {
|
||||
AuthTokenDetails,
|
||||
PostDetails,
|
||||
PostResponse,
|
||||
SocialProvider,
|
||||
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
|
||||
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
|
||||
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import dayjs from 'dayjs';
|
||||
import { Integration } from '@prisma/client';
|
||||
|
||||
export class SlackProvider extends SocialAbstract implements SocialProvider {
|
||||
identifier = 'slack';
|
||||
name = 'Slack';
|
||||
isBetweenSteps = false;
|
||||
scopes = ['identify', 'guilds'];
|
||||
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
|
||||
const { access_token, expires_in, refresh_token } = await (
|
||||
await this.fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
process.env.DISCORD_CLIENT_ID +
|
||||
':' +
|
||||
process.env.DISCORD_CLIENT_SECRET
|
||||
).toString('base64')}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
const { application } = await (
|
||||
await fetch('https://discord.com/api/oauth2/@me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
return {
|
||||
refreshToken: refresh_token,
|
||||
expiresIn: expires_in,
|
||||
accessToken: access_token,
|
||||
id: '',
|
||||
name: application.name,
|
||||
picture: '',
|
||||
username: '',
|
||||
};
|
||||
}
|
||||
async generateAuthUrl(refresh?: string) {
|
||||
const state = makeId(6);
|
||||
|
||||
return {
|
||||
url: `https://slack.com/oauth/v2/authorize?client_id=${
|
||||
process.env.SLACK_ID
|
||||
}&redirect_uri=${encodeURIComponent(
|
||||
`${
|
||||
process?.env?.FRONTEND_URL?.indexOf('https') === -1
|
||||
? 'https://redirectmeto.com/'
|
||||
: ''
|
||||
}${process?.env?.FRONTEND_URL}/integrations/social/slack${
|
||||
refresh ? `?refresh=${refresh}` : ''
|
||||
}`
|
||||
)}&scope=channels:read,chat:write,users:read,groups:read,channels:join,chat:write.customize&state=${state}`,
|
||||
codeVerifier: makeId(10),
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(params: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
refresh?: string;
|
||||
}) {
|
||||
const { access_token, team, bot_user_id, authed_user, ...all } = await (
|
||||
await this.fetch(`https://slack.com/api/oauth.v2.access`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.SLACK_ID!,
|
||||
client_secret: process.env.SLACK_SECRET!,
|
||||
code: params.code,
|
||||
redirect_uri: `${
|
||||
process?.env?.FRONTEND_URL?.indexOf('https') === -1
|
||||
? 'https://redirectmeto.com/'
|
||||
: ''
|
||||
}${process?.env?.FRONTEND_URL}/integrations/social/slack${
|
||||
params.refresh ? `?refresh=${params.refresh}` : ''
|
||||
}`,
|
||||
}),
|
||||
})
|
||||
).json();
|
||||
|
||||
const { user } = await (
|
||||
await fetch(`https://slack.com/api/users.info?user=${bot_user_id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
return {
|
||||
id: team.id,
|
||||
name: user.real_name,
|
||||
accessToken: access_token,
|
||||
refreshToken: 'null',
|
||||
expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
|
||||
picture: user.profile.image_48,
|
||||
username: user.name,
|
||||
};
|
||||
}
|
||||
|
||||
async channels(accessToken: string, params: any, id: string) {
|
||||
const list = await (
|
||||
await fetch(
|
||||
`https://slack.com/api/conversations.list?types=public_channel,private_channel`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
).json();
|
||||
|
||||
return list.channels.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
}));
|
||||
}
|
||||
|
||||
async post(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
postDetails: PostDetails[],
|
||||
integration: Integration
|
||||
): Promise<PostResponse[]> {
|
||||
await fetch(`https://slack.com/api/conversations.join`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: postDetails[0].settings.channel,
|
||||
}),
|
||||
});
|
||||
|
||||
let lastId = '';
|
||||
for (const post of postDetails) {
|
||||
const { ts } = await (
|
||||
await fetch(`https://slack.com/api/chat.postMessage`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: postDetails[0].settings.channel,
|
||||
username: integration.name,
|
||||
icon_url: integration.picture,
|
||||
...(lastId ? { thread_ts: lastId } : {}),
|
||||
blocks: [
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: post.message,
|
||||
},
|
||||
},
|
||||
...(post.media?.length
|
||||
? post.media.map((m) => ({
|
||||
type: 'image',
|
||||
image_url: m.url,
|
||||
alt_text: '',
|
||||
}))
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
})
|
||||
).json();
|
||||
|
||||
lastId = ts;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async changeProfilePicture(id: string, accessToken: string, url: string) {
|
||||
return {
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
async changeNickname(id: string, accessToken: string, name: string) {
|
||||
return {
|
||||
name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { Integration } from '@prisma/client';
|
||||
|
||||
export interface IAuthenticator {
|
||||
authenticate(params: {
|
||||
code: string;
|
||||
|
|
@ -6,7 +8,21 @@ export interface IAuthenticator {
|
|||
}): Promise<AuthTokenDetails>;
|
||||
refreshToken(refreshToken: string): Promise<AuthTokenDetails>;
|
||||
generateAuthUrl(refresh?: string): Promise<GenerateAuthUrlResponse>;
|
||||
analytics?(id: string, accessToken: string, date: number): Promise<AnalyticsData[]>;
|
||||
analytics?(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
date: number
|
||||
): Promise<AnalyticsData[]>;
|
||||
changeNickname?(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
name: string
|
||||
): Promise<{ name: string }>;
|
||||
changeProfilePicture?(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
url: string
|
||||
): Promise<{ url: string }>;
|
||||
}
|
||||
|
||||
export interface AnalyticsData {
|
||||
|
|
@ -35,7 +51,8 @@ export interface ISocialMediaIntegration {
|
|||
post(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
postDetails: PostDetails[]
|
||||
postDetails: PostDetails[],
|
||||
integration: Integration
|
||||
): Promise<PostResponse[]>; // Schedules a new post
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { FC, useState } from 'react';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface ImageSrc {
|
||||
|
|
@ -12,6 +12,11 @@ interface ImageSrc {
|
|||
const ImageWithFallback: FC<ImageSrc> = (props) => {
|
||||
const { src, fallbackSrc, ...rest } = props;
|
||||
const [imgSrc, setImgSrc] = useState(src);
|
||||
useEffect(() => {
|
||||
if (src !== imgSrc) {
|
||||
setImgSrc(src);
|
||||
}
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<Image
|
||||
|
|
|
|||
Loading…
Reference in New Issue