postiz/apps/frontend/src/components/launches/launches.component.tsx

471 lines
14 KiB
TypeScript

'use client';
import { AddProviderButton } from '@gitroom/frontend/components/launches/add.provider.component';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import Image from 'next/image';
import { groupBy, orderBy } from 'lodash';
import { CalendarWeekProvider } from '@gitroom/frontend/components/launches/calendar.context';
import { Filters } from '@gitroom/frontend/components/launches/filters';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
import clsx from 'clsx';
import { useUser } from '../layout/user.context';
import { Menu } from '@gitroom/frontend/components/launches/menu/menu';
import { useRouter, useSearchParams } from 'next/navigation';
import { Integration } from '@prisma/client';
import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { useFireEvents } from '@gitroom/helpers/utils/use.fire.events';
import { Calendar } from './calendar';
import { useDrag, useDrop } from 'react-dnd';
import { DNDProvider } from '@gitroom/frontend/components/launches/helpers/dnd.provider';
import { GeneratorComponent } from './generator/generator';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { NewPost } from '@gitroom/frontend/components/launches/new.post';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
interface MenuComponentInterface {
refreshChannel: (
integration: Integration & {
identifier: string;
}
) => () => void;
continueIntegration: (integration: Integration) => () => void;
totalNonDisabledChannels: number;
mutate: (shouldReload?: boolean) => void;
update: (shouldReload: boolean) => void;
}
export const OpenClose: FC<{
isOpen: boolean;
}> = (props) => {
const { isOpen } = props;
return (
<svg
width="11"
height="6"
viewBox="0 0 22 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={clsx(
'rotate-180 transition-all',
isOpen ? 'rotate-180' : 'rotate-90'
)}
>
<path
d="M21.9245 11.3823C21.8489 11.5651 21.7207 11.7213 21.5563 11.8312C21.3919 11.9411 21.1986 11.9998 21.0008 11.9998H1.00079C0.802892 12 0.609399 11.9414 0.444805 11.8315C0.280212 11.7217 0.151917 11.5654 0.076165 11.3826C0.000412494 11.1998 -0.0193921 10.9986 0.0192583 10.8045C0.0579087 10.6104 0.153276 10.4322 0.293288 10.2923L10.2933 0.29231C10.3862 0.199333 10.4964 0.125575 10.6178 0.0752506C10.7392 0.0249263 10.8694 -0.000976562 11.0008 -0.000976562C11.1322 -0.000976562 11.2623 0.0249263 11.3837 0.0752506C11.5051 0.125575 11.6154 0.199333 11.7083 0.29231L21.7083 10.2923C21.8481 10.4322 21.9433 10.6105 21.9818 10.8045C22.0202 10.9985 22.0003 11.1996 21.9245 11.3823Z"
fill="currentColor"
/>
</svg>
);
};
export const MenuGroupComponent: FC<
MenuComponentInterface & {
changeItemGroup: (id: string, group: string) => void;
group: {
id: string;
name: string;
values: Array<
Integration & {
identifier: string;
changeProfilePicture: boolean;
changeNickName: boolean;
}
>;
};
}
> = (props) => {
const {
group,
mutate,
update,
continueIntegration,
totalNonDisabledChannels,
refreshChannel,
changeItemGroup,
} = props;
const [isOpen, setIsOpen] = useState(
!!+(localStorage.getItem(group.name + '_isOpen') || '1')
);
const changeOpenClose = useCallback(
(e: any) => {
setIsOpen(!isOpen);
localStorage.setItem(group.name + '_isOpen', isOpen ? '0' : '1');
e.stopPropagation();
},
[isOpen]
);
const [collectedProps, drop] = useDrop(() => ({
accept: 'menu',
drop: (
item: {
id: string;
},
monitor
) => {
changeItemGroup(item.id, group.id);
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
}),
}));
return (
<div
className="gap-[16px] flex flex-col relative"
// @ts-ignore
ref={drop}
>
{collectedProps.isOver && (
<div className="absolute start-0 top-0 w-full h-full pointer-events-none">
<div className="w-full h-full start-0 top-0 relative">
<div className="bg-white/30 w-full h-full p-[8px] box-content rounded-md" />
</div>
</div>
)}
{!!group.name && (
<div
className="flex items-center gap-[5px] cursor-pointer"
onClick={changeOpenClose}
>
<div>
<OpenClose isOpen={isOpen} />
</div>
<div>{group.name}</div>
</div>
)}
<div
className={clsx(
'gap-[16px] flex flex-col relative',
!isOpen && 'hidden'
)}
>
{group.values.map((integration) => (
<MenuComponent
key={integration.id}
integration={integration}
mutate={mutate}
continueIntegration={continueIntegration}
update={update}
refreshChannel={refreshChannel}
totalNonDisabledChannels={totalNonDisabledChannels}
/>
))}
</div>
</div>
);
};
export const MenuComponent: FC<
MenuComponentInterface & {
integration: Integration & {
identifier: string;
changeProfilePicture: boolean;
changeNickName: boolean;
refreshNeeded?: boolean;
};
}
> = (props) => {
const {
totalNonDisabledChannels,
continueIntegration,
refreshChannel,
mutate,
update,
integration,
} = props;
const user = useUser();
const [collected, drag, dragPreview] = useDrag(() => ({
type: 'menu',
item: {
id: integration.id,
},
}));
return (
<div
// @ts-ignore
ref={dragPreview}
{...(integration.refreshNeeded && {
onClick: refreshChannel(integration),
'data-tooltip-id': 'tooltip',
'data-tooltip-content': 'Channel disconnected, click to reconnect.',
})}
key={integration.id}
className={clsx(
'flex gap-[8px] items-center',
integration.refreshNeeded && 'cursor-pointer'
)}
>
<div
className={clsx(
'relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth',
integration.disabled && 'opacity-50'
)}
>
{(integration.inBetweenSteps || integration.refreshNeeded) && (
<div
className="absolute start-0 top-0 w-[39px] h-[46px] cursor-pointer"
onClick={
integration.refreshNeeded
? refreshChannel(integration)
: continueIntegration(integration)
}
>
<div className="bg-red-500 w-[15px] h-[15px] rounded-full -start-[5px] -top-[5px] absolute z-[200] text-[10px] flex justify-center items-center">
!
</div>
<div className="bg-primary/60 w-[39px] h-[46px] start-0 top-0 absolute rounded-full z-[199]" />
</div>
)}
<ImageWithFallback
fallbackSrc={`/icons/platforms/${integration.identifier}.png`}
src={integration.picture || '/no-picture.jpg'}
className="rounded-full"
alt={integration.identifier}
width={32}
height={32}
/>
{integration.identifier === 'youtube' ? (
<img
src="/icons/platforms/youtube.svg"
className="absolute z-10 -bottom-[5px] -end-[5px]"
width={20}
/>
) : (
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -end-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
)}
</div>
<div
// @ts-ignore
ref={drag}
{...(integration.disabled &&
totalNonDisabledChannels === user?.totalChannels
? {
'data-tooltip-id': 'tooltip',
'data-tooltip-content':
'This channel is disabled, please upgrade your plan to enable it.',
}
: {})}
role="Handle"
className={clsx(
'flex-1 whitespace-nowrap text-ellipsis overflow-hidden cursor-move',
integration.disabled && 'opacity-50'
)}
>
{integration.name}
</div>
<Menu
canChangeProfilePicture={integration.changeProfilePicture}
canChangeNickName={integration.changeNickName}
refreshChannel={refreshChannel}
mutate={mutate}
onChange={update}
id={integration.id}
canEnable={
user?.totalChannels! > totalNonDisabledChannels &&
integration.disabled
}
canDisable={!integration.disabled}
/>
</div>
);
};
export const LaunchesComponent = () => {
const fetch = useFetch();
const user = useUser();
const { billingEnabled } = useVariables();
const router = useRouter();
const search = useSearchParams();
const toast = useToaster();
const fireEvents = useFireEvents();
const t = useT();
const [reload, setReload] = useState(false);
const load = useCallback(async (path: string) => {
return (await (await fetch(path)).json()).integrations;
}, []);
const {
isLoading,
data: integrations,
mutate,
} = useSWR('/integrations/list', load, {
fallbackData: [],
});
const totalNonDisabledChannels = useMemo(() => {
return (
integrations?.filter((integration: any) => !integration.disabled)
?.length || 0
);
}, [integrations]);
const changeItemGroup = useCallback(
async (id: string, group: string) => {
mutate(
integrations.map((integration: any) => {
if (integration.id === id) {
return {
...integration,
customer: {
id: group,
},
};
}
return integration;
}),
false
);
await fetch(`/integrations/${id}/group`, {
method: 'PUT',
body: JSON.stringify({
group,
}),
});
mutate();
},
[integrations]
);
const sortedIntegrations = useMemo(() => {
return orderBy(
integrations,
['type', 'disabled', 'identifier'],
['desc', 'asc', 'asc']
);
}, [integrations]);
const menuIntegrations = useMemo(() => {
return orderBy(
Object.values(
groupBy(sortedIntegrations, (o) => o?.customer?.id || '')
).map((p) => ({
name: (p[0].customer?.name || '') as string,
id: (p[0].customer?.id || '') as string,
isEmpty: p.length === 0,
values: orderBy(
p,
['type', 'disabled', 'identifier'],
['desc', 'asc', 'asc']
),
})),
['isEmpty', 'name'],
['desc', 'asc']
);
}, [sortedIntegrations]);
const update = useCallback(async (shouldReload: boolean) => {
if (shouldReload) {
setReload(true);
}
await mutate();
if (shouldReload) {
setReload(false);
}
}, []);
const continueIntegration = useCallback(
(integration: any) => async () => {
router.push(
`/launches?added=${integration.identifier}&continue=${integration.id}`
);
},
[]
);
const refreshChannel = useCallback(
(
integration: Integration & {
identifier: string;
}
) =>
async () => {
const { url } = await (
await fetch(
`/integrations/social/${integration.identifier}?refresh=${integration.internalId}`,
{
method: 'GET',
}
)
).json();
window.location.href = url;
},
[]
);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
if (search.get('msg')) {
toast.show(search.get('msg')!, 'warning');
window?.opener?.postMessage(
{
msg: search.get('msg')!,
success: false,
},
'*'
);
}
if (search.get('added')) {
fireEvents('channel_added');
window?.opener?.postMessage(
{
msg: 'Channel added',
success: true,
},
'*'
);
}
if (window.opener) {
window.close();
}
}, []);
if (isLoading || reload) {
return <LoadingComponent />;
}
// @ts-ignore
return (
<DNDProvider>
<CalendarWeekProvider integrations={sortedIntegrations}>
<div className="flex flex-1 flex-col">
<div className="flex flex-1 relative">
<div className="outline-none w-full h-full grid grid-cols[1fr] md:grid-cols-[220px_minmax(0,1fr)] gap-[30px] scrollbar scrollbar-thumb-tableBorder scrollbar-track-secondary">
<div className="bg-third p-[16px] flex flex-col gap-[24px] min-h-[100%]">
<h2 className="text-[20px]">{t('channels')}</h2>
<div className="gap-[16px] flex flex-col select-none">
{sortedIntegrations.length === 0 && (
<div className="text-[12px]">
{t('no_channels', 'No channels')}
</div>
)}
{menuIntegrations.map((menu) => (
<MenuGroupComponent
changeItemGroup={changeItemGroup}
key={menu.name}
group={menu}
mutate={mutate}
continueIntegration={continueIntegration}
update={update}
refreshChannel={refreshChannel}
totalNonDisabledChannels={totalNonDisabledChannels}
/>
))}
</div>
<div className="flex flex-col gap-[10px]">
<AddProviderButton update={() => update(true)} />
{sortedIntegrations?.length > 0 && <NewPost />}
{sortedIntegrations?.length > 0 &&
user?.tier?.ai &&
billingEnabled && <GeneratorComponent />}
<div className="mt-[5px]">
{process.env.NEXT_PUBLIC_VERSION ? `v${process.env.NEXT_PUBLIC_VERSION}` : ''}
</div>
</div>
</div>
<div className="flex-1 flex flex-col gap-[14px]">
<Filters />
<Calendar />
</div>
</div>
</div>
</div>
</CalendarWeekProvider>
</DNDProvider>
);
};