feat: a bunch of cose

This commit is contained in:
Nevo David 2024-02-17 01:13:02 +07:00
parent bfa36a0dcf
commit c215375bea
70 changed files with 4229 additions and 253 deletions

View File

@ -14,7 +14,8 @@
"rules": {
"@typescript-eslint/no-non-null-asserted-optional-chain": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off"
"@typescript-eslint/ban-ts-comment": "off",
"react/display-name": "off"
}
},
{

View File

@ -11,15 +11,21 @@ import {PermissionsService} from "@gitroom/backend/services/auth/permissions/per
import {IntegrationsController} from "@gitroom/backend/api/routes/integrations.controller";
import {IntegrationManager} from "@gitroom/nestjs-libraries/integrations/integration.manager";
import {SettingsController} from "@gitroom/backend/api/routes/settings.controller";
import {BullMqModule} from "@gitroom/nestjs-libraries/bull-mq-transport/bull-mq.module";
import {ioRedis} from "@gitroom/nestjs-libraries/redis/redis.service";
import {PostsController} from "@gitroom/backend/api/routes/posts.controller";
const authenticatedController = [
UsersController,
AnalyticsController,
IntegrationsController,
SettingsController
SettingsController,
PostsController
];
@Module({
imports: [],
imports: [BullMqModule.forRoot({
connection: ioRedis
})],
controllers: [StripeController, AuthController, ...authenticatedController],
providers: [
AuthService,
@ -27,7 +33,7 @@ const authenticatedController = [
AuthMiddleware,
PoliciesGuard,
PermissionsService,
IntegrationManager
IntegrationManager,
],
get exports() {
return [...this.imports, ...this.providers];

View File

@ -41,7 +41,6 @@ export class AuthController {
@Res({ passthrough: true }) response: Response
) {
try {
console.log('heghefrgefg');
const jwt = await this._authService.routeAuth(body.provider, body);
response.cookie('auth', jwt, {
domain: '.' + new URL(process.env.FRONTEND_URL!).hostname,

View File

@ -5,6 +5,7 @@ import {IntegrationManager} from "@gitroom/nestjs-libraries/integrations/integra
import {IntegrationService} from "@gitroom/nestjs-libraries/database/prisma/integrations/integration.service";
import {GetOrgFromRequest} from "@gitroom/nestjs-libraries/user/org.from.request";
import {Organization} from "@prisma/client";
import {ApiKeyDto} from "@gitroom/nestjs-libraries/dtos/integrations/api.key.dto";
@Controller('/integrations')
export class IntegrationsController {
@ -13,6 +14,18 @@ export class IntegrationsController {
private _integrationService: IntegrationService
) {
}
@Get('/')
getIntegration() {
return this._integrationManager.getAllIntegrations();
}
@Get('/list')
async getIntegrationList(
@GetOrgFromRequest() org: Organization,
) {
return {integrations: (await this._integrationService.getIntegrationsList(org.id)).map(p => ({name: p.name, id: p.id, picture: p.picture, identifier: p.providerIdentifier, type: p.type}))};
}
@Get('/social/:integration')
async getIntegrationUrl(
@Param('integration') integration: string
@ -25,14 +38,14 @@ export class IntegrationsController {
const {codeVerifier, state, url} = await integrationProvider.generateAuthUrl();
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300);
return url;
return {url};
}
@Post('/article/:integration/connect')
async connectArticle(
@GetOrgFromRequest() org: Organization,
@Param('integration') integration: string,
@Body('code') api: string
@Body() api: ApiKeyDto
) {
if (!this._integrationManager.getAllowedArticlesIntegrations().includes(integration)) {
throw new Error('Integration not allowed');
@ -43,13 +56,13 @@ export class IntegrationsController {
}
const integrationProvider = this._integrationManager.getArticlesIntegration(integration);
const {id, name, token} = await integrationProvider.authenticate(api);
const {id, name, token, picture} = await integrationProvider.authenticate(api.api);
if (!id) {
throw new Error('Invalid api key');
}
return this._integrationService.createIntegration(org.id, name, 'article', String(id), integration, token);
return this._integrationService.createIntegration(org.id, name, picture,'article', String(id), integration, token);
}
@Post('/social/:integration/connect')
@ -68,7 +81,7 @@ export class IntegrationsController {
}
const integrationProvider = this._integrationManager.getSocialIntegration(integration);
const {accessToken, expiresIn, refreshToken, id, name} = await integrationProvider.authenticate({
const {accessToken, expiresIn, refreshToken, id, name, picture} = await integrationProvider.authenticate({
code: body.code,
codeVerifier: getCodeVerifier
});
@ -77,6 +90,6 @@ export class IntegrationsController {
throw new Error('Invalid api key');
}
return this._integrationService.createIntegration(org.id, name, 'social', String(id), integration, accessToken, refreshToken, expiresIn);
return this._integrationService.createIntegration(org.id, name, picture, 'social', String(id), integration, accessToken, refreshToken, expiresIn);
}
}

View File

@ -0,0 +1,21 @@
import {Body, Controller, Post} from '@nestjs/common';
import {PostsService} from "@gitroom/nestjs-libraries/database/prisma/posts/posts.service";
import {GetOrgFromRequest} from "@gitroom/nestjs-libraries/user/org.from.request";
import {Organization} from "@prisma/client";
import {CreatePostDto} from "@gitroom/nestjs-libraries/dtos/posts/create.post.dto";
@Controller('/posts')
export class PostsController {
constructor(
private _postsService: PostsService
) {
}
@Post('/')
createPost(
@GetOrgFromRequest() org: Organization,
@Body() body: CreatePostDto
) {
return this._postsService.createPost(org.id, body);
}
}

View File

@ -1,3 +1,5 @@
process.env.TZ='UTC';
import cookieParser from 'cookie-parser';
import {Logger, ValidationPipe} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 983 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -1,5 +1,9 @@
import {LaunchesComponent} from "@gitroom/frontend/components/launches/launches.component";
import {internalFetch} from "@gitroom/helpers/utils/internal.fetch";
export default async function Index() {
const {integrations} = await (await internalFetch('/integrations/list')).json();
return (
<>asd</>
<LaunchesComponent integrations={integrations} />
);
}

View File

@ -1,4 +1,3 @@
import '../global.css';
import {LayoutSettings} from "@gitroom/frontend/components/layout/layout.settings";
export default async function Layout({ children }: { children: React.ReactNode }) {

View File

@ -2,9 +2,9 @@
@tailwind components;
@tailwind utilities;
/*body, html {*/
/* overflow-x: hidden;*/
/*}*/
body, html {
background-color: black;
}
.box {
position: relative;
padding: 8px 24px;
@ -41,7 +41,7 @@
}
.table1 thead {
background-color: #111423;
background-color: #0F1524;
height: 44px;
font-size: 12px;
border-bottom: 1px solid #28344F;
@ -55,4 +55,22 @@
padding: 16px 24px;
font-family: Inter;
font-size: 14px;
}
.swal2-modal {
background-color: black !important;
border: 2px solid #0B101B;
}
.swal2-modal * {
color: white !important;
}
.swal2-icon {
color: white !important;
border-color: white !important;
}
.swal2-confirm {
background-color: #262373 !important;
}

View File

@ -1,3 +1,5 @@
import './global.css';
import LayoutContext from "@gitroom/frontend/components/layout/layout.context";
import {ReactNode} from "react";
import {Chakra_Petch} from "next/font/google";

View File

@ -0,0 +1,349 @@
'use client';
import { FC, useCallback, useEffect, useState } from 'react';
import dayjs from 'dayjs';
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
import Image from 'next/image';
import clsx from 'clsx';
import MDEditor from '@uiw/react-md-editor';
import { usePreventWindowUnload } from '@gitroom/react/helpers/use.prevent.window.unload';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useModals } from '@mantine/modals';
import { ShowAllProviders } from '@gitroom/frontend/components/launches/providers/show.all.providers';
import { useHideTopEditor } from '@gitroom/frontend/components/launches/helpers/use.hide.top.editor';
import { Button } from '@gitroom/react/form/button';
import { IntegrationContext } from '@gitroom/frontend/components/launches/helpers/use.integration';
import {
getValues,
resetValues,
} from '@gitroom/frontend/components/launches/helpers/use.values';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import {
useMoveToIntegration,
useMoveToIntegrationListener,
} from '@gitroom/frontend/components/launches/helpers/use.move.to.integration';
export const PickPlatforms: FC<{
integrations: Integrations[];
selectedIntegrations: Integrations[];
onChange: (integrations: Integrations[]) => void;
singleSelect: boolean;
}> = (props) => {
const { integrations, selectedIntegrations, onChange } = props;
const [selectedAccounts, setSelectedAccounts] =
useState<Integrations[]>(selectedIntegrations);
useEffect(() => {
if (
props.singleSelect &&
selectedAccounts.length &&
integrations.indexOf(selectedAccounts?.[0]) === -1
) {
addPlatform(integrations[0])();
}
}, [integrations, selectedAccounts]);
useMoveToIntegrationListener(props.singleSelect, (identifier) => {
const findIntegration = integrations.find(
(p) => p.identifier === identifier
);
if (findIntegration) {
addPlatform(findIntegration)();
}
});
const addPlatform = useCallback(
(integration: Integrations) => async () => {
if (props.singleSelect) {
onChange([integration]);
setSelectedAccounts([integration]);
return;
}
if (selectedAccounts.includes(integration)) {
const changedIntegrations = selectedAccounts.filter(
({ id }) => id !== integration.id
);
if (
!props.singleSelect &&
!(await deleteDialog(
'Are you sure you want to remove this platform?'
))
) {
return;
}
onChange(changedIntegrations);
setSelectedAccounts(changedIntegrations);
} else {
const changedIntegrations = [...selectedAccounts, integration];
onChange(changedIntegrations);
setSelectedAccounts(changedIntegrations);
}
},
[selectedAccounts]
);
return (
<div className="flex">
{integrations.map((integration) =>
!props.singleSelect ? (
<div
key={integration.id}
className="flex gap-[8px] items-center mr-[10px]"
>
<div
onClick={addPlatform(integration)}
className={clsx(
'cursor-pointer relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth filter transition-all duration-500',
selectedAccounts.findIndex((p) => p.id === integration.id) ===
-1
? 'grayscale opacity-65'
: 'grayscale-0'
)}
>
<img
src={integration.picture}
className="rounded-full"
alt={integration.identifier}
width={32}
height={32}
/>
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
</div>
</div>
) : (
<div key={integration.id} className="flex w-full">
<div
onClick={addPlatform(integration)}
className={clsx(
'cursor-pointer flex-1 relative h-[40px] flex justify-center items-center bg-fifth filter transition-all duration-500',
selectedAccounts.findIndex((p) => p.id === integration.id) ===
-1
? 'bg-sixth'
: 'bg-forth'
)}
>
<div className="flex items-center justify-center gap-[10px]">
<div className="relative">
<img
src={integration.picture}
className="rounded-full"
alt={integration.identifier}
width={32}
height={32}
/>
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
</div>
<div>{integration.name}</div>
</div>
</div>
</div>
)
)}
</div>
);
};
export const PreviewComponent: FC<{
integrations: Integrations[];
editorValue: string[];
}> = (props) => {
const { integrations, editorValue } = props;
const [selectedIntegrations, setSelectedIntegrations] = useState([
integrations[0],
]);
useEffect(() => {
if (integrations.indexOf(selectedIntegrations[0]) === -1) {
setSelectedIntegrations([integrations[0]]);
}
}, [integrations, selectedIntegrations]);
return (
<div>
<PickPlatforms
integrations={integrations}
selectedIntegrations={selectedIntegrations}
onChange={setSelectedIntegrations}
singleSelect={true}
/>
<IntegrationContext.Provider
value={{ value: editorValue, integration: selectedIntegrations?.[0] }}
>
<ShowAllProviders
value={editorValue}
integrations={integrations}
selectedProvider={selectedIntegrations?.[0]}
/>
</IntegrationContext.Provider>
</div>
);
};
export const AddEditModal: FC<{
date: dayjs.Dayjs;
integrations: Integrations[];
}> = (props) => {
const { date, integrations } = props;
// selected integrations to allow edit
const [selectedIntegrations, setSelectedIntegrations] = useState<
Integrations[]
>([]);
// value of each editor
const [value, setValue] = useState<string[]>(['']);
const fetch = useFetch();
// prevent the window exit by mistake
usePreventWindowUnload(true);
// hook to move the settings in the right place to fix missing fields
const moveToIntegration = useMoveToIntegration();
// hook to test if the top editor should be hidden
const showHide = useHideTopEditor();
// hook to open a new modal
const modal = useModals();
// if the user exit the popup we reset the global variable with all the values
useEffect(() => {
return () => {
resetValues();
};
}, []);
// Change the value of the global editor
const changeValue = useCallback(
(index: number) => (newValue: string) => {
return setValue((prev) => {
prev[index] = newValue;
return [...prev];
});
},
[value]
);
// Add another editor
const addValue = useCallback(
(index: number) => () => {
setValue((prev) => {
prev.splice(index + 1, 0, '');
return [...prev];
});
},
[value]
);
// override the close modal to ask the user if he is sure to close
const askClose = useCallback(async () => {
if (
await deleteDialog(
'Are you sure you want to close this modal? (all data will be lost)',
'Yes, close it!'
)
) {
modal.closeAll();
}
}, []);
// function to send to the server and save
const schedule = useCallback(async () => {
const values = getValues();
const allKeys = Object.keys(values).map((v) => ({
integration: integrations.find((p) => p.id === v),
value: values[v].posts,
valid: values[v].isValid,
settings: values[v].settings(),
}));
for (const key of allKeys) {
if (!key.valid) {
moveToIntegration(key?.integration?.identifier!);
return;
}
}
await fetch('/posts', {
method: 'POST',
body: JSON.stringify({
date: date.utc().format('YYYY-MM-DDTHH:mm:ss'),
posts: allKeys,
}),
});
}, []);
return (
<>
<button
onClick={askClose}
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-black 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="flex flex-col gap-[20px]">
<PickPlatforms
integrations={integrations}
selectedIntegrations={[]}
singleSelect={false}
onChange={setSelectedIntegrations}
/>
{!showHide.hideTopEditor ? (
<>
{value.map((p, index) => (
<>
<MDEditor
key={`edit_${index}`}
height={value.length > 1 ? 150 : 500}
value={p}
preview="edit"
// @ts-ignore
onChange={changeValue(index)}
/>
<div>
<Button onClick={addValue(index)}>Add post</Button>
</div>
</>
))}
</>
) : (
<div className="h-[100px] flex justify-center items-center bg-sixth border-tableBorder border-2">
Global Editor Hidden
</div>
)}
{!!selectedIntegrations.length && (
<PreviewComponent
integrations={selectedIntegrations}
editorValue={value}
/>
)}
<Button onClick={schedule}>Schedule</Button>
</div>
</>
);
};

View File

@ -0,0 +1,109 @@
"use client";
import {useModals} from "@mantine/modals";
import {FC, useCallback} from "react";
import {useFetch} from "@gitroom/helpers/utils/custom.fetch";
import {Input} from "@gitroom/react/form/input";
import {FieldValues, FormProvider, useForm} from "react-hook-form";
import {Button} from "@gitroom/react/form/button";
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import {ApiKeyDto} from "@gitroom/nestjs-libraries/dtos/integrations/api.key.dto";
import {useRouter} from "next/navigation";
const resolver = classValidatorResolver(ApiKeyDto);
export const AddProviderButton = () => {
const modal = useModals();
const fetch = useFetch();
const openModal = useCallback(async () => {
const data = await (await fetch('/integrations')).json();
modal.openModal({
title: 'Add Channel',
children: <AddProviderComponent {...data} />
})
}, []);
return (
<button
className="text-white p-[8px] rounded-md bg-forth"
onClick={openModal}
>
Add Channel
</button>
);
}
export const ApiModal: FC<{identifier: string, name: string}> = (props) => {
const fetch = useFetch();
const router = useRouter();
const modal = useModals();
const methods = useForm({
mode: 'onChange',
resolver
});
const submit = useCallback(async (data: FieldValues) => {
const add = await fetch(`/integrations/article/${props.identifier}/connect`, {
method: 'POST',
body: JSON.stringify({api: data.api})
});
if (add.ok) {
modal.closeAll();
router.refresh();
return ;
}
methods.setError('api', {
message: 'Invalid API key'
});
}, []);
return (
<FormProvider {...methods}>
<form className="gap-[8px] flex flex-col" onSubmit={methods.handleSubmit(submit)}>
<div><Input label="API Key" name="api"/></div>
<div><Button type="submit">Add platform</Button></div>
</form>
</FormProvider>
)
}
export const AddProviderComponent: FC<{social: Array<{identifier: string, name: string}>, article: Array<{identifier: string, name: string}>}> = (props) => {
const fetch = useFetch();
const modal = useModals();
const {social, article} = props;
const getSocialLink = useCallback((identifier: string) => async () => {
const {url} = await (await fetch('/integrations/social/' + identifier)).json();
window.location.href = url;
}, []);
const showApiButton = useCallback((identifier: string, name: string) => async () => {
modal.openModal({
title: `Add ${name}`,
children: <ApiModal name={name} identifier={identifier} />
})
}, []);
return (
<div className="w-full flex flex-col gap-[20px]">
<div className="flex flex-col">
<h2>Social</h2>
<div className="flex flex-wrap gap-[10px]">
{social.map((item) => (
<div key={item.identifier} onClick={getSocialLink(item.identifier)} className="w-[100px] h-[100px] bg-forth text-white justify-center items-center flex">
{item.name}
</div>
))}
</div>
</div>
<div className="flex flex-col">
<h2>Articles</h2>
<div className="flex flex-wrap gap-[10px]">
{article.map((item) => (
<div key={item.identifier} onClick={showApiButton(item.identifier, item.name)} className="w-[100px] h-[100px] bg-forth text-white justify-center items-center flex">
{item.name}
</div>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,40 @@
'use client';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import isoWeek from 'dayjs/plugin/isoWeek';
import utc from 'dayjs/plugin/utc';
import {createContext, FC, ReactNode, useContext, useState} from 'react';
import dayjs from 'dayjs';
dayjs.extend(weekOfYear);
dayjs.extend(isoWeek);
dayjs.extend(utc);
const CalendarContext = createContext({
currentWeek: dayjs().week(),
integrations: [] as Integrations[],
setFilters: (filters: { currentWeek: number }) => {},
});
export interface Integrations {
name: string;
id: string;
identifier: string;
type: string;
picture: string;
}
export const CalendarWeekProvider: FC<{ children: ReactNode, integrations: Integrations[] }> = ({
children,
integrations
}) => {
const [filters, setFilters] = useState({
currentWeek: dayjs().week(),
});
return (
<CalendarContext.Provider value={{ ...filters, integrations, setFilters }}>
{children}
</CalendarContext.Provider>
);
};
export const useCalendar = () => useContext(CalendarContext);

View File

@ -0,0 +1,141 @@
'use client';
import { FC, useCallback, useMemo } from 'react';
import {
useCalendar,
} from '@gitroom/frontend/components/launches/calendar.context';
import dayjs from 'dayjs';
import { useModals } from '@mantine/modals';
import {AddEditModal} from "@gitroom/frontend/components/launches/add.edit.model";
import clsx from "clsx";
const days = [
'',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
];
const hours = [
'00:00',
'01:00',
'02:00',
'03:00',
'04:00',
'05:00',
'06:00',
'07:00',
'08:00',
'09:00',
'10:00',
'11:00',
'12:00',
'13:00',
'14:00',
'15:00',
'16:00',
'17:00',
'18:00',
'19:00',
'20:00',
'21:00',
'22:00',
'23:00',
];
const CalendarColumn: FC<{ day: number; hour: string }> = (props) => {
const { day, hour } = props;
const week = useCalendar();
const modal = useModals();
const getDate = useMemo(() => {
const date =
dayjs().isoWeek(week.currentWeek).isoWeekday(day).format('YYYY-MM-DD') +
'T' +
hour +
':00';
return dayjs(date);
}, [week.currentWeek]);
const addModal = useCallback(() => {
modal.openModal({
closeOnClickOutside: false,
closeOnEscape: false,
withCloseButton: false,
children: (
<AddEditModal integrations={week.integrations} date={getDate} />
),
size: '80%',
title: `Adding posts for ${getDate.format('DD/MM/YYYY HH:mm')}`,
});
}, []);
const isBeforeNow = useMemo(() => {
return getDate.isBefore(dayjs());
}, [getDate]);
return (
<div className={clsx("h-[calc(216px/6)] text-[12px] hover:bg-white/20 pointer flex justify-center items-center", isBeforeNow && 'bg-white/10 pointer-events-none')}>
<div
onClick={addModal}
className="flex-1 h-full flex justify-center items-center"
>
{isBeforeNow ? '' : '+ Add'}
</div>
</div>
);
};
export const Calendar = () => {
return (
<div>
<div className="grid grid-cols-8 text-center border-tableBorder border-r">
{days.map((day) => (
<div
className="border-tableBorder border-l border-b h-[36px] border-t flex items-center justify-center bg-input text-[14px] sticky top-0"
key={day}
>
{day}
</div>
))}
{hours.map((hour) =>
days.map((day, index) => (
<>
{index === 0 ? (
<div
className="border-tableBorder border-l border-b h-[216px]"
key={day + hour}
>
{['00', '10', '20', '30', '40', '50'].map((num) => (
<div
key={day + hour + num}
className="h-[calc(216px/6)] text-[12px] flex justify-center items-center"
>
{hour.split(':')[0] + ':' + num}
</div>
))}
</div>
) : (
<div
className="border-tableBorder border-l border-b h-[216px] flex flex-col"
key={day + hour}
>
{['00', '10', '20', '30', '40', '50'].map((num) => (
<CalendarColumn
key={day + hour + num}
day={index}
hour={hour.split(':')[0] + ':' + num}
/>
))}
</div>
)}
</>
))
)}
</div>
</div>
);
};

View File

@ -0,0 +1,9 @@
"use client";
import {useCalendar} from "@gitroom/frontend/components/launches/calendar.context";
import dayjs from "dayjs";
export const Filters = () => {
const week = useCalendar();
const betweenDates = dayjs().isoWeek(week.currentWeek).startOf('isoWeek').format('DD/MM/YYYY') + ' - ' + dayjs().isoWeek(week.currentWeek).endOf('isoWeek').format('DD/MM/YYYY');
return <div className="text-white h-[50px]" onClick={() => week.setFilters({currentWeek: week.currentWeek + 1})}>Week {week.currentWeek} ({betweenDates})</div>;
};

View File

@ -0,0 +1,30 @@
import removeMd from "remove-markdown";
import {useMemo} from "react";
export const useFormatting = (text: string[], params: {
removeMarkdown?: boolean,
saveBreaklines?: boolean,
specialFunc?: (text: string) => string,
}) => {
return useMemo(() => {
return text.map((value) => {
let newText = value;
if (params.saveBreaklines) {
newText = newText.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢');
}
if (params.removeMarkdown) {
newText = removeMd(value);
}
if (params.saveBreaklines) {
newText = newText.replace('𝔫𝔢𝔴𝔩𝔦𝔫𝔢', '\n');
}
if (params.specialFunc) {
newText = params.specialFunc(newText);
}
return {
text: newText,
count: params.removeMarkdown && params.saveBreaklines ? newText.replace(/\n/g, ' ').length : newText.length,
}
});
}, [text]);
}

View File

@ -0,0 +1,34 @@
"use client";
import EventEmitter from 'events';
import {useEffect, useState} from "react";
const emitter = new EventEmitter();
export const useHideTopEditor = () => {
const [hideTopEditor, setHideTopEditor] = useState(false);
useEffect(() => {
const hide = () => {
setHideTopEditor(true);
};
const show = () => {
setHideTopEditor(false);
};
emitter.on('hide', hide);
emitter.on('show', show);
return () => {
emitter.off('hide', hide);
emitter.off('show', show);
};
}, []);
return {
hideTopEditor,
hide: () => {
emitter.emit('hide');
},
show: () => {
emitter.emit('show');
}
}
}

View File

@ -0,0 +1,8 @@
"use client";
import {createContext, useContext} from "react";
import {Integrations} from "@gitroom/frontend/components/launches/calendar.context";
export const IntegrationContext = createContext<{integration: Integrations|undefined, value: string[]}>({integration: undefined, value: []});
export const useIntegration = () => useContext(IntegrationContext);

View File

@ -0,0 +1,30 @@
'use client';
import EventEmitter from 'events';
import {useCallback, useEffect} from 'react';
const emitter = new EventEmitter();
export const useMoveToIntegration = () => {
return useCallback((identifier: string) => {
emitter.emit('moveToIntegration', identifier);
}, []);
};
export const useMoveToIntegrationListener = (
enabled: boolean,
callback: (identifier: string) => void
) => {
useEffect(() => {
if (!enabled) {
return;
}
return load();
}, []);
const load = useCallback(() => {
emitter.on('moveToIntegration', callback);
return () => {
emitter.off('moveToIntegration', callback);
};
}, []);
};

View File

@ -0,0 +1,57 @@
import {useEffect, useMemo} from 'react';
import { useForm } from 'react-hook-form';
import { UseFormProps } from 'react-hook-form/dist/types';
import {allProvidersSettings} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/all.providers.settings";
import {classValidatorResolver} from "@hookform/resolvers/class-validator";
const finalInformation = {} as {
[key: string]: { posts: string[]; settings: () => object; isValid: boolean };
};
export const useValues = (identifier: string, integration: string, value: string[]) => {
const resolver = useMemo(() => {
const findValidator = allProvidersSettings.find((provider) => provider.identifier === identifier)!;
return classValidatorResolver(findValidator?.validator);
}, [integration]);
const form = useForm({
resolver
});
const getValues = useMemo(() => {
return form.getValues;
}, [form]);
finalInformation[integration]= finalInformation[integration] || {};
finalInformation[integration].posts = value;
finalInformation[integration].isValid = form.formState.isValid;
finalInformation[integration].settings = getValues;
useEffect(() => {
return () => {
delete finalInformation[integration];
};
}, []);
return form;
};
export const useSettings = (formProps?: Omit<UseFormProps, 'mode'>) => {
// const { integration } = useIntegration();
// const form = useForm({
// ...formProps,
// mode: 'onChange',
// });
//
// finalInformation[integration?.identifier!].settings = {
// __type: integration?.identifier!,
// ...form.getValues(),
// };
// return form;
};
export const getValues = () => finalInformation;
export const resetValues = () => {
Object.keys(finalInformation).forEach((key) => {
delete finalInformation[key];
});
};

View File

@ -0,0 +1,61 @@
import { AddProviderButton } from '@gitroom/frontend/components/launches/add.provider.component';
import { FC, useMemo } from 'react';
import Image from 'next/image';
import { orderBy } from 'lodash';
import { Calendar } from '@gitroom/frontend/components/launches/calendar';
import {CalendarWeekProvider, Integrations} from '@gitroom/frontend/components/launches/calendar.context';
import { Filters } from '@gitroom/frontend/components/launches/filters';
export const LaunchesComponent: FC<{
integrations: Integrations[]
}> = (props) => {
const { integrations } = props;
const sortedIntegrations = useMemo(() => {
return orderBy(integrations, ['type', 'identifier'], ['desc', 'asc']);
}, [integrations]);
return (
<CalendarWeekProvider integrations={sortedIntegrations}>
<div className="flex flex-1 flex-col">
<Filters />
<div className="flex flex-1 relative">
<div className="absolute w-full h-full flex flex-1 gap-[30px] overflow-hidden overflow-y-scroll scrollbar scrollbar-thumb-tableBorder scrollbar-track-secondary">
<div className="w-[330px] bg-third p-[16px] flex flex-col gap-[24px] sticky top-0">
<h2 className="text-[20px]">Channels</h2>
<div className="gap-[16px] flex flex-col">
{sortedIntegrations.map((integration) => (
<div
key={integration.id}
className="flex gap-[8px] items-center"
>
<div className="relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth">
<img
src={integration.picture}
className="rounded-full"
alt={integration.identifier}
width={32}
height={32}
/>
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
</div>
<div className="flex-1">{integration.name}</div>
<div>3</div>
</div>
))}
</div>
<AddProviderButton />
</div>
<div className="flex-1 flex flex-col gap-[14px]">
<Calendar />
</div>
</div>
</div>
</div>
</CalendarWeekProvider>
);
};

View File

@ -0,0 +1,12 @@
import {FC} from "react";
import {withProvider} from "@gitroom/frontend/components/launches/providers/high.order.provider";
const DevtoPreview: FC = () => {
return <div>asd</div>
};
const DevtoSettings: FC = () => {
return <div>asdfasd</div>
};
export default withProvider(DevtoSettings, DevtoPreview);

View File

@ -0,0 +1,144 @@
'use client';
import { FC, useCallback, useEffect, useState } from 'react';
import { Button } from '@gitroom/react/form/button';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import MDEditor from '@uiw/react-md-editor';
import { useHideTopEditor } from '@gitroom/frontend/components/launches/helpers/use.hide.top.editor';
import { useValues } from '@gitroom/frontend/components/launches/helpers/use.values';
import { FormProvider } from 'react-hook-form';
import { useMoveToIntegrationListener } from '@gitroom/frontend/components/launches/helpers/use.move.to.integration';
// This is a simple function that if we edit in place, we hide the editor on top
export const EditorWrapper: FC = (props) => {
const showHide = useHideTopEditor();
useEffect(() => {
showHide.hide();
return () => {
showHide.show();
};
}, []);
return null;
};
export const withProvider = (SettingsComponent: FC, PreviewComponent: FC) => {
return (props: {
identifier: string;
id: string;
value: string[];
show: boolean;
}) => {
const [editInPlace, setEditInPlace] = useState(false);
const [InPlaceValue, setInPlaceValue] = useState(['']);
const [showTab, setShowTab] = useState(0);
// in case there is an error on submit, we change to the settings tab for the specific provider
useMoveToIntegrationListener(true, (identifier) => {
if (identifier === props.identifier) {
setShowTab(2);
}
});
// this is a smart function, it updates the global value without updating the states (too heavy) and set the settings validation
const form = useValues(
props.identifier,
props.id,
editInPlace ? InPlaceValue : props.value
);
// change editor value
const changeValue = useCallback(
(index: number) => (newValue: string) => {
return setInPlaceValue((prev) => {
prev[index] = newValue;
return [...prev];
});
},
[InPlaceValue]
);
// add another local editor
const addValue = useCallback(
(index: number) => () => {
setInPlaceValue((prev) => {
prev.splice(index + 1, 0, '');
return [...prev];
});
},
[InPlaceValue]
);
// This is a function if we want to switch from the global editor to edit in place
const changeToEditor = useCallback(
(editor: boolean) => async () => {
if (
editor &&
!editInPlace &&
!(await deleteDialog(
'Are you sure you want to edit in place?',
'Yes, edit in place!'
))
) {
return false;
}
setShowTab(editor ? 1 : 0);
if (editor && !editInPlace) {
setEditInPlace(true);
setInPlaceValue(props.value);
}
},
[props.value, editInPlace]
);
if (!props.show) {
return null;
}
return (
<FormProvider {...form}>
<div className="mt-[15px]">
{editInPlace && <EditorWrapper />}
<div className="flex">
<div>
<Button secondary={showTab !== 0} onClick={changeToEditor(false)}>
Preview
</Button>
</div>
<div>
<Button secondary={showTab !== 2} onClick={() => setShowTab(2)}>
Settings
</Button>
</div>
<div>
<Button secondary={showTab !== 1} onClick={changeToEditor(true)}>
Editor
</Button>
</div>
</div>
{showTab === 1 && (
<div className="flex flex-col gap-[20px]">
{InPlaceValue.map((val, index) => (
<>
<MDEditor
key={`edit_inner_${index}`}
height={InPlaceValue.length > 1 ? 200 : 500}
value={val}
preview="edit"
// @ts-ignore
onChange={changeValue(index)}
/>
<div>
<Button onClick={addValue(index)}>Add post</Button>
</div>
</>
))}
</div>
)}
{showTab === 2 && <SettingsComponent />}
{showTab === 0 && <PreviewComponent />}
</div>
</FormProvider>
);
};
};

View File

@ -0,0 +1,93 @@
import { FC } from 'react';
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
import localFont from 'next/font/local';
import clsx from 'clsx';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
const chirp = localFont({
src: [
{
path: './fonts/x/Chirp-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: './fonts/x/Chirp-Bold.woff2',
weight: '700',
style: 'normal',
},
],
});
const LinkedinPreview: FC = (props) => {
const { value: topValue, integration } = useIntegration();
const newValues = useFormatting(topValue, {
removeMarkdown: true,
saveBreaklines: true,
specialFunc: (text: string) => {
return text.slice(0, 280);
}
});
return (
<div
className={clsx(
'max-w-[598px] px-[16px] border border-[#2E3336]',
chirp.className
)}
>
<div className="w-full h-full relative flex flex-col pt-[12px]">
{newValues.map((value, index) => (
<div
key={`tweet_${index}`}
className={`flex gap-[8px] pb-[${
index === topValue.length - 1 ? '12px' : '24px'
}] relative`}
>
<div className="w-[40px] flex flex-col items-center">
<img
src={integration?.picture}
alt="x"
className="rounded-full relative z-[2]"
/>
{index !== topValue.length - 1 && (
<div className="flex-1 w-[2px] h-[calc(100%-10px)] bg-[#2E3336] absolute top-[10px] z-[1]" />
)}
</div>
<div className="flex-1 flex flex-col gap-[4px]">
<div className="flex">
<div className="h-[22px] text-[15px] font-[700]">
{integration?.name}
</div>
<div className="text-[15px] text-[#1D9BF0] mt-[1px] ml-[2px]">
<svg
viewBox="0 0 22 22"
aria-label="Verified account"
role="img"
className="max-w-[20px] max-h-[20px] fill-current h-[1.25em]"
data-testid="icon-verified"
>
<g>
<path d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"></path>
</g>
</svg>
</div>
<div className="text-[15px] font-[400] text-[#71767b] ml-[4px]">
@username
</div>
</div>
<pre className={chirp.className}>{value.text}</pre>
</div>
</div>
))}
</div>
</div>
);
};
const LinkedinSettings: FC = () => {
return <div>asdfasd</div>;
};
export default withProvider(LinkedinSettings, LinkedinPreview);

View File

@ -0,0 +1,93 @@
import { FC } from 'react';
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
import localFont from 'next/font/local';
import clsx from 'clsx';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
const chirp = localFont({
src: [
{
path: './fonts/x/Chirp-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: './fonts/x/Chirp-Bold.woff2',
weight: '700',
style: 'normal',
},
],
});
const RedditPreview: FC = (props) => {
const { value: topValue, integration } = useIntegration();
const newValues = useFormatting(topValue, {
removeMarkdown: true,
saveBreaklines: true,
specialFunc: (text: string) => {
return text.slice(0, 280);
}
});
return (
<div
className={clsx(
'max-w-[598px] px-[16px] border border-[#2E3336]',
chirp.className
)}
>
<div className="w-full h-full relative flex flex-col pt-[12px]">
{newValues.map((value, index) => (
<div
key={`tweet_${index}`}
className={`flex gap-[8px] pb-[${
index === topValue.length - 1 ? '12px' : '24px'
}] relative`}
>
<div className="w-[40px] flex flex-col items-center">
<img
src={integration?.picture}
alt="x"
className="rounded-full relative z-[2]"
/>
{index !== topValue.length - 1 && (
<div className="flex-1 w-[2px] h-[calc(100%-10px)] bg-[#2E3336] absolute top-[10px] z-[1]" />
)}
</div>
<div className="flex-1 flex flex-col gap-[4px]">
<div className="flex">
<div className="h-[22px] text-[15px] font-[700]">
{integration?.name}
</div>
<div className="text-[15px] text-[#1D9BF0] mt-[1px] ml-[2px]">
<svg
viewBox="0 0 22 22"
aria-label="Verified account"
role="img"
className="max-w-[20px] max-h-[20px] fill-current h-[1.25em]"
data-testid="icon-verified"
>
<g>
<path d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"></path>
</g>
</svg>
</div>
<div className="text-[15px] font-[400] text-[#71767b] ml-[4px]">
@username
</div>
</div>
<pre className={chirp.className}>{value.text}</pre>
</div>
</div>
))}
</div>
</div>
);
};
const RedditSettings: FC = () => {
return <div>asdfasd</div>;
};
export default withProvider(RedditSettings, RedditPreview);

View File

@ -0,0 +1,28 @@
import {FC} from "react";
import {Integrations} from "@gitroom/frontend/components/launches/calendar.context";
import DevtoProvider from "@gitroom/frontend/components/launches/providers/devto.provider";
import XProvider from "@gitroom/frontend/components/launches/providers/x.provider";
import LinkedinProvider from "@gitroom/frontend/components/launches/providers/linkedin.provider";
import RedditProvider from "@gitroom/frontend/components/launches/providers/reddit.provider";
const Providers = [
{identifier: 'devto', component: DevtoProvider},
{identifier: 'x', component: XProvider},
{identifier: 'linkedin', component: LinkedinProvider},
{identifier: 'reddit', component: RedditProvider},
];
export const ShowAllProviders: FC<{integrations: Integrations[], value: string[], selectedProvider?: Integrations}> = (props) => {
const {integrations, value, selectedProvider} = props;
return (
<>
{integrations.map((integration) => {
const {component: ProviderComponent} = Providers.find(provider => provider.identifier === integration.identifier) || {component: null};
if (!ProviderComponent || integrations.map(p => p.id).indexOf(selectedProvider?.id!) === -1) {
return null;
}
return <ProviderComponent key={integration.id} {...integration} value={value} show={selectedProvider?.id === integration.id} />;
})}
</>
)
}

View File

@ -0,0 +1,97 @@
import { FC } from 'react';
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
import localFont from 'next/font/local';
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 {useSettings} from "@gitroom/frontend/components/launches/helpers/use.values";
const chirp = localFont({
src: [
{
path: './fonts/x/Chirp-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: './fonts/x/Chirp-Bold.woff2',
weight: '700',
style: 'normal',
},
],
});
const XPreview: FC = (props) => {
const { value: topValue, integration } = useIntegration();
const newValues = useFormatting(topValue, {
removeMarkdown: true,
saveBreaklines: true,
specialFunc: (text: string) => {
return text.slice(0, 280);
}
});
return (
<div
className={clsx(
'max-w-[598px] px-[16px] border border-[#2E3336]',
chirp.className
)}
>
<div className="w-full h-full relative flex flex-col pt-[12px]">
{newValues.map((value, index) => (
<div
key={`tweet_${index}`}
className={`flex gap-[8px] pb-[${
index === topValue.length - 1 ? '12px' : '24px'
}] relative`}
>
<div className="w-[40px] flex flex-col items-center">
<img
src={integration?.picture}
alt="x"
className="rounded-full relative z-[2]"
/>
{index !== topValue.length - 1 && (
<div className="flex-1 w-[2px] h-[calc(100%-10px)] bg-[#2E3336] absolute top-[10px] z-[1]" />
)}
</div>
<div className="flex-1 flex flex-col gap-[4px]">
<div className="flex">
<div className="h-[22px] text-[15px] font-[700]">
{integration?.name}
</div>
<div className="text-[15px] text-[#1D9BF0] mt-[1px] ml-[2px]">
<svg
viewBox="0 0 22 22"
aria-label="Verified account"
role="img"
className="max-w-[20px] max-h-[20px] fill-current h-[1.25em]"
data-testid="icon-verified"
>
<g>
<path d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"></path>
</g>
</svg>
</div>
<div className="text-[15px] font-[400] text-[#71767b] ml-[4px]">
@username
</div>
</div>
<pre className={chirp.className}>{value.text}</pre>
</div>
</div>
))}
</div>
</div>
);
};
const XSettings: FC = () => {
const settings = useSettings({
});
return <div>asdfasd</div>;
};
export default withProvider(XSettings, XPreview);

View File

@ -4,30 +4,33 @@ import {headers} from "next/headers";
import {ContextWrapper} from "@gitroom/frontend/components/layout/user.context";
import {NotificationComponent} from "@gitroom/frontend/components/notifications/notification.component";
import {TopMenu} from "@gitroom/frontend/components/layout/top.menu";
import {MantineWrapper} from "@gitroom/react/helpers/mantine.wrapper";
export const LayoutSettings = ({children}: {children: ReactNode}) => {
const user = JSON.parse(headers().get('user')!);
return (
<ContextWrapper user={user}>
<div className="min-h-[100vh] 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">
<div className="text-2xl">
Gitroom
<MantineWrapper>
<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">
<div className="text-2xl">
Gitroom
</div>
<TopMenu />
<div>
<NotificationComponent />
</div>
</div>
<TopMenu />
<div>
<NotificationComponent />
</div>
</div>
<div className="flex-1 flex">
<div className="flex-1 rounded-3xl px-[23px] py-[17px] flex flex-col">
<Title />
<div className="flex flex-1 flex-col">
{children}
<div className="flex-1 flex">
<div className="flex-1 rounded-3xl px-[23px] py-[17px] flex flex-col">
<Title />
<div className="flex flex-1 flex-col">
{children}
</div>
</div>
</div>
</div>
</div>
</MantineWrapper>
</ContextWrapper>
);
}

View File

@ -3,15 +3,26 @@ import Image from "next/image";
import {Button} from "@gitroom/react/form/button";
import {FC, useCallback, useEffect, useState} from "react";
import {useFetch} from "@gitroom/helpers/utils/custom.fetch";
import {deleteDialog} from "@gitroom/react/helpers/delete.dialog";
const ConnectedComponent: FC<{id: string, login: string}> = (props) => {
const {id, login} = props;
const ConnectedComponent: FC<{id: string, login: string, deleteRepository: () => void}> = (props) => {
const {id, login, deleteRepository} = props;
const fetch = useFetch();
const disconnect = useCallback(async () => {
if (!await deleteDialog('Are you sure you want to disconnect this repository?')) {
return ;
}
deleteRepository();
await fetch(`/settings/repository/${id}`, {
method: 'DELETE'
});
}, []);
return (
<div className="my-[16px] mt-[16px] h-[90px] bg-sixth border-fifth border rounded-[4px] p-[24px]">
<div className="flex items-center gap-[8px] font-[Inter]">
<div><Image src="/icons/github.svg" alt="GitHub" width={40} height={40}/></div>
<div className="flex-1"><strong>Connected:</strong> {login}</div>
<Button>Disconnect</Button>
<Button onClick={disconnect}>Disconnect</Button>
</div>
</div>
);
@ -93,10 +104,18 @@ export const GithubComponent: FC<{ organizations: Array<{ login: string, id: str
if (git.id === g.id) {
return {id: g.id, login: name};
}
return g;
return git;
})
});
}, []);
}, [githubState]);
const deleteConnect = useCallback((g: {id: string, login: string}) => () => {
setGithubState((gitlibs) => {
return gitlibs.filter((git, index) => {
return git.id !== g.id;
})
});
}, [githubState]);
return (
<>
@ -105,7 +124,7 @@ export const GithubComponent: FC<{ organizations: Array<{ login: string, id: str
{!g.login ? (
<ConnectComponent setConnected={setConnected(g)} organizations={organizations} {...g} />
): (
<ConnectedComponent {...g} />
<ConnectedComponent deleteRepository={deleteConnect(g)} {...g} />
)}
</>
))}

View File

@ -20,12 +20,20 @@ module.exports = {
primary: '#000',
secondary: '#090B13',
third: '#080B13',
forth: '#262373',
fifth: '#172034',
forth: '#612AD5',
fifth: '#28344F',
sixth: '#0B101B',
gray: '#8C8C8C',
input: '#131B2C',
inputText: '#64748B',
tableBorder: '#1F2941'
},
gridTemplateColumns: {
'13': 'repeat(13, minmax(0, 1fr));'
}
},
},
plugins: [],
plugins: [
require('tailwind-scrollbar')
],
};

View File

@ -6,12 +6,13 @@ import {DatabaseModule} from "@gitroom/nestjs-libraries/database/prisma/database
import {BullMqModule} from "@gitroom/nestjs-libraries/bull-mq-transport/bull-mq.module";
import {ioRedis} from "@gitroom/nestjs-libraries/redis/redis.service";
import {TrendingService} from "@gitroom/nestjs-libraries/services/trending.service";
import {PostsController} from "@gitroom/workers/app/posts.controller";
@Module({
imports: [RedisModule, DatabaseModule, BullMqModule.forRoot({
connection: ioRedis
})],
controllers: [StarsController],
controllers: [StarsController, PostsController],
providers: [TrendingService],
})
export class AppModule {}

View File

@ -0,0 +1,15 @@
import {Controller} from '@nestjs/common';
import {EventPattern, Transport} from '@nestjs/microservices';
import {PostsService} from "@gitroom/nestjs-libraries/database/prisma/posts/posts.service";
@Controller()
export class PostsController {
constructor(
private _postsService: PostsService
) {
}
@EventPattern('post', Transport.REDIS)
async checkStars(data: {id: string}) {
return this._postsService.post(data.id);
}
}

View File

@ -1,9 +1,7 @@
import { DynamicModule, Module } from '@nestjs/common';
import { DynamicModule } from '@nestjs/common';
import { BullMqCoreModule } from './bull-mq-core.module';
import { IBullMqModuleOptionsAsync } from './interfaces/bull-mq-module-options-async.interface';
import { IBullMqModuleOptions } from './interfaces/bull-mq-module-options.interface';
@Module({})
export class BullMqModule {
static forRoot(options: IBullMqModuleOptions): DynamicModule {
return {

View File

@ -74,10 +74,21 @@ export class BullMqClient extends ClientProxy {
return () => void 0;
}
async delay(pattern: string, jobId: string, delay: number) {
const queue = this.getQueue(pattern);
return queue.getJob(jobId).then((job) => job?.changeDelay(delay));
}
async delete(pattern: string, jobId: string) {
const queue = this.getQueue(pattern);
return queue.getJob(jobId).then((job) => job?.remove());
}
protected async dispatchEvent(
packet: ReadPacket<IBullMqEvent<any>>,
): Promise<any> {
const queue = this.getQueue(packet.pattern);
console.log(packet);
await queue.add(packet.pattern, packet.data, {
jobId: packet.data.id ?? v4(),
...packet.data.options,

View File

@ -11,6 +11,9 @@ import {SubscriptionRepository} from "@gitroom/nestjs-libraries/database/prisma/
import {NotificationService} from "@gitroom/nestjs-libraries/notifications/notification.service";
import {IntegrationService} from "@gitroom/nestjs-libraries/database/prisma/integrations/integration.service";
import {IntegrationRepository} from "@gitroom/nestjs-libraries/database/prisma/integrations/integration.repository";
import {PostsService} from "@gitroom/nestjs-libraries/database/prisma/posts/posts.service";
import {PostsRepository} from "@gitroom/nestjs-libraries/database/prisma/posts/posts.repository";
import {IntegrationManager} from "@gitroom/nestjs-libraries/integrations/integration.manager";
@Global()
@Module({
@ -29,7 +32,10 @@ import {IntegrationRepository} from "@gitroom/nestjs-libraries/database/prisma/i
SubscriptionRepository,
NotificationService,
IntegrationService,
IntegrationRepository
IntegrationRepository,
PostsService,
PostsRepository,
IntegrationManager
],
get exports() {
return this.providers;

View File

@ -8,13 +8,14 @@ export class IntegrationRepository {
) {
}
createIntegration(org: string, name: string, type: 'article' | 'social' , internalId: string, provider: string, token: string, refreshToken = '', expiresIn = 999999999) {
createIntegration(org: string, name: string, picture: string, type: 'article' | 'social' , internalId: string, provider: string, token: string, refreshToken = '', expiresIn = 999999999) {
return this._integration.model.integration.create({
data: {
type: type as any,
name,
providerIdentifier: provider,
token,
picture,
refreshToken,
...expiresIn ? {tokenExpiration: new Date(Date.now() + expiresIn * 1000)} :{},
internalId,
@ -22,4 +23,12 @@ export class IntegrationRepository {
}
})
}
getIntegrationsList(org: string) {
return this._integration.model.integration.findMany({
where: {
organizationId: org
}
});
}
}

View File

@ -7,7 +7,11 @@ export class IntegrationService {
private _integrationRepository: IntegrationRepository,
) {
}
createIntegration(org: string, name: string, type: 'article' | 'social' , internalId: string, provider: string, token: string, refreshToken = '', expiresIn?: number) {
return this._integrationRepository.createIntegration(org, name, type, internalId, provider, token, refreshToken, expiresIn);
createIntegration(org: string, name: string, picture: string, type: 'article' | 'social' , internalId: string, provider: string, token: string, refreshToken = '', expiresIn?: number) {
return this._integrationRepository.createIntegration(org, name, picture, type, internalId, provider, token, refreshToken, expiresIn);
}
getIntegrationsList(org: string) {
return this._integrationRepository.getIntegrationsList(org);
}
}

View File

@ -0,0 +1,71 @@
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Post as PostBody } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto';
import dayjs from 'dayjs';
import { Integration, Post } from '@prisma/client';
@Injectable()
export class PostsRepository {
constructor(private _post: PrismaRepository<'post'>) {}
getPost(id: string, includeIntegration = false) {
return this._post.model.post.findUnique({
where: {
id,
},
include: {
...(includeIntegration ? { integration: true } : {}),
childrenPost: true,
},
});
}
updatePost(id: string, postId: string, releaseURL: string) {
return this._post.model.post.update({
where: {
id,
},
data: {
state: 'PUBLISHED',
releaseURL,
releaseId: postId,
},
});
}
async createPost(orgId: string, date: string, body: PostBody) {
const posts: Post[] = [];
for (const value of body.value) {
posts.push(
await this._post.model.post.create({
data: {
publishDate: dayjs(date).toDate(),
integration: {
connect: {
id: body.integration.id,
organizationId: orgId,
},
},
...(posts.length
? {
parentPost: {
connect: {
id: posts[posts.length - 1]?.id,
},
},
}
: {}),
content: value,
organization: {
connect: {
id: orgId,
},
},
},
})
);
}
return posts;
}
}

View File

@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { PostsRepository } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.repository';
import { CreatePostDto } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport/client/bull-mq.client';
import dayjs from 'dayjs';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { Integration, Post } from '@prisma/client';
type PostWithConditionals = Post & {
integration?: Integration;
childrenPost: Post[];
};
@Injectable()
export class PostsService {
constructor(
private _postRepository: PostsRepository,
private _workerServiceProducer: BullMqClient,
private _integrationManager: IntegrationManager
) {}
async getPostsRecursively(
id: string,
includeIntegration = false
): Promise<PostWithConditionals[]> {
const post = await this._postRepository.getPost(id, includeIntegration);
return [
post!,
...(post?.childrenPost?.length
? await this.getPostsRecursively(post.childrenPost[0].id)
: []),
];
}
async post(id: string) {
const [firstPost, ...morePosts] = await this.getPostsRecursively(id, true);
if (!firstPost) {
return;
}
if (firstPost.integration?.type === 'article') {
return this.postArticle(firstPost.integration!, [firstPost, ...morePosts]);
}
return this.postSocial(firstPost.integration!, [firstPost, ...morePosts]);
}
private async postSocial(integration: Integration, posts: Post[]) {
const getIntegration = this._integrationManager.getSocialIntegration(integration.providerIdentifier);
if (!getIntegration) {
return;
}
const publishedPosts = await getIntegration.post(integration.internalId, integration.token, posts.map(p => ({
id: p.id,
message: p.content,
settings: JSON.parse(p.settings || '{}'),
})));
for (const post of publishedPosts) {
await this._postRepository.updatePost(post.id, post.postId, post.releaseURL);
}
}
private async postArticle(integration: Integration, posts: Post[]) {
const getIntegration = this._integrationManager.getArticlesIntegration(integration.providerIdentifier);
if (!getIntegration) {
return;
}
const {postId, releaseURL} = await getIntegration.post(integration.token, posts.map(p => p.content).join('\n\n'), JSON.parse(posts[0].settings || '{}'));
await this._postRepository.updatePost(posts[0].id, postId, releaseURL);
}
async createPost(orgId: string, body: CreatePostDto) {
for (const post of body.posts) {
const posts = await this._postRepository.createPost(
orgId,
body.date,
post
);
this._workerServiceProducer.emit('post', {
id: posts[0].id,
options: {
delay: 0 // dayjs(posts[0].publishDate).diff(dayjs(), 'millisecond'),
},
payload: {
id: posts[0].id,
},
});
}
}
}

View File

@ -128,6 +128,7 @@ model Integration {
organizationId String
name String
organization Organization @relation(fields: [organizationId], references: [id])
picture String?
providerIdentifier String
type String
token String
@ -183,21 +184,19 @@ model Slots {
model Post {
id String @id @default(cuid())
state State @default(QUEUE)
queueId String?
publishDate DateTime
organizationId String
IntegrationId String
integrationId String
content String
organization Organization @relation(fields: [organizationId], references: [id])
Integration Integration @relation(fields: [IntegrationId], references: [id])
integration Integration @relation(fields: [integrationId], references: [id])
title String?
description String?
canonicalUrl String?
canonicalPostId String?
parentPostId String?
releaseId String?
releaseURL String?
canonicalPost Post? @relation("canonicalPostId", fields: [canonicalPostId], references: [id])
settings String?
parentPost Post? @relation("parentPostId", fields: [parentPostId], references: [id])
canonicalChildren Post[] @relation("canonicalPostId")
childrenPost Post[] @relation("parentPostId")
tags PostTag[]
media PostMedia[]
@ -207,7 +206,8 @@ model Post {
enum State {
QUEUE
SENT
PUBLISHED
ERROR
DRAFT
}

View File

@ -4,7 +4,7 @@ import {chunk, groupBy} from "lodash";
import dayjs from "dayjs";
import {NotificationService} from "@gitroom/nestjs-libraries/notifications/notification.service";
import {StarsListDto} from "@gitroom/nestjs-libraries/dtos/analytics/stars.list.dto";
import * as console from "console";
import {BullMqClient} from "@gitroom/nestjs-libraries/bull-mq-transport/client/bull-mq.client";
enum Inform {
Removed,
New,
@ -14,7 +14,8 @@ enum Inform {
export class StarsService {
constructor(
private _starsRepository: StarsRepository,
private _notificationsService: NotificationService
private _notificationsService: NotificationService,
private _workerServiceProducer: BullMqClient
){}
getGitHubRepositoriesByOrgId(org: string) {
@ -49,7 +50,6 @@ export class StarsService {
}
async syncProcess(login: string, page = 1) {
console.log('processing', login, page);
const starsRequest = await fetch(`https://api.github.com/repos/${login}/stargazers?page=${page}&per_page=100`, {
headers: {
Accept: 'application/vnd.github.v3.star+json',
@ -221,6 +221,7 @@ export class StarsService {
}
async updateGitHubLogin(orgId: string, id: string, login: string) {
this._workerServiceProducer.emit('sync_all_stars', {payload: {login}}).subscribe();
return this._starsRepository.updateGitHubLogin(orgId, id, login);
}

View File

@ -0,0 +1,9 @@
import {IsString, MinLength} from "class-validator";
export class ApiKeyDto {
@IsString()
@MinLength(4, {
message: 'Must be at least 4 characters'
})
api: string;
}

View File

@ -0,0 +1,47 @@
import {ArrayMinSize, IsArray, IsDateString, IsDefined, IsString, ValidateNested} from "class-validator";
import {Type} from "class-transformer";
import {DevToSettingsDto} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto";
export class EmptySettings {}
export class Integration {
@IsDefined()
@IsString()
id: string
}
export class Post {
@IsDefined()
@Type(() => Integration)
@ValidateNested()
integration: Integration;
@IsDefined()
@ArrayMinSize(1)
@IsArray()
@IsString({ each: true })
value: string[];
@Type(() => EmptySettings, {
keepDiscriminatorProperty: true,
discriminator: {
property: '__type',
subTypes: [
{ value: DevToSettingsDto, name: 'devto' },
],
},
})
settings: DevToSettingsDto
}
export class CreatePostDto {
@IsDefined()
@IsDateString()
date: string;
@IsDefined()
@Type(() => Post)
@IsArray()
@ValidateNested({each: true})
@ArrayMinSize(1)
posts: Post[]
}

View File

@ -0,0 +1,7 @@
import {DevToSettingsDto} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto";
export const allProvidersSettings = [{
identifier: 'devto',
validator: DevToSettingsDto
}];
export type AllProvidersSettings = DevToSettingsDto;

View File

@ -0,0 +1,24 @@
import {IsArray, IsDefined, IsOptional, IsString} from "class-validator";
export class DevToSettingsDto {
@IsString()
@IsDefined()
title: string;
@IsString()
@IsOptional()
main_image?: number;
@IsString()
canonical: string;
@IsString({
each: true
})
@IsArray()
tags: string[];
@IsString()
@IsOptional()
organization?: string;
}

View File

@ -1,6 +1,6 @@
export interface ArticleIntegrationsInterface {
authenticate(token: string): Promise<{id: string, name: string, token: string}>;
publishPost(token: string, content: string): Promise<string>;
authenticate(token: string): Promise<{id: string, name: string, token: string, picture: string}>;
post(token: string, content: string, settings: object): Promise<{postId: string, releaseURL: string}>;
}
export interface ArticleProvider extends ArticleIntegrationsInterface {

View File

@ -3,8 +3,8 @@ import {ArticleProvider} from "@gitroom/nestjs-libraries/integrations/article/ar
export class DevToProvider implements ArticleProvider {
identifier = 'devto';
name = 'Dev.to';
async authenticate(token: string): Promise<{ id: string; name: string; token: string; }> {
const {name, id} = await (await fetch('https://dev.to/api/users/me', {
async authenticate(token: string) {
const {name, id, profile_image} = await (await fetch('https://dev.to/api/users/me', {
headers: {
'api-key': token
}
@ -13,11 +13,15 @@ export class DevToProvider implements ArticleProvider {
return {
id,
name,
token
token,
picture: profile_image
}
}
async publishPost(token: string, content: string): Promise<string> {
return '';
async post(token: string, content: string, settings: object) {
return {
postId: '123',
releaseURL: 'https://dev.to'
}
}
}

View File

@ -3,9 +3,9 @@ import {ArticleIntegrationsInterface, ArticleProvider} from "@gitroom/nestjs-lib
export class HashnodeProvider implements ArticleProvider {
identifier = 'hashnode';
name = 'Hashnode';
async authenticate(token: string): Promise<{ id: string; name: string; token: string; }> {
async authenticate(token: string) {
try {
const {data: {me: {name, id}}} = await (await fetch('https://gql.hashnode.com', {
const {data: {me: {name, id, profilePicture}}} = await (await fetch('https://gql.hashnode.com', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -16,7 +16,8 @@ export class HashnodeProvider implements ArticleProvider {
query {
me {
name,
id
id,
profilePicture
}
}
`
@ -24,19 +25,23 @@ export class HashnodeProvider implements ArticleProvider {
})).json();
return {
id, name, token
id, name, token, picture: profilePicture
}
}
catch (err) {
return {
id: '',
name: '',
token: ''
token: '',
picture: ''
}
}
}
async publishPost(token: string, content: string): Promise<string> {
return '';
async post(token: string, content: string, settings: object) {
return {
postId: '123',
releaseURL: 'https://dev.to'
}
}
}

View File

@ -4,8 +4,8 @@ export class MediumProvider implements ArticleProvider {
identifier = 'medium';
name = 'Medium';
async authenticate(token: string): Promise<{ id: string; name: string; token: string; }> {
const {data: {name, id}} = await (await fetch('https://api.medium.com/v1/me', {
async authenticate(token: string) {
const {data: {name, id, imageUrl}} = await (await fetch('https://api.medium.com/v1/me', {
headers: {
Authorization: `Bearer ${token}`
}
@ -14,11 +14,15 @@ export class MediumProvider implements ArticleProvider {
return {
id,
name,
token
token,
picture: imageUrl
}
}
async publishPost(token: string, content: string): Promise<string> {
return '';
async post(token: string, content: string, settings: object) {
return {
postId: '123',
releaseURL: 'https://dev.to'
}
}
}

View File

@ -22,6 +22,12 @@ const articleIntegrationList = [
@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);
}

View File

@ -1,82 +1,202 @@
import {AuthTokenDetails, PostDetails, PostResponse, SocialProvider} from "@gitroom/nestjs-libraries/integrations/social/social.integrations.interface";
import {makeId} from "@gitroom/nestjs-libraries/services/make.is";
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 LinkedinProvider implements SocialProvider {
identifier = 'linkedin';
name = 'LinkedIn';
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
const {access_token: accessToken, refresh_token: refreshToken} = await (await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
identifier = 'linkedin';
name = 'LinkedIn';
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
const { access_token: accessToken, refresh_token: refreshToken } = await (
await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token,
client_id: process.env.LINKEDIN_CLIENT_ID!,
client_secret: process.env.LINKEDIN_CLIENT_SECRET!,
}),
})
).json();
const {
name,
sub: id,
picture,
} = await (
await fetch('https://api.linkedin.com/v2/userinfo', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
).json();
return {
id,
accessToken,
refreshToken,
name,
picture,
};
}
async generateAuthUrl() {
const state = makeId(6);
const codeVerifier = makeId(30);
const url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${
process.env.LINKEDIN_CLIENT_ID
}&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/linkedin`
)}&state=${state}&scope=${encodeURIComponent(
'openid profile w_member_social r_liteprofile'
)}`;
return {
url,
codeVerifier,
state,
};
}
async authenticate(params: { code: string; codeVerifier: string }) {
const body = new URLSearchParams();
body.append('grant_type', 'authorization_code');
body.append('code', params.code);
body.append(
'redirect_uri',
`${process.env.FRONTEND_URL}/integrations/social/linkedin`
);
body.append('client_id', process.env.LINKEDIN_CLIENT_ID!);
body.append('client_secret', process.env.LINKEDIN_CLIENT_SECRET!);
const {
access_token: accessToken,
expires_in: expiresIn,
refresh_token: refreshToken,
} = await (
await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
).json();
const {
name,
sub: id,
picture,
} = await (
await fetch('https://api.linkedin.com/v2/userinfo', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
).json();
return {
id,
accessToken,
refreshToken,
expiresIn,
name,
picture,
};
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails[]
): Promise<PostResponse[]> {
const [firstPost, ...restPosts] = postDetails;
console.log('posting');
const data = await fetch('https://api.linkedin.com/v2/posts', {
method: 'POST',
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
author: `urn:li:person:${id}`,
commentary: firstPost.message,
visibility: 'PUBLIC',
distribution: {
feedDistribution: 'MAIN_FEED',
targetEntities: [],
thirdPartyDistributionChannels: [],
},
lifecycleState: 'PUBLISHED',
isReshareDisabledByAuthor: false,
// content: {
// // contentEntities: [
// // {
// // entityLocation: 'URL_OF_THE_CONTENT_TO_SHARE',
// // thumbnails: [
// // {
// // resolvedUrl: 'URL_OF_THE_THUMBNAIL_IMAGE',
// // },
// // ],
// // },
// // ],
// title: firstPost.message,
// },
// distribution: {
// linkedInDistributionTarget: {},
// },
// owner: `urn:li:person:${id}`,
// subject: firstPost.message,
// text: {
// text: firstPost.message,
// },
}),
});
const topPostId = data.headers.get('x-restli-id')!;
const ids = [
{
status: 'posted',
postId: topPostId,
id: firstPost.id,
releaseURL: `https://www.linkedin.com/feed/update/${topPostId}`,
},
];
for (const post of restPosts) {
const {object} = await (await fetch(
`https://api.linkedin.com/v2/socialActions/${decodeURIComponent(
topPostId
)}/comments`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
actor: `urn:li:person:${id}`,
object: topPostId,
message: {
text: post.message,
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token,
client_id: process.env.LINKEDIN_CLIENT_ID!,
client_secret: process.env.LINKEDIN_CLIENT_SECRET!
})
})).json()
const {id, localizedFirstName, localizedLastName} = await (await fetch('https://api.linkedin.com/v2/me', {
headers: {
Authorization: `Bearer ${accessToken}`
}
})).json();
return {
id,
accessToken,
refreshToken,
name: `${localizedFirstName} ${localizedLastName}`
}),
}
)).json()
ids.push({
status: 'posted',
postId: object,
id: post.id,
releaseURL: `https://www.linkedin.com/embed/feed/update/${object}`,
});
}
async generateAuthUrl() {
const state = makeId(6);
const codeVerifier = makeId(30);
const url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${process.env.LINKEDIN_CLIENT_ID}&redirect_uri=${encodeURIComponent(`${process.env.FRONTEND_URL}/integrations/social/linkedin`)}&state=${state}&scope=${encodeURIComponent('openid profile w_member_social')}`;
return {
url,
codeVerifier,
state
}
}
async authenticate(params: {code: string, codeVerifier: string}) {
const body = new URLSearchParams();
body.append('grant_type', 'authorization_code');
body.append('code', params.code);
body.append('redirect_uri', `${process.env.FRONTEND_URL}/integrations/social/linkedin`);
body.append('client_id', process.env.LINKEDIN_CLIENT_ID!);
body.append('client_secret', process.env.LINKEDIN_CLIENT_SECRET!);
const {access_token: accessToken, expires_in: expiresIn, refresh_token: refreshToken, ...data} = await (await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body
})).json()
console.log({accessToken, expiresIn, refreshToken, data});
const {name, sub: id} = await (await fetch('https://api.linkedin.com/v2/userinfo', {
headers: {
Authorization: `Bearer ${accessToken}`
}
})).json();
return {
id,
accessToken,
refreshToken,
expiresIn,
name
}
}
async schedulePost(accessToken: string, postDetails: PostDetails[]): Promise<PostResponse[]> {
return [];
}
}
return ids;
}
}

View File

@ -17,7 +17,7 @@ export class RedditProvider implements SocialProvider {
})
})).json();
const {name, id} = await (await fetch('https://oauth.reddit.com/api/v1/me', {
const {name, id, icon_img} = await (await fetch('https://oauth.reddit.com/api/v1/me', {
headers: {
Authorization: `Bearer ${accessToken}`
}
@ -28,7 +28,8 @@ export class RedditProvider implements SocialProvider {
name,
accessToken,
refreshToken: newRefreshToken,
expiresIn
expiresIn,
picture: icon_img.split('?')[0]
}
}
@ -57,7 +58,7 @@ export class RedditProvider implements SocialProvider {
})
})).json();
const {name, id} = await (await fetch('https://oauth.reddit.com/api/v1/me', {
const {name, id, icon_img} = await (await fetch('https://oauth.reddit.com/api/v1/me', {
headers: {
Authorization: `Bearer ${accessToken}`
}
@ -68,14 +69,28 @@ export class RedditProvider implements SocialProvider {
name,
accessToken,
refreshToken,
expiresIn
expiresIn,
picture: icon_img.split('?')[0]
}
}
async schedulePost(accessToken: string, postDetails: PostDetails[]): Promise<PostResponse[]> {
return [{
postId: '123',
status: 'scheduled'
}];
async post(id: string, accessToken: string, postDetails: PostDetails[]): Promise<PostResponse[]> {
const [post, ...rest] = postDetails;
const response = await fetch('https://oauth.reddit.com/api/submit', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
title: 'test',
kind: 'self',
text: post.message,
sr: '/r/gitroom'
})
});
console.log(response);
return [];
}
}

View File

@ -16,22 +16,26 @@ export type AuthTokenDetails = {
accessToken: string; // The obtained access token
refreshToken?: string; // The refresh token, if applicable
expiresIn?: number; // The duration in seconds for which the access token is valid
picture?: string;
};
export interface ISocialMediaIntegration {
schedulePost(accessToken: string, postDetails: PostDetails[]): Promise<PostResponse[]>; // Schedules a new post
post(id: string, accessToken: string, postDetails: PostDetails[]): Promise<PostResponse[]>; // Schedules a new post
}
export type PostResponse = {
id: string; // The db internal id of the post
postId: string; // The ID of the scheduled post returned by the platform
releaseURL: string; // The URL of the post on the platform
status: string; // Status of the operation or initial post status
};
export type PostDetails = {
id: string;
message: string;
scheduledTime: Date; // The time when the post should be published
media?: MediaContent[]; // Optional array of media content to be attached with the post
poll?: PollDetails; // Optional poll details
settings: object;
media?: MediaContent[];
poll?: PollDetails;
};
export type PollDetails = {

View File

@ -1,67 +1,105 @@
import { TwitterApi } from 'twitter-api-v2';
import {AuthTokenDetails, PostDetails, PostResponse, SocialProvider} from "@gitroom/nestjs-libraries/integrations/social/social.integrations.interface";
import {
AuthTokenDetails,
PostDetails,
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
export class XProvider implements SocialProvider {
identifier = 'x';
name = 'X';
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const startingClient = new TwitterApi({ clientId: process.env.TWITTER_CLIENT_ID!, clientSecret: process.env.TWITTER_CLIENT_SECRET! });
const { accessToken, refreshToken: newRefreshToken, expiresIn, client } = await startingClient.refreshOAuth2Token(refreshToken);
const {data: {id, name}} = await client.v2.me();
return {
id,
name,
accessToken,
refreshToken: newRefreshToken,
expiresIn
}
identifier = 'x';
name = 'X';
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const startingClient = new TwitterApi({
clientId: process.env.TWITTER_CLIENT_ID!,
clientSecret: process.env.TWITTER_CLIENT_SECRET!,
});
const {
accessToken,
refreshToken: newRefreshToken,
expiresIn,
client,
} = await startingClient.refreshOAuth2Token(refreshToken);
const {
data: { id, name, profile_image_url },
} = await client.v2.me();
return {
id,
name,
accessToken,
refreshToken: newRefreshToken,
expiresIn,
picture: profile_image_url,
};
}
async generateAuthUrl() {
const client = new TwitterApi({
clientId: process.env.TWITTER_CLIENT_ID!,
clientSecret: process.env.TWITTER_CLIENT_SECRET!,
});
const { url, codeVerifier, state } = client.generateOAuth2AuthLink(
process.env.FRONTEND_URL + '/integrations/social/x',
{ scope: ['tweet.read', 'users.read', 'tweet.write', 'offline.access'] }
);
return {
url,
codeVerifier,
state,
};
}
async authenticate(params: { code: string; codeVerifier: string }) {
const startingClient = new TwitterApi({
clientId: process.env.TWITTER_CLIENT_ID!,
clientSecret: process.env.TWITTER_CLIENT_SECRET!,
});
const { accessToken, refreshToken, expiresIn, client } =
await startingClient.loginWithOAuth2({
code: params.code,
codeVerifier: params.codeVerifier,
redirectUri: process.env.FRONTEND_URL + '/integrations/social/x',
});
const {
data: { id, name, profile_image_url },
} = await client.v2.me({
'user.fields': 'profile_image_url',
});
return {
id,
accessToken,
name,
refreshToken,
expiresIn,
picture: profile_image_url,
};
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails[],
): Promise<PostResponse[]> {
const client = new TwitterApi(accessToken);
const {data: {username}} = await client.v2.me({
"user.fields": "username"
});
const ids: Array<{postId: string, id: string, releaseURL: string}> = [];
for (const post of postDetails) {
const { data }: { data: { id: string } } = await client.v2.tweet({
text: post.message,
...(ids.length
? { reply: { in_reply_to_tweet_id: ids[ids.length - 1].postId } }
: {}),
});
ids.push({postId: data.id, id: post.id, releaseURL: `https://twitter.com/${username}/status/${data.id}`});
}
async generateAuthUrl() {
const client = new TwitterApi({ clientId: process.env.TWITTER_CLIENT_ID!, clientSecret: process.env.TWITTER_CLIENT_SECRET! });
const {url, codeVerifier, state} = client.generateOAuth2AuthLink(
process.env.FRONTEND_URL + '/integrations/social/x',
{ scope: ['tweet.read', 'users.read', 'tweet.write', 'offline.access'] });
return {
url,
codeVerifier,
state
}
}
async authenticate(params: {code: string, codeVerifier: string}) {
const startingClient = new TwitterApi({ clientId: process.env.TWITTER_CLIENT_ID!, clientSecret: process.env.TWITTER_CLIENT_SECRET! });
const {accessToken, refreshToken, expiresIn, client} = await startingClient.loginWithOAuth2({
code: params.code,
codeVerifier: params.codeVerifier,
redirectUri: process.env.FRONTEND_URL + '/integrations/social/x'
});
const {data: {id, name}} = await client.v2.me();
return {
id,
accessToken,
name,
refreshToken,
expiresIn
}
}
async schedulePost(accessToken: string, postDetails: PostDetails[]): Promise<PostResponse[]> {
const client = new TwitterApi(accessToken);
const ids: string[] = [];
for (const post of postDetails) {
const {data}: {data: {id: string}} = await client.v2.tweet({
text: post.message,
...ids.length ? { reply: {in_reply_to_tweet_id: ids[ids.length - 1]} } : {},
});
ids.push(data.id);
}
return ids.map(p => ({
postId: p,
status: 'posted'
}));
}
}
return ids.map((p) => ({
...p,
status: 'posted',
}));
}
}

View File

@ -1,8 +1,8 @@
import {ButtonHTMLAttributes, DetailedHTMLProps, FC} from "react";
import {clsx} from "clsx";
export const Button: FC<DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>> = (props) => {
export const Button: FC<DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {secondary?: boolean}> = (props) => {
return (
<button {...props} type={props.type || 'button'} className={clsx('bg-forth px-[24px] h-[40px] cursor-pointer items-center justify-center flex', props?.className)} />
<button {...props} type={props.type || 'button'} className={clsx(`${props.secondary ? 'bg-sixth' : 'bg-forth'} px-[24px] h-[40px] cursor-pointer items-center justify-center flex`, props?.className)} />
)
}

View File

@ -0,0 +1,22 @@
"use client";
import {DetailedHTMLProps, FC, InputHTMLAttributes, useMemo} from "react";
import clsx from "clsx";
import {useFormContext} from "react-hook-form";
export const Input: FC<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> & {label: string, name: string}> = (props) => {
const {label, className, ...rest} = props;
const form = useFormContext();
const err = useMemo(() => {
if (!form || !form.formState.errors[props?.name!]) return;
return form?.formState?.errors?.[props?.name!]?.message! as string;
}, [form?.formState?.errors?.[props?.name!]?.message]);
return (
<div className="flex flex-col gap-[6px]">
<div className="font-['Inter'] text-[14px]">{label}</div>
<input {...form.register(props.name)} className={clsx("bg-input h-[44px] px-[16px] outline-none border-fifth border rounded-[4px] text-inputText placeholder-inputText", className)} {...rest} />
<div className="text-red-400 text-[12px]">{err || <>&nbsp;</>}</div>
</div>
)
}

View File

@ -0,0 +1,14 @@
import Swal from "sweetalert2";
export const deleteDialog = async (message: string, confirmButton?: string) => {
const fire = await Swal.fire({
title: 'Are you sure?',
text: message,
icon: 'warning',
showCancelButton: true,
confirmButtonText: confirmButton || 'Yes, delete it!',
cancelButtonText: 'No, cancel!',
});
return fire.isConfirmed;
}

View File

@ -0,0 +1,19 @@
"use client";
import {ReactNode} from "react";
import {MantineProvider} from "@mantine/core";
import {ModalsProvider} from "@mantine/modals";
export const MantineWrapper = (props: { children: ReactNode }) => {
return (
<MantineProvider>
<ModalsProvider modalProps={{
classNames: {
modal: 'bg-primary text-white border-fifth border',
close: 'bg-black hover:bg-black cursor-pointer',
}
}}>
{props.children}
</ModalsProvider>
</MantineProvider>
)
};

View File

@ -0,0 +1,10 @@
import {useEffect} from "react";
export const usePreventWindowUnload = (preventDefault: boolean) => {
useEffect(() => {
if (!preventDefault) return;
const handleBeforeUnload = (event: any) => event.preventDefault();
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [preventDefault]);
}

2019
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
"version": "0.0.0",
"license": "MIT",
"scripts": {
"dev": "concurrently \"stripe listen --forward-to localhost:3000/payment\" \"nx run-many --target=serve --projects=frontend,backend --parallel=4\"",
"dev": "concurrently \"stripe listen --forward-to localhost:3000/payment\" \"nx run-many --target=serve --projects=frontend,backend,workers --parallel=4\"",
"workers": "nx run workers:serve:development",
"cron": "nx run cron:serve:development",
"command": "nx run commands:build && nx run commands:command",
@ -13,6 +13,9 @@
"private": true,
"dependencies": {
"@casl/ability": "^6.5.0",
"@hookform/resolvers": "^3.3.4",
"@mantine/core": "^5.10.5",
"@mantine/modals": "^5.10.5",
"@nestjs/common": "^10.0.2",
"@nestjs/core": "^10.0.2",
"@nestjs/microservices": "^10.3.1",
@ -22,11 +25,14 @@
"@novu/notification-center": "^0.23.0",
"@prisma/client": "^5.8.1",
"@swc/helpers": "~0.5.2",
"@sweetalert2/theme-dark": "^5.0.16",
"@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.5",
"@types/lodash": "^4.14.202",
"@types/md5": "^2.3.5",
"@types/remove-markdown": "^0.3.4",
"@types/stripe": "^8.0.417",
"@uiw/react-md-editor": "^4.0.3",
"axios": "^1.0.0",
"bcrypt": "^5.1.1",
"bullmq": "^5.1.5",
@ -45,14 +51,16 @@
"prisma-paginate": "^5.2.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.49.3",
"react-hook-form": "^7.50.1",
"react-query": "^3.39.3",
"react-router-dom": "6.11.2",
"redis": "^4.6.12",
"reflect-metadata": "^0.1.13",
"remove-markdown": "^0.5.0",
"rxjs": "^7.8.0",
"simple-statistics": "^7.8.3",
"stripe": "^14.14.0",
"sweetalert2": "^11.10.5",
"tslib": "^2.3.0",
"twitter-api-v2": "^1.16.0",
"yargs": "^17.7.2"
@ -107,6 +115,7 @@
"prisma": "^5.8.1",
"react-refresh": "^0.10.0",
"sass": "1.62.1",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.1.0",
"ts-node": "10.9.1",