feat: canonical tag

This commit is contained in:
Nevo David 2024-03-03 15:26:15 +07:00
parent 4ebc751849
commit 515d6413b2
44 changed files with 1825 additions and 346 deletions

View File

@ -55,8 +55,8 @@ export class IntegrationsController {
@Post('/function')
async functionIntegration(
@GetOrgFromRequest() org: Organization,
@Body() body: IntegrationFunctionDto
@GetOrgFromRequest() org: Organization,
@Body() body: IntegrationFunctionDto
) {
const getIntegration = await this._integrationService.getIntegrationById(
org.id,
@ -124,7 +124,7 @@ export class IntegrationsController {
throw new Error('Invalid api key');
}
return this._integrationService.createIntegration(
return this._integrationService.createOrUpdateIntegration(
org.id,
name,
picture,
@ -166,7 +166,7 @@ export class IntegrationsController {
throw new Error('Invalid api key');
}
return this._integrationService.createIntegration(
return this._integrationService.createOrUpdateIntegration(
org.id,
name,
picture,

View File

@ -42,6 +42,14 @@ export class PostsController {
};
}
@Get('/old')
oldPosts(
@GetOrgFromRequest() org: Organization,
@Query('date') date: string
) {
return this._postsService.getOldPosts(org.id, date);
}
@Get('/:id')
getPost(@GetOrgFromRequest() org: Organization, @Param('id') id: string) {
return this._postsService.getPost(org.id, id);

View File

@ -1,19 +1,25 @@
import { Module } from '@nestjs/common';
import { CommandModule as ExternalCommandModule } from 'nestjs-command';
import {CheckStars} from "./tasks/check.stars";
import {DatabaseModule} from "@gitroom/nestjs-libraries/database/prisma/database.module";
import {RedisModule} from "@gitroom/nestjs-libraries/redis/redis.module";
import {BullMqModule} from "@gitroom/nestjs-libraries/bull-mq-transport/bull-mq.module";
import {ioRedis} from "@gitroom/nestjs-libraries/redis/redis.service";
import { CheckStars } from './tasks/check.stars';
import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/database.module';
import { RedisModule } from '@gitroom/nestjs-libraries/redis/redis.module';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport/bull-mq.module';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import {RefreshTokens} from "./tasks/refresh.tokens";
@Module({
imports: [ExternalCommandModule, DatabaseModule, RedisModule, BullMqModule.forRoot({
connection: ioRedis
})],
controllers: [],
providers: [CheckStars],
get exports() {
return [...this.imports, ...this.providers];
}
imports: [
ExternalCommandModule,
DatabaseModule,
RedisModule,
BullMqModule.forRoot({
connection: ioRedis,
}),
],
controllers: [],
providers: [CheckStars, RefreshTokens],
get exports() {
return [...this.imports, ...this.providers];
},
})
export class CommandModule {}

View File

@ -0,0 +1,16 @@
import { Command } from 'nestjs-command';
import { Injectable } from '@nestjs/common';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
@Injectable()
export class RefreshTokens {
constructor(private _integrationService: IntegrationService) {}
@Command({
command: 'refresh',
describe: 'Refresh all tokens',
})
async refresh() {
await this._integrationService.refreshTokens();
return true;
}
}

View File

@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import {CheckTrending} from "./tasks/check.trending";
import {RefreshTokens} from "@gitroom/cron/tasks/refresh.tokens";
import {CheckStars} from "@gitroom/cron/tasks/check.stars";
@Module({
imports: [ScheduleModule.forRoot()],
controllers: [],
providers: [CheckTrending],
providers: [RefreshTokens, CheckStars],
})
export class CronModule {}

View File

@ -1,10 +0,0 @@
import { Injectable } from '@nestjs/common';
import {Interval} from '@nestjs/schedule';
@Injectable()
export class CheckTrending {
@Interval(3600000)
checkTrending() {
console.log('hello');
}
}

View File

@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import {Cron} from '@nestjs/schedule';
import {IntegrationService} from "@gitroom/nestjs-libraries/database/prisma/integrations/integration.service";
@Injectable()
export class RefreshTokens {
constructor(
private _integrationService: IntegrationService,
) {
}
@Cron('0 * * * *')
async refresh() {
await this._integrationService.refreshTokens();
}
}

View File

@ -1,7 +1,15 @@
import {internalFetch} from "@gitroom/helpers/utils/internal.fetch";
import {redirect} from "next/navigation";
export default async function Page({params: {provider}, searchParams}: {params: {provider: string}, searchParams: object}) {
export default async function Page({params: {provider}, searchParams}: {params: {provider: string}, searchParams: any}) {
if (provider === 'x') {
searchParams = {
...searchParams,
state: searchParams.oauth_token || '',
code: searchParams.oauth_verifier || ''
};
}
await internalFetch(`/integrations/social/${provider}/connect`, {
method: 'POST',
body: JSON.stringify(searchParams)

View File

@ -7,7 +7,6 @@ import clsx from "clsx";
export const StarsAndForks: FC<StarsAndForksInterface> = (props) => {
const {list} = props;
console.log(list);
return (
<>
{list.map(item => (

View File

@ -1,6 +1,6 @@
'use client';
import { FC, useCallback, useEffect, useState } from 'react';
import React, { FC, useCallback, useEffect, useState } from 'react';
import dayjs from 'dayjs';
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
import clsx from 'clsx';
@ -27,6 +27,8 @@ import { PickPlatforms } from '@gitroom/frontend/components/launches/helpers/pic
import { ProvidersOptions } from '@gitroom/frontend/components/launches/providers.options';
import { v4 as uuidv4 } from 'uuid';
import { useSWRConfig } from 'swr';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { postSelector } from '@gitroom/frontend/components/post-url-selector/post.url.selector';
export const AddEditModal: FC<{
date: dayjs.Dayjs;
@ -70,6 +72,8 @@ export const AddEditModal: FC<{
const expend = useExpend();
const toaster = useToaster();
// if it's edit just set the current integration
useEffect(() => {
if (existingData.integration) {
@ -159,8 +163,13 @@ export const AddEditModal: FC<{
const schedule = useCallback(
(type: 'draft' | 'now' | 'schedule' | 'delete') => async () => {
if (type === 'delete') {
if (!await deleteDialog('Are you sure you want to delete this post?', 'Yes, delete it!')) {
return ;
if (
!(await deleteDialog(
'Are you sure you want to delete this post?',
'Yes, delete it!'
))
) {
return;
}
await fetch(`/posts/${existingData.group}`, {
method: 'DELETE',
@ -183,7 +192,7 @@ export const AddEditModal: FC<{
for (const key of allKeys) {
if (key.value.some((p) => !p.content || p.content.length < 6)) {
setShowError(true);
return ;
return;
}
if (!key.valid) {
@ -205,6 +214,11 @@ export const AddEditModal: FC<{
existingData.group = uuidv4();
mutate('/posts');
toaster.show(
!existingData.integration
? 'Added successfully'
: 'Updated successfully'
);
modal.closeAll();
},
[]
@ -270,6 +284,7 @@ export const AddEditModal: FC<{
.getCommands()
.filter((f) => f.name !== 'image'),
newImage,
postSelector(date),
]}
value={p.content}
preview="edit"
@ -399,6 +414,7 @@ export const AddEditModal: FC<{
<ProvidersOptions
integrations={selectedIntegrations}
editorValue={value}
date={date}
/>
</div>
)}

View File

@ -81,7 +81,6 @@ export const CalendarWeekProvider: FC<{
const { isLoading } = swr;
const { posts, comments } = swr?.data || { posts: [], comments: [] };
console.log(comments);
const changeDate = useCallback(
(id: string, date: dayjs.Dayjs) => {
setInternalData((d) =>

View File

@ -187,7 +187,7 @@ export const CommentComponent: FC<{ date: dayjs.Dayjs }> = (props) => {
})
).json();
setCommentsList(list => ([
setCommentsList((list) => [
{
id,
user: { email: user?.email!, id: user?.id! },
@ -195,7 +195,7 @@ export const CommentComponent: FC<{ date: dayjs.Dayjs }> = (props) => {
childrenComment: [],
},
...list,
]));
]);
},
[commentsList, setCommentsList]
);
@ -298,8 +298,6 @@ export const CommentComponent: FC<{ date: dayjs.Dayjs }> = (props) => {
</svg>
</button>
<CommentBox type="textarea" onChange={addComment} />
<div>
{commentsList.map((comment, index) => (
<>
@ -374,6 +372,7 @@ export const CommentComponent: FC<{ date: dayjs.Dayjs }> = (props) => {
</div>
</>
))}
<CommentBox type="textarea" onChange={addComment} />
</div>
</div>
);

View File

@ -38,7 +38,8 @@ export const newImage: ICommand = {
if (
state1.selectedText.includes('http') ||
state1.selectedText.includes('www')
state1.selectedText.includes('www') ||
state1.selectedText.includes('(post:')
) {
executeCommand({
api,

View File

@ -1,8 +1,17 @@
"use client";
'use client';
import {createContext, useContext} from "react";
import {Integrations} from "@gitroom/frontend/components/launches/calendar.context";
import { createContext, useContext } from 'react';
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
import dayjs from 'dayjs';
export const IntegrationContext = createContext<{integration: Integrations|undefined, value: Array<{content: string, id?: string, image?: Array<{path: string, id: string}>}>}>({integration: undefined, value: []});
export const IntegrationContext = createContext<{
date: dayjs.Dayjs;
integration: Integrations | undefined;
value: Array<{
content: string;
id?: string;
image?: Array<{ path: string; id: string }>;
}>;
}>({ integration: undefined, value: [], date: dayjs() });
export const useIntegration = () => useContext(IntegrationContext);
export const useIntegration = () => useContext(IntegrationContext);

View File

@ -3,12 +3,14 @@ import {Integrations} from "@gitroom/frontend/components/launches/calendar.conte
import {PickPlatforms} from "@gitroom/frontend/components/launches/helpers/pick.platform.component";
import {IntegrationContext} from "@gitroom/frontend/components/launches/helpers/use.integration";
import {ShowAllProviders} from "@gitroom/frontend/components/launches/providers/show.all.providers";
import dayjs from "dayjs";
export const ProvidersOptions: FC<{
integrations: Integrations[];
editorValue: Array<{ id?: string; content: string }>;
date: dayjs.Dayjs;
}> = (props) => {
const { integrations, editorValue } = props;
const { integrations, editorValue, date } = props;
const [selectedIntegrations, setSelectedIntegrations] = useState([
integrations[0],
]);
@ -28,7 +30,7 @@ export const ProvidersOptions: FC<{
hide={integrations.length === 1}
/>
<IntegrationContext.Provider
value={{ value: editorValue, integration: selectedIntegrations?.[0] }}
value={{ value: editorValue, integration: selectedIntegrations?.[0], date }}
>
<ShowAllProviders
value={editorValue}

View File

@ -11,6 +11,7 @@ import clsx from 'clsx';
import localFont from 'next/font/local';
import MDEditor from '@uiw/react-md-editor';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { Canonical } from '@gitroom/react/form/canonical';
const font = localFont({
src: [
@ -21,7 +22,7 @@ const font = localFont({
});
const DevtoPreview: FC = () => {
const { value} = useIntegration();
const { value } = useIntegration();
const settings = useSettings();
const image = useMediaDirectory();
const [coverPicture, title, tags] = settings.watch([
@ -55,7 +56,12 @@ const DevtoPreview: FC = () => {
</div>
</div>
<div className="px-[60px]">
<MDEditor.Markdown style={{ whiteSpace: 'pre-wrap' }} className={font.className} skipHtml={true} source={value.map(p => p.content).join('\n')} />
<MDEditor.Markdown
style={{ whiteSpace: 'pre-wrap' }}
className={font.className}
skipHtml={true}
source={value.map((p) => p.content).join('\n')}
/>
</div>
</div>
);
@ -63,10 +69,15 @@ const DevtoPreview: FC = () => {
const DevtoSettings: FC = () => {
const form = useSettings();
const { date } = useIntegration();
return (
<>
<Input label="Title" {...form.register('title')} />
<Input label="Canonical Link" {...form.register('canonical')} />
<Canonical
date={date}
label="Canonical Link"
{...form.register('canonical')}
/>
<MediaComponent
label="Cover picture"
description="Add a cover picture"

View File

@ -11,6 +11,7 @@ import clsx from 'clsx';
import MDEditor from '@uiw/react-md-editor';
import { Plus_Jakarta_Sans } from 'next/font/google';
import { MediaComponent } from '@gitroom/frontend/components/media/media.component';
import {Canonical} from "@gitroom/react/form/canonical";
const font = Plus_Jakarta_Sans({
subsets: ['latin'],
@ -60,11 +61,12 @@ const HashnodePreview: FC = () => {
const HashnodeSettings: FC = () => {
const form = useSettings();
const {date} = useIntegration();
return (
<>
<Input label="Title" {...form.register('title')} />
<Input label="Subtitle" {...form.register('subtitle')} />
<Input label="Canonical Link" {...form.register('canonical')} />
<Canonical date={date} label="Canonical Link" {...form.register('canonical')} />
<MediaComponent
label="Cover picture"
description="Add a cover picture"

View File

@ -24,6 +24,7 @@ import { MultiMediaComponent } from '@gitroom/frontend/components/media/media.co
import { createPortal } from 'react-dom';
import clsx from 'clsx';
import { newImage } from '@gitroom/frontend/components/launches/helpers/new.image.component';
import { postSelector } from '@gitroom/frontend/components/post-url-selector/post.url.selector';
// Simple component to change back to settings on after changing tab
export const SetTab: FC<{ changeTab: () => void }> = (props) => {
@ -74,7 +75,7 @@ export const withProvider = (
show: boolean;
}) => {
const existingData = useExistingData();
const { integration } = useIntegration();
const { integration, date } = useIntegration();
const [editInPlace, setEditInPlace] = useState(!!existingData.integration);
const [InPlaceValue, setInPlaceValue] = useState<
Array<{
@ -253,6 +254,7 @@ export const withProvider = (
.getCommands()
.filter((f) => f.name !== 'image'),
newImage,
postSelector(date),
]}
preview="edit"
// @ts-ignore
@ -338,6 +340,7 @@ export const withProvider = (
<div className="mt-[20px] flex flex-col items-center">
<IntegrationContext.Provider
value={{
date,
value: editInPlace ? InPlaceValue : props.value,
integration,
}}

View File

@ -9,6 +9,7 @@ import { useIntegration } from '@gitroom/frontend/components/launches/helpers/us
import clsx from 'clsx';
import MDEditor from '@uiw/react-md-editor';
import localFont from 'next/font/local'
import {Canonical} from "@gitroom/react/form/canonical";
const charter = localFont({
src: [
@ -67,11 +68,12 @@ const MediumPreview: FC = () => {
const MediumSettings: FC = () => {
const form = useSettings();
const {date} = useIntegration();
return (
<>
<Input label="Title" {...form.register('title')} />
<Input label="Subtitle" {...form.register('subtitle')} />
<Input label="Canonical Link" {...form.register('canonical')} />
<Canonical date={date} label="Canonical Link" {...form.register('canonical')} />
<div>
<MediumPublications {...form.register('publication')} />
</div>

View File

@ -12,7 +12,8 @@ import {
} from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/reddit.dto';
import clsx from 'clsx';
import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory';
import {deleteDialog} from "@gitroom/react/helpers/delete.dialog";
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import MDEditor from '@uiw/react-md-editor';
const RenderRedditComponent: FC<{
type: string;
@ -23,20 +24,16 @@ const RenderRedditComponent: FC<{
const { type, images } = props;
const [firstPost] = useFormatting(topValue, {
removeMarkdown: true,
saveBreaklines: true,
specialFunc: (text: string) => {
return text.slice(0, 280);
},
});
const [firstPost] = topValue;
switch (type) {
case 'self':
return (
<pre className="font-['Inter'] text-[14px] text-wrap">
{firstPost?.text}
</pre>
<MDEditor.Markdown
style={{ whiteSpace: 'pre-wrap', fontSize: '14px' }}
skipHtml={true}
source={firstPost?.content}
/>
);
case 'link':
return (
@ -81,7 +78,6 @@ const RedditPreview: FC = (props) => {
return text.slice(0, 280);
},
});
console.log(settings);
if (!settings || !settings.length) {
return <>Please add at least one Subreddit from the settings</>;
@ -130,6 +126,11 @@ const RedditPreview: FC = (props) => {
<div className="text-[14px] font-[600]">
{integration?.name}
</div>
<MDEditor.Markdown
style={{ whiteSpace: 'pre-wrap' }}
skipHtml={true}
source={p.text}
/>
<pre className="font-['Inter'] text-[14px] mt-[8px] font-[400] text-white">
{p.text}
</pre>
@ -155,22 +156,29 @@ const RedditSettings: FC = () => {
append({});
}, [fields, append]);
const deleteField = useCallback((index: number) => async () => {
if (!await deleteDialog('Are you sure you want to delete this Subreddit?')) return;
remove(index);
}, [fields, remove]);
const deleteField = useCallback(
(index: number) => async () => {
if (
!(await deleteDialog('Are you sure you want to delete this Subreddit?'))
)
return;
remove(index);
},
[fields, remove]
);
return (
<>
<div className="flex flex-col gap-[20px] mb-[20px]">
{fields.map((field, index) => (
<div key={field.id} className="flex flex-col relative">
<div onClick={deleteField(index)} className="absolute -left-[10px] justify-center items-center flex -top-[10px] w-[20px] h-[20px] bg-red-600 rounded-full text-white">
<div
onClick={deleteField(index)}
className="absolute -left-[10px] justify-center items-center flex -top-[10px] w-[20px] h-[20px] bg-red-600 rounded-full text-white"
>
x
</div>
<Subreddit
{...register(`subreddit.${index}.value`)}
/>
<Subreddit {...register(`subreddit.${index}.value`)} />
</div>
))}
</div>

View File

@ -8,6 +8,8 @@ import { ToolTip } from '@gitroom/frontend/components/layout/top.tip';
import { ShowMediaBoxModal } from '@gitroom/frontend/components/media/media.component';
import Image from 'next/image';
import dynamic from 'next/dynamic';
import { Toaster } from '@gitroom/react/toaster/toaster';
import { ShowPostSelector } from '@gitroom/frontend/components/post-url-selector/post.url.selector';
const NotificationComponent = dynamic(
() =>
@ -25,6 +27,8 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => {
<MantineWrapper>
<ToolTip />
<ShowMediaBoxModal />
<Toaster />
<ShowPostSelector />
<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 flex items-center gap-[10px]">

View File

@ -77,7 +77,6 @@ export const MediaBox: FC<{
})
).json();
console.log(data);
setListMedia([...mediaList, data]);
},
[mediaList]

View File

@ -0,0 +1,219 @@
'use client';
import { EventEmitter } from 'events';
import React, { FC, useCallback, useEffect, useState } from 'react';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import {
executeCommand,
ExecuteState,
ICommand,
selectWord,
TextAreaTextApi,
} from '@uiw/react-md-editor';
import dayjs from 'dayjs';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import removeMd from 'remove-markdown';
const postUrlEmitter = new EventEmitter();
export const ShowPostSelector = () => {
const [showPostSelector, setShowPostSelector] = useState(false);
const [callback, setCallback] = useState<{
callback: (tag: string) => void;
} | null>({ callback: (tag: string) => {} } as any);
const [date, setDate] = useState(dayjs());
useEffect(() => {
postUrlEmitter.on(
'show',
(params: { date: dayjs.Dayjs; callback: (url: string) => void }) => {
setCallback(params);
setDate(params.date);
setShowPostSelector(true);
}
);
return () => {
setShowPostSelector(false);
setCallback(null);
setDate(dayjs());
postUrlEmitter.removeAllListeners();
};
}, []);
const close = useCallback(() => {
setShowPostSelector(false);
setCallback(null);
setDate(dayjs());
}, []);
if (!showPostSelector) {
return <></>;
}
return (
<PostSelector onClose={close} onSelect={callback?.callback!} date={date} />
);
};
export const showPostSelector = (date: dayjs.Dayjs) => {
return new Promise<string>((resolve) => {
postUrlEmitter.emit('show', {
date,
callback: (tag: string) => {
resolve(tag);
},
});
});
};
export const useShowPostSelector = (day: dayjs.Dayjs) => {
return useCallback(() => {
return showPostSelector(day);
}, [day]);
};
export const PostSelector: FC<{
onClose: () => void;
onSelect: (tag: string) => void;
date: dayjs.Dayjs;
}> = (props) => {
const { onClose, onSelect, date } = props;
const fetch = useFetch();
const fetchOldPosts = useCallback(() => {
return fetch(
'/posts/old?date=' + date.utc().format('YYYY-MM-DDTHH:mm:00'),
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
).then((res) => res.json());
}, [date]);
const onCloseWithEmptyString = useCallback(() => {
onSelect('');
onClose();
}, []);
const select = useCallback((id: string) => () => {
onSelect(`(post:${id})`);
onClose();
}, []);
const { isLoading, data } = useSWR('old-posts', fetchOldPosts);
return (
<div className="text-white fixed left-0 top-0 bg-black/80 z-[300] w-full h-full p-[60px] animate-fade">
<div className="flex flex-col w-full h-full bg-[#0B101B] border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] relative">
<div className="flex">
<div className="flex-1">
<TopTitle
title={'Select Post Before ' + date.format('DD/MM/YYYY HH:mm:ss')}
/>
</div>
<button
onClick={onCloseWithEmptyString}
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>
<div className="mt-[10px]">
{!!data && data.length > 0 && (
<div className="flex flex-row flex-wrap gap-[10px]">
{data.map((p: any) => (
<div
onClick={select(p.id)}
className="cursor-pointer overflow-hidden flex gap-[20px] flex-col w-[200px] h-[200p] p-3 border border-tableBorder rounded-[8px] bg-secondary hover:bg-primary"
key={p.id}
>
<div className="flex gap-[10px] items-center">
<div className="relative">
<img
src={p.integration.picture}
className="w-[32px] h-[32px] rounded-full"
/>
<img
className="w-[20px] h-[20px] rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
src={
`/icons/platforms/` +
p?.integration?.providerIdentifier +
'.png'
}
/>
</div>
<div>{p.integration.name}</div>
</div>
<div className="flex-1">{removeMd(p.content)}</div>
<div>Status: {p.state}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
};
export const postSelector = (date: dayjs.Dayjs): ICommand => ({
name: 'postselector',
keyCommand: 'postselector',
shortcuts: 'ctrlcmd+p',
prefix: '(post:',
suffix: ')',
buttonProps: {
'aria-label': 'Add Post Url',
title: 'Add Post Url',
},
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="13"
height="13"
viewBox="0 0 32 32"
fill="none"
>
<path
d="M27.4602 14.6576C27.4039 14.4173 27.2893 14.1947 27.1264 14.0093C26.9635 13.824 26.7575 13.6817 26.5264 13.5951L19.7214 11.0438L21.4714 2.2938C21.5354 1.97378 21.4933 1.64163 21.3514 1.34773C21.2095 1.05382 20.9757 0.814201 20.6854 0.665207C20.395 0.516214 20.064 0.465978 19.7425 0.52212C19.421 0.578262 19.1266 0.737718 18.9039 0.976302L4.90393 15.9763C4.73549 16.1566 4.61413 16.3756 4.55059 16.614C4.48705 16.8525 4.4833 17.1028 4.53968 17.343C4.59605 17.5832 4.7108 17.8058 4.87377 17.9911C5.03673 18.1763 5.24287 18.3185 5.47393 18.4051L12.2789 20.9563L10.5289 29.7063C10.465 30.0263 10.5071 30.3585 10.649 30.6524C10.7908 30.9463 11.0247 31.1859 11.315 31.3349C11.6054 31.4839 11.9364 31.5341 12.2579 31.478C12.5794 31.4218 12.8738 31.2624 13.0964 31.0238L27.0964 16.0238C27.2647 15.8435 27.3859 15.6245 27.4494 15.3862C27.5128 15.1479 27.5165 14.8976 27.4602 14.6576ZM14.5064 25.1163L15.4714 20.2938C15.5412 19.9446 15.4845 19.5819 15.3113 19.2706C15.1382 18.9594 14.86 18.7199 14.5264 18.5951L8.62518 16.3838L17.4914 6.8838L16.5264 11.7063C16.4566 12.0555 16.5134 12.4182 16.6865 12.7295C16.8597 13.0407 17.1379 13.2802 17.4714 13.4051L23.3752 15.6163L14.5064 25.1163Z"
fill="#fff"
/>
</svg>
),
execute: async (state: ExecuteState, api: TextAreaTextApi) => {
const newSelectionRange = selectWord({
text: state.text,
selection: state.selection,
prefix: state.command.prefix!,
suffix: state.command.suffix,
});
let state1 = api.setSelectionRange(newSelectionRange);
state1 = api.setSelectionRange(newSelectionRange);
const media = await showPostSelector(date);
executeCommand({
api,
selectedText: state1.selectedText,
selection: state.selection,
prefix: media,
suffix: '',
});
},
});

View File

@ -3,14 +3,16 @@ const { join } = require('path');
module.exports = {
content: [
...createGlobPatternsForDependencies(__dirname + '../../../libraries/react-shared-libraries'),
join(
__dirname + '../../../libraries/react-shared-libraries',
'{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}'
...createGlobPatternsForDependencies(
__dirname + '../../../libraries/react-shared-libraries'
),
join(
__dirname,
'{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}'
__dirname + '../../../libraries/react-shared-libraries',
'{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}'
),
join(
__dirname,
'{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}'
),
...createGlobPatternsForDependencies(__dirname),
],
@ -27,25 +29,27 @@ module.exports = {
gray: '#8C8C8C',
input: '#131B2C',
inputText: '#64748B',
tableBorder: '#1F2941'
tableBorder: '#1F2941',
},
gridTemplateColumns: {
'13': 'repeat(13, minmax(0, 1fr));'
13: 'repeat(13, minmax(0, 1fr));',
},
backgroundImage: {
loginBox: 'url(/auth/login-box.png)',
loginBg: 'url(/auth/bg-login.png)'
loginBg: 'url(/auth/bg-login.png)',
},
animation: {
fade: 'fadeOut 0.5s ease-in-out',
overflow: 'overFlow 0.5s ease-in-out forwards',
overflowReverse: 'overFlowReverse 0.5s ease-in-out forwards',
fadeDown: 'fadeDown 4s ease-in-out forwards',
},
boxShadow: {
yellow: '0 0 60px 20px #6b6237'
yellow: '0 0 60px 20px #6b6237',
green: '0px 0px 50px rgba(60, 124, 90, 0.3)'
},
// that is actual animation
keyframes: theme => ({
keyframes: (theme) => ({
fadeOut: {
'0%': { opacity: 0, transform: 'translateY(30px)' },
'100%': { opacity: 1, transform: 'translateY(0)' },
@ -60,10 +64,15 @@ module.exports = {
'99%': { overflow: 'visible' },
'100%': { overflow: 'hidden' },
},
})
fadeDown: {
'0%': { opacity: 0, transform: 'translateY(-30px)' },
'10%': { opacity: 1, transform: 'translateY(0)' },
'85%': { opacity: 1, transform: 'translateY(0)' },
'90%': { opacity: 1, transform: 'translateY(10px)' },
'100%': { opacity: 0, transform: 'translateY(-30px)' },
},
}),
},
},
plugins: [
require('tailwind-scrollbar')
],
};
plugins: [require('tailwind-scrollbar')],
};

View File

@ -0,0 +1 @@
export const timer = (ms: number) => new Promise(res => setTimeout(res, ms));

View File

@ -1,43 +1,83 @@
import {PrismaRepository} from "@gitroom/nestjs-libraries/database/prisma/prisma.service";
import {Injectable} from "@nestjs/common";
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import dayjs from 'dayjs';
@Injectable()
export class IntegrationRepository {
constructor(
private _integration: PrismaRepository<'integration'>
) {
}
constructor(private _integration: PrismaRepository<'integration'>) {}
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,
organizationId: org,
}
})
}
createOrUpdateIntegration(
org: string,
name: string,
picture: string,
type: 'article' | 'social',
internalId: string,
provider: string,
token: string,
refreshToken = '',
expiresIn = 999999999
) {
return this._integration.model.integration.upsert({
where: {
organizationId_internalId: {
internalId,
organizationId: org,
},
},
create: {
type: type as any,
name,
providerIdentifier: provider,
token,
picture,
refreshToken,
...(expiresIn
? { tokenExpiration: new Date(Date.now() + expiresIn * 1000) }
: {}),
internalId,
organizationId: org,
},
update: {
type: type as any,
name,
providerIdentifier: provider,
token,
picture,
refreshToken,
...(expiresIn
? { tokenExpiration: new Date(Date.now() + expiresIn * 1000) }
: {}),
internalId,
organizationId: org,
},
});
}
getIntegrationById(org: string, id: string) {
return this._integration.model.integration.findFirst({
where: {
organizationId: org,
id
}
});
}
needsToBeRefreshed() {
return this._integration.model.integration.findMany({
where: {
tokenExpiration: {
lte: dayjs().add(1, 'day').toDate(),
},
deletedAt: null,
},
});
}
getIntegrationsList(org: string) {
return this._integration.model.integration.findMany({
where: {
organizationId: org
}
});
}
}
getIntegrationById(org: string, id: string) {
return this._integration.model.integration.findFirst({
where: {
organizationId: org,
id,
},
});
}
getIntegrationsList(org: string) {
return this._integration.model.integration.findMany({
where: {
organizationId: org,
},
});
}
}

View File

@ -1,21 +1,66 @@
import {Injectable} from "@nestjs/common";
import {IntegrationRepository} from "@gitroom/nestjs-libraries/database/prisma/integrations/integration.repository";
import { Injectable } from '@nestjs/common';
import { IntegrationRepository } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.repository';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
@Injectable()
export class IntegrationService {
constructor(
private _integrationRepository: IntegrationRepository,
) {
}
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);
}
constructor(
private _integrationRepository: IntegrationRepository,
private _integrationManager: IntegrationManager
) {}
createOrUpdateIntegration(
org: string,
name: string,
picture: string,
type: 'article' | 'social',
internalId: string,
provider: string,
token: string,
refreshToken = '',
expiresIn?: number
) {
return this._integrationRepository.createOrUpdateIntegration(
org,
name,
picture,
type,
internalId,
provider,
token,
refreshToken,
expiresIn
);
}
getIntegrationsList(org: string) {
return this._integrationRepository.getIntegrationsList(org);
}
getIntegrationsList(org: string) {
return this._integrationRepository.getIntegrationsList(org);
}
getIntegrationById(org: string, id: string) {
return this._integrationRepository.getIntegrationById(org, id);
getIntegrationById(org: string, id: string) {
return this._integrationRepository.getIntegrationById(org, id);
}
async refreshTokens() {
const integrations = await this._integrationRepository.needsToBeRefreshed();
for (const integration of integrations) {
const provider = this._integrationManager.getSocialIntegration(
integration.providerIdentifier
);
const { refreshToken, accessToken, expiresIn } =
await provider.refreshToken(integration.refreshToken!);
await this.createOrUpdateIntegration(
integration.organizationId,
integration.name,
integration.picture!,
'social',
integration.internalId,
integration.providerIdentifier,
accessToken,
refreshToken,
expiresIn
);
}
}
}

View File

@ -13,6 +13,52 @@ dayjs.extend(isoWeek);
export class PostsRepository {
constructor(private _post: PrismaRepository<'post'>) {}
getOldPosts(orgId: string, date: string) {
return this._post.model.post.findMany({
where: {
organizationId: orgId,
publishDate: {
lte: dayjs(date).toDate(),
},
deletedAt: null,
parentPostId: null,
},
orderBy: {
publishDate: 'desc',
},
select: {
id: true,
content: true,
publishDate: true,
releaseURL: true,
state: true,
integration: {
select: {
id: true,
name: true,
providerIdentifier: true,
picture: true,
},
},
},
});
}
getPostUrls(orgId: string, ids: string[]) {
return this._post.model.post.findMany({
where: {
organizationId: orgId,
id: {
in: ids,
},
},
select: {
id: true,
releaseURL: true,
},
});
}
getPosts(orgId: string, query: GetPostsDto) {
const date = dayjs().year(query.year).isoWeek(query.week);
@ -107,7 +153,12 @@ export class PostsRepository {
});
}
async createOrUpdatePost(state: 'draft' | 'schedule', orgId: string, date: string, body: PostBody) {
async createOrUpdatePost(
state: 'draft' | 'schedule',
orgId: string,
date: string,
body: PostBody
) {
const posts: Post[] = [];
const uuid = uuidv4();
@ -137,7 +188,7 @@ export class PostsRepository {
: {}),
content: value.content,
group: uuid,
state: state === 'draft' ? 'DRAFT' as const : 'QUEUE' as const,
state: state === 'draft' ? ('DRAFT' as const) : ('QUEUE' as const),
image: JSON.stringify(value.image),
settings: JSON.stringify(body.settings),
organization: {

View File

@ -4,7 +4,7 @@ import { CreatePostDto } from '@gitroom/nestjs-libraries/dtos/posts/create.post.
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';
import { Integration, Post, Media } from '@prisma/client';
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
type PostWithConditionals = Post & {
@ -55,6 +55,10 @@ export class PostsService {
};
}
async getOldPosts(orgId: string, date: string) {
return this._postRepository.getOldPosts(orgId, date);
}
async post(id: string) {
const [firstPost, ...morePosts] = await this.getPostsRecursively(id, true);
if (!firstPost) {
@ -71,6 +75,25 @@ export class PostsService {
return this.postSocial(firstPost.integration!, [firstPost, ...morePosts]);
}
private async updateTags(orgId: string, post: Post[]): Promise<Post[]> {
const plainText = JSON.stringify(post);
const extract = Array.from(
plainText.match(/\(post:[a-zA-Z0-9-_]+\)/g) || []
);
if (!extract.length) {
return post;
}
const ids = extract.map((e) => e.replace('(post:', '').replace(')', ''));
const urls = await this._postRepository.getPostUrls(orgId, ids);
const newPlainText = ids.reduce((acc, value) => {
const findUrl = urls?.find?.((u) => u.id === value)?.releaseURL || '';
return acc.replace(new RegExp(`\\(post:${value}\\)`, 'g'), findUrl.split(',')[0]);
}, plainText);
return this.updateTags(orgId, JSON.parse(newPlainText) as Post[]);
}
private async postSocial(integration: Integration, posts: Post[]) {
const getIntegration = this._integrationManager.getSocialIntegration(
integration.providerIdentifier
@ -79,13 +102,24 @@ export class PostsService {
return;
}
const newPosts = await this.updateTags(integration.organizationId, posts);
const publishedPosts = await getIntegration.post(
integration.internalId,
integration.token,
posts.map((p) => ({
newPosts.map((p) => ({
id: p.id,
message: p.content,
settings: JSON.parse(p.settings || '{}'),
media: (JSON.parse(p.image || '[]') as Media[]).map((m) => ({
url:
process.env.FRONTEND_URL +
'/' +
process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY +
m.path,
type: 'image',
path: process.env.UPLOAD_DIRECTORY + m.path,
})),
}))
);
@ -105,12 +139,15 @@ export class PostsService {
if (!getIntegration) {
return;
}
const newPosts = await this.updateTags(integration.organizationId, posts);
const { postId, releaseURL } = await getIntegration.post(
integration.token,
posts.map((p) => p.content).join('\n\n'),
JSON.parse(posts[0].settings || '{}')
newPosts.map((p) => p.content).join('\n\n'),
JSON.parse(newPosts[0].settings || '{}')
);
await this._postRepository.updatePost(posts[0].id, postId, releaseURL);
await this._postRepository.updatePost(newPosts[0].id, postId, releaseURL);
}
async deletePost(orgId: string, group: string) {
@ -135,16 +172,16 @@ export class PostsService {
'post',
previousPost ? previousPost : posts?.[0]?.id
);
if (body.type === 'schedule') {
// this._workerServiceProducer.emit('post', {
// id: posts[0].id,
// options: {
// delay: dayjs(posts[0].publishDate).diff(dayjs(), 'millisecond'),
// },
// payload: {
// id: posts[0].id,
// },
// });
if (body.type === 'schedule' && dayjs(body.date).isAfter(dayjs())) {
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

@ -141,7 +141,12 @@ model Integration {
tokenExpiration DateTime?
refreshToken String?
posts Post[]
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
@@index([updatedAt])
@@index([deletedAt])
@@unique([organizationId, internalId])
}

View File

@ -1,32 +1,46 @@
import {ArrayMaxSize, IsArray, IsDefined, IsOptional, IsString, Matches, MinLength, ValidateNested} from "class-validator";
import {MediaDto} from "@gitroom/nestjs-libraries/dtos/media/media.dto";
import {Type} from "class-transformer";
import {DevToTagsSettings} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.tags.settings";
import {
ArrayMaxSize,
IsArray,
IsDefined,
IsOptional,
IsString,
Matches,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto';
import { Type } from 'class-transformer';
import { DevToTagsSettings } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.tags.settings';
export class DevToSettingsDto {
@IsString()
@MinLength(2)
@IsDefined()
title: string;
@IsString()
@MinLength(2)
@IsDefined()
title: string;
@IsOptional()
@ValidateNested()
@Type(() => MediaDto)
main_image?: MediaDto;
@IsOptional()
@ValidateNested()
@Type(() => MediaDto)
main_image?: MediaDto;
@IsOptional()
@IsString()
@Matches(/^(|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/, {
message: 'Invalid URL'
})
canonical?: string;
@IsOptional()
@IsString()
@ValidateIf((o) => o.canonical && o.canonical.indexOf('(post:') === -1)
@Matches(
/^(|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/,
{
message: 'Invalid URL',
}
)
canonical?: string;
@IsString()
@IsOptional()
organization?: string;
@IsString()
@IsOptional()
organization?: string;
@IsArray()
@ArrayMaxSize(4)
@IsOptional()
tags: DevToTagsSettings[];
}
@IsArray()
@ArrayMaxSize(4)
@IsOptional()
tags: DevToTagsSettings[];
}

View File

@ -1,12 +1,5 @@
import {
ArrayMinSize,
IsArray,
IsDefined,
IsOptional,
IsString,
Matches,
MinLength,
ValidateNested,
ArrayMinSize, IsArray, IsDefined, IsOptional, IsString, Matches, MinLength, ValidateIf, ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto';
@ -37,6 +30,7 @@ export class HashnodeSettingsDto {
@IsOptional()
@IsString()
@ValidateIf((o) => o.canonical && o.canonical.indexOf('(post:') === -1)
@Matches(
/^(|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/,
{

View File

@ -1,37 +1,51 @@
import {ArrayMaxSize, IsArray, IsDefined, IsOptional, IsString, Matches, MinLength, ValidateNested} from "class-validator";
import {
ArrayMaxSize,
IsArray,
IsDefined,
IsOptional,
IsString,
Matches,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
export class MediumTagsSettings {
@IsString()
value: string;
@IsString()
value: string;
@IsString()
label: string;
@IsString()
label: string;
}
export class MediumSettingsDto {
@IsString()
@MinLength(2)
@IsDefined()
title: string;
@IsString()
@MinLength(2)
@IsDefined()
title: string;
@IsString()
@MinLength(2)
@IsDefined()
subtitle: string;
@IsString()
@MinLength(2)
@IsDefined()
subtitle: string;
@IsOptional()
@IsString()
@Matches(/^(|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/, {
message: 'Invalid URL'
})
canonical?: string;
@IsOptional()
@IsString()
@ValidateIf((o) => o.canonical && o.canonical.indexOf('(post:') === -1)
@Matches(
/^(|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/,
{
message: 'Invalid URL',
}
)
canonical?: string;
@IsString()
@IsOptional()
publication?: string;
@IsString()
@IsOptional()
publication?: string;
@IsArray()
@ArrayMaxSize(4)
@IsOptional()
tags: MediumTagsSettings[];
}
@IsArray()
@ArrayMaxSize(4)
@IsOptional()
tags: MediumTagsSettings[];
}

View File

@ -76,7 +76,6 @@ export class DevToProvider implements ArticleProvider {
article: {
title: settings.title,
body_markdown: content,
published: false,
main_image: settings?.main_image?.path
? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY}${settings?.main_image?.path}`
: undefined,

View File

@ -39,7 +39,7 @@ export class MediumProvider implements ArticleProvider {
async post(token: string, content: string, settings: MediumSettingsDto) {
const { id } = await this.authenticate(token);
const { data, ...all } = await (
const { data } = await (
await fetch(
settings?.publication
? `https://api.medium.com/v1/publications/${settings?.publication}/posts`
@ -64,7 +64,6 @@ export class MediumProvider implements ArticleProvider {
)
).json();
console.log(all);
return {
postId: data.id,
releaseURL: data.url,

View File

@ -5,6 +5,9 @@ import {
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { readFileSync } from 'fs';
import sharp from 'sharp';
import { lookup } from 'mime-types';
export class LinkedinProvider implements SocialProvider {
identifier = 'linkedin';
@ -110,13 +113,86 @@ export class LinkedinProvider implements SocialProvider {
};
}
private async uploadPicture(
accessToken: string,
personId: string,
picture: any
) {
const {
value: { uploadUrl, image },
} = await (
await fetch(
'https://api.linkedin.com/rest/images?action=initializeUpload',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202402',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
initializeUploadRequest: {
owner: `urn:li:person:${personId}`,
},
}),
}
)
).json();
await fetch(uploadUrl, {
method: 'PUT',
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202402',
Authorization: `Bearer ${accessToken}`,
},
body: picture,
});
return image;
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails[]
): Promise<PostResponse[]> {
const [firstPost, ...restPosts] = postDetails;
console.log('posting');
const uploadAll = (
await Promise.all(
postDetails.flatMap((p) =>
p?.media?.flatMap(async (m) => {
return {
id: await this.uploadPicture(
accessToken,
id,
await sharp(readFileSync(m.path), {
animated: lookup(m.path) === 'image/gif',
})
.resize({
width: 1000,
})
.toBuffer()
),
postId: p.id,
};
})
)
)
).reduce((acc, val) => {
if (!val?.id) {
return acc;
}
acc[val.postId] = acc[val.postId] || [];
acc[val.postId].push(val.id);
return acc;
}, {} as Record<string, string[]>);
const media_ids = (uploadAll[firstPost.id] || []).filter((f) => f);
const data = await fetch('https://api.linkedin.com/v2/posts', {
method: 'POST',
headers: {
@ -133,29 +209,25 @@ export class LinkedinProvider implements SocialProvider {
targetEntities: [],
thirdPartyDistributionChannels: [],
},
content: {
...(media_ids.length === 0
? {}
: media_ids.length === 1
? {
media: {
id: media_ids[0],
},
}
: {
multiImage: {
images: media_ids.map((id) => ({
id,
})),
},
}),
},
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,
// },
}),
});
@ -169,25 +241,27 @@ export class LinkedinProvider implements SocialProvider {
},
];
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,
const { object } = await (
await fetch(
`https://api.linkedin.com/v2/socialActions/${decodeURIComponent(
topPostId
)}/comments`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}),
}
)).json()
body: JSON.stringify({
actor: `urn:li:person:${id}`,
object: topPostId,
message: {
text: post.message,
},
}),
}
)
).json();
ids.push({
status: 'posted',

View File

@ -5,6 +5,9 @@ import {
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { RedditSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/reddit.dto';
import { timer } from '@gitroom/helpers/utils/timer';
import { groupBy } from 'lodash';
export class RedditProvider implements SocialProvider {
identifier = 'reddit';
@ -108,24 +111,107 @@ export class RedditProvider implements SocialProvider {
async post(
id: string,
accessToken: string,
postDetails: PostDetails[]
postDetails: PostDetails<RedditSettingsDto>[]
): 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',
}),
});
return [];
const valueArray: PostResponse[] = [];
for (const firstPostSettings of post.settings.subreddit) {
const {
json: {
data: { id, name, url },
},
} = await (
await fetch('https://oauth.reddit.com/api/submit', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
api_type: 'json',
title: firstPostSettings.value.type,
kind:
firstPostSettings.value.type === 'media'
? 'image'
: firstPostSettings.value.type,
...(firstPostSettings.value.flair
? { flair_id: firstPostSettings.value.flair.id }
: {}),
...(firstPostSettings.value.type === 'link'
? {
url: firstPostSettings.value.url,
}
: {}),
...(firstPostSettings.value.type === 'media'
? {
url: `${process.env.NEXT_PUBLIC_BACKEND_URL}/${process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY}${firstPostSettings.value.media[0].path}`,
}
: {}),
text: post.message,
sr: firstPostSettings.value.subreddit,
}),
})
).json();
valueArray.push({
postId: id,
releaseURL: url,
id: post.id,
status: 'published',
});
for (const comment of rest) {
const {
json: {
data: {
things: [
{
data: { id: commentId, permalink },
},
],
},
},
} = await (
await fetch('https://oauth.reddit.com/api/comment', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
text: comment.message,
thing_id: name,
api_type: 'json',
}),
})
).json();
// console.log(JSON.stringify(allTop, null, 2), JSON.stringify(allJson, null, 2), JSON.stringify(allData, null, 2));
valueArray.push({
postId: commentId,
releaseURL: 'https://www.reddit.com' + permalink,
id: comment.id,
status: 'published',
});
if (rest.length > 1) {
await timer(5000);
}
}
if (post.settings.subreddit.length > 1) {
await timer(5000);
}
}
return Object.values(groupBy(valueArray, (p) => p.id)).map((p) => ({
id: p[0].id,
postId: p.map((p) => p.postId).join(','),
releaseURL: p.map((p) => p.releaseURL).join(','),
status: 'published',
}));
}
async subreddits(accessToken: string, data: any) {
@ -144,12 +230,16 @@ export class RedditProvider implements SocialProvider {
)
).json();
console.log(children);
return children.filter(({data} : {data: any}) => data.subreddit_type === "public").map(({ data: { title, url, id } }: any) => ({
title,
name: url,
id,
}));
return children
.filter(
({ data }: { data: any }) =>
data.subreddit_type === 'public' && data.submission_type !== 'image'
)
.map(({ data: { title, url, id } }: any) => ({
title,
name: url,
id,
}));
}
private getPermissions(submissionType: string, allow_images: string) {
@ -162,9 +252,9 @@ export class RedditProvider implements SocialProvider {
permissions.push('link');
}
if (submissionType === "any" || allow_images) {
permissions.push('media');
}
// if (submissionType === 'any' || allow_images) {
// permissions.push('media');
// }
return permissions;
}
@ -182,36 +272,43 @@ export class RedditProvider implements SocialProvider {
})
).json();
const {
is_flair_required,
} = await (
await fetch(`https://oauth.reddit.com/api/v1/${data.subreddit.split('/r/')[1]}/post_requirements`, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
})
const { is_flair_required } = await (
await fetch(
`https://oauth.reddit.com/api/v1/${
data.subreddit.split('/r/')[1]
}/post_requirements`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
)
).json();
const newData = await (
await fetch(`https://oauth.reddit.com/${data.subreddit}/api/link_flair_v2`, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
})
await fetch(
`https://oauth.reddit.com/${data.subreddit}/api/link_flair_v2`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
)
).json();
return {
subreddit: data.subreddit,
allow: this.getPermissions(submission_type, allow_images),
is_flair_required,
flairs: newData?.map?.((p: any) => ({
id: p.id,
name: p.text
})) || []
}
flairs:
newData?.map?.((p: any) => ({
id: p.id,
name: p.text,
})) || [],
};
}
}

View File

@ -30,10 +30,10 @@ export type PostResponse = {
status: string; // Status of the operation or initial post status
};
export type PostDetails = {
export type PostDetails<T = any> = {
id: string;
message: string;
settings: object;
settings: T;
media?: MediaContent[];
poll?: PollDetails;
};
@ -46,7 +46,7 @@ export type PollDetails = {
export type MediaContent = {
type: 'image' | 'video'; // Type of the media content
url: string; // URL of the media file, if it's already hosted somewhere
file?: File; // The actual media file to upload, if not hosted
path: string;
};
export interface SocialProvider extends IAuthenticator, ISocialMediaIntegration {

View File

@ -5,6 +5,9 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { readFileSync } from 'fs';
import { lookup } from 'mime-types';
import sharp from 'sharp';
export class XProvider implements SocialProvider {
identifier = 'x';
@ -35,66 +38,125 @@ export class XProvider implements SocialProvider {
async generateAuthUrl() {
const client = new TwitterApi({
clientId: process.env.TWITTER_CLIENT_ID!,
clientSecret: process.env.TWITTER_CLIENT_SECRET!,
// clientId: process.env.TWITTER_CLIENT_ID!,
// clientSecret: process.env.TWITTER_CLIENT_SECRET!,
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
});
const { url, codeVerifier, state } = client.generateOAuth2AuthLink(
process.env.FRONTEND_URL + '/integrations/social/x',
{ scope: ['tweet.read', 'users.read', 'tweet.write', 'offline.access'] }
);
const { url, oauth_token, oauth_token_secret } =
await client.generateAuthLink(
process.env.FRONTEND_URL + '/integrations/social/x',
{
authAccessType: 'write',
linkMode: 'authenticate',
forceLogin: false,
}
);
return {
url,
codeVerifier,
state,
codeVerifier: oauth_token + ':' + oauth_token_secret,
state: oauth_token,
};
}
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 { code, codeVerifier } = params;
const [oauth_token, oauth_token_secret] = codeVerifier.split(':');
const {
data: { id, name, profile_image_url },
} = await client.v2.me({
'user.fields': 'profile_image_url',
const startingClient = new TwitterApi({
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
accessToken: oauth_token,
accessSecret: oauth_token_secret,
});
const { accessToken, client, accessSecret } = await startingClient.login(
code
);
const { id, name, profile_image_url_https } = await client.currentUser(
true
);
return {
id,
accessToken,
id: String(id),
accessToken: accessToken + ':' + accessSecret,
name,
refreshToken,
expiresIn,
picture: profile_image_url,
refreshToken: '',
expiresIn: 999999999,
picture: profile_image_url_https,
};
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails[],
postDetails: PostDetails[]
): Promise<PostResponse[]> {
const client = new TwitterApi(accessToken);
const {data: {username}} = await client.v2.me({
"user.fields": "username"
const [accessTokenSplit, accessSecretSplit] = accessToken.split(':');
const client = new TwitterApi({
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
accessToken: accessTokenSplit,
accessSecret: accessSecretSplit,
});
const ids: Array<{postId: string, id: string, releaseURL: string}> = [];
const {
data: { username },
} = await client.v2.me({
'user.fields': 'username',
});
// upload everything before, you don't want it to fail between the posts
const uploadAll = (
await Promise.all(
postDetails.flatMap((p) =>
p?.media?.flatMap(async (m) => {
return {
id: await client.v1.uploadMedia(
await sharp(readFileSync(m.path), {
animated: lookup(m.path) === 'image/gif',
})
.resize({
width: 1000,
})
.gif()
.toBuffer(),
{
mimeType: lookup(m.path) || '',
}
),
postId: p.id,
};
})
)
)
).reduce((acc, val) => {
if (!val?.id) {
return acc;
}
acc[val.postId] = acc[val.postId] || [];
acc[val.postId].push(val.id);
return acc;
}, {} as Record<string, string[]>);
const ids: Array<{ postId: string; id: string; releaseURL: string }> = [];
for (const post of postDetails) {
const media_ids = (uploadAll[post.id] || []).filter((f) => f);
const { data }: { data: { id: string } } = await client.v2.tweet({
text: post.message,
...(media_ids.length ? { media: { media_ids } } : {}),
...(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}`});
ids.push({
postId: data.id,
id: post.id,
releaseURL: `https://twitter.com/${username}/status/${data.id}`,
});
}
return ids.map((p) => ({

View File

@ -0,0 +1,79 @@
'use client';
import React, {
DetailedHTMLProps,
FC,
InputHTMLAttributes,
useCallback,
useMemo,
} from 'react';
import { clsx } from 'clsx';
import { useFormContext } from 'react-hook-form';
import dayjs from 'dayjs';
import { useShowPostSelector } from '../../../../apps/frontend/src/components/post-url-selector/post.url.selector';
export const Canonical: FC<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> & {
error?: any;
date: dayjs.Dayjs;
disableForm?: boolean;
label: string;
name: string;
}
> = (props) => {
const { label, date, className, disableForm, error, ...rest } = props;
const form = useFormContext();
const err = useMemo(() => {
if (error) return error;
if (!form || !form.formState.errors[props?.name!]) return;
return form?.formState?.errors?.[props?.name!]?.message! as string;
}, [form?.formState?.errors?.[props?.name!]?.message, error]);
const postSelector = useShowPostSelector(date);
const onPostSelector = useCallback(async () => {
const id = await postSelector();
if (disableForm) {
// @ts-ignore
return rest.onChange({
// @ts-ignore
target: { value: id, name: props.name },
});
}
return form.setValue(props.name, id);
}, [form]);
return (
<div className="flex flex-col gap-[6px]">
<div className="flex items-center gap-[3px]">
<div className="font-['Inter'] text-[14px]">{label}</div>
<div>
<svg
onClick={onPostSelector}
className="cursor-pointer"
xmlns="http://www.w3.org/2000/svg"
width="13"
height="13"
viewBox="0 0 32 32"
fill="none"
>
<path
d="M27.4602 14.6576C27.4039 14.4173 27.2893 14.1947 27.1264 14.0093C26.9635 13.824 26.7575 13.6817 26.5264 13.5951L19.7214 11.0438L21.4714 2.2938C21.5354 1.97378 21.4933 1.64163 21.3514 1.34773C21.2095 1.05382 20.9757 0.814201 20.6854 0.665207C20.395 0.516214 20.064 0.465978 19.7425 0.52212C19.421 0.578262 19.1266 0.737718 18.9039 0.976302L4.90393 15.9763C4.73549 16.1566 4.61413 16.3756 4.55059 16.614C4.48705 16.8525 4.4833 17.1028 4.53968 17.343C4.59605 17.5832 4.7108 17.8058 4.87377 17.9911C5.03673 18.1763 5.24287 18.3185 5.47393 18.4051L12.2789 20.9563L10.5289 29.7063C10.465 30.0263 10.5071 30.3585 10.649 30.6524C10.7908 30.9463 11.0247 31.1859 11.315 31.3349C11.6054 31.4839 11.9364 31.5341 12.2579 31.478C12.5794 31.4218 12.8738 31.2624 13.0964 31.0238L27.0964 16.0238C27.2647 15.8435 27.3859 15.6245 27.4494 15.3862C27.5128 15.1479 27.5165 14.8976 27.4602 14.6576ZM14.5064 25.1163L15.4714 20.2938C15.5412 19.9446 15.4845 19.5819 15.3113 19.2706C15.1382 18.9594 14.86 18.7199 14.5264 18.5951L8.62518 16.3838L17.4914 6.8838L16.5264 11.7063C16.4566 12.0555 16.5134 12.4182 16.6865 12.7295C16.8597 13.0407 17.1379 13.2802 17.4714 13.4051L23.3752 15.6163L14.5064 25.1163Z"
fill="#fff"
/>
</svg>
</div>
</div>
<input
{...(disableForm ? {} : 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

@ -1,7 +1,7 @@
"use client";
import {DetailedHTMLProps, FC, InputHTMLAttributes, useMemo} from "react";
import clsx from "clsx";
import {clsx} from "clsx";
import {useFormContext} from "react-hook-form";
export const Input: FC<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> & {error?: any, disableForm?: boolean, label: string, name: string}> = (props) => {

View File

@ -0,0 +1,88 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import EventEmitter from 'events';
const toaster = new EventEmitter();
export const Toaster = () => {
const [showToaster, setShowToaster] = useState(false);
const [toasterText, setToasterText] = useState('');
useEffect(() => {
toaster.on('show', (text: string) => {
setToasterText(text);
setShowToaster(true);
setTimeout(() => {
setShowToaster(false);
}, 4200);
});
return () => {
toaster.removeAllListeners();
};
}, []);
if (!showToaster) {
return <></>;
}
return (
<div className="animate-fadeDown shadow-green rounded-[8px] gap-[18px] flex items-center overflow-hidden bg-[#0F1524] p-[16px] min-w-[319px] fixed left-[50%] text-white z-[300] top-[32px] -translate-x-[50%] h-[56px]">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M12 2.25C10.0716 2.25 8.18657 2.82183 6.58319 3.89317C4.97982 4.96452 3.73013 6.48726 2.99218 8.26884C2.25422 10.0504 2.06114 12.0108 2.43735 13.9021C2.81355 15.7934 3.74215 17.5307 5.10571 18.8943C6.46928 20.2579 8.20656 21.1865 10.0979 21.5627C11.9892 21.9389 13.9496 21.7458 15.7312 21.0078C17.5127 20.2699 19.0355 19.0202 20.1068 17.4168C21.1782 15.8134 21.75 13.9284 21.75 12C21.7473 9.41498 20.7192 6.93661 18.8913 5.10872C17.0634 3.28084 14.585 2.25273 12 2.25ZM16.2806 10.2806L11.0306 15.5306C10.961 15.6004 10.8783 15.6557 10.7872 15.6934C10.6962 15.7312 10.5986 15.7506 10.5 15.7506C10.4014 15.7506 10.3038 15.7312 10.2128 15.6934C10.1218 15.6557 10.039 15.6004 9.96938 15.5306L7.71938 13.2806C7.57865 13.1399 7.49959 12.949 7.49959 12.75C7.49959 12.551 7.57865 12.3601 7.71938 12.2194C7.86011 12.0786 8.05098 11.9996 8.25 11.9996C8.44903 11.9996 8.6399 12.0786 8.78063 12.2194L10.5 13.9397L15.2194 9.21937C15.2891 9.14969 15.3718 9.09442 15.4628 9.0567C15.5539 9.01899 15.6515 8.99958 15.75 8.99958C15.8486 8.99958 15.9461 9.01899 16.0372 9.0567C16.1282 9.09442 16.2109 9.14969 16.2806 9.21937C16.3503 9.28906 16.4056 9.37178 16.4433 9.46283C16.481 9.55387 16.5004 9.65145 16.5004 9.75C16.5004 9.84855 16.481 9.94613 16.4433 10.0372C16.4056 10.1282 16.3503 10.2109 16.2806 10.2806Z"
fill="#6CE9A6"
/>
</svg>
</div>
<div className="flex-1">{toasterText}</div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="60"
height="56"
viewBox="0 0 60 56"
fill="none"
className="absolute top-0 left-0"
>
<g filter="url(#filter0_f_376_2968)">
<ellipse cx="-12" cy="28" rx="28" ry="13" fill="#6CE9A6" />
</g>
<defs>
<filter
id="filter0_f_376_2968"
x="-84"
y="-29"
width="144"
height="114"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="22"
result="effect1_foregroundBlur_376_2968"
/>
</filter>
</defs>
</svg>
</div>
);
};
export const useToaster = () => {
return {
show: useCallback((text: string) => {
toaster.emit('show', text);
}, []),
};
};

551
package-lock.json generated
View File

@ -31,6 +31,7 @@
"@types/jsonwebtoken": "^9.0.5",
"@types/lodash": "^4.14.202",
"@types/md5": "^2.3.5",
"@types/mime-types": "^2.1.4",
"@types/remove-markdown": "^0.3.4",
"@types/stripe": "^8.0.417",
"@uiw/react-md-editor": "^4.0.3",
@ -48,6 +49,7 @@
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"mime-types": "^2.1.35",
"multer": "^1.4.5-lts.1",
"nestjs-command": "^3.1.4",
"next": "13.4.4",
@ -66,6 +68,7 @@
"reflect-metadata": "^0.1.13",
"remove-markdown": "^0.5.0",
"rxjs": "^7.8.0",
"sharp": "^0.33.2",
"simple-statistics": "^7.8.3",
"stripe": "^14.14.0",
"sweetalert2": "^11.10.5",
@ -2400,6 +2403,15 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@emnapi/runtime": {
"version": "0.45.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.45.0.tgz",
"integrity": "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.11.0",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz",
@ -3138,6 +3150,437 @@
"integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==",
"dev": true
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.2.tgz",
"integrity": "sha512-itHBs1rPmsmGF9p4qRe++CzCgd+kFYktnsoR1sbIAfsRMrJZau0Tt1AH9KVnufc2/tU02Gf6Ibujx+15qRE03w==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"glibc": ">=2.26",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.1"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.2.tgz",
"integrity": "sha512-/rK/69Rrp9x5kaWBjVN07KixZanRr+W1OiyKdXcbjQD6KbW+obaTeBBtLUAtbBsnlTTmWthw99xqoOS7SsySDg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"glibc": ">=2.26",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.1"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-kQyrSNd6lmBV7O0BUiyu/OEw9yeNGFbQhbxswS1i6rMDwBBSX+e+rPzu3S+MwAiGU3HdLze3PanQ4Xkfemgzcw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"macos": ">=11",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.1.tgz",
"integrity": "sha512-eVU/JYLPVjhhrd8Tk6gosl5pVlvsqiFlt50wotCvdkFGf+mDNBJxMh+bvav+Wt3EBnNZWq8Sp2I7XfSjm8siog==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"macos": ">=10.13",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.1.tgz",
"integrity": "sha512-FtdMvR4R99FTsD53IA3LxYGghQ82t3yt0ZQ93WMZ2xV3dqrb0E8zq4VHaTOuLEAuA83oDawHV3fd+BsAPadHIQ==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.28",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.1.tgz",
"integrity": "sha512-bnGG+MJjdX70mAQcSLxgeJco11G+MxTz+ebxlz8Y3dxyeb3Nkl7LgLI0mXupoO+u1wRNx/iRj5yHtzA4sde1yA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.26",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.1.tgz",
"integrity": "sha512-3+rzfAR1YpMOeA2zZNp+aYEzGNWK4zF3+sdMxuCS3ey9HhDbJ66w6hDSHDMoap32DueFwhhs3vwooAB2MaK4XQ==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.28",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.1.tgz",
"integrity": "sha512-3NR1mxFsaSgMMzz1bAnnKbSAI+lHXVTqAHgc1bgzjHuXjo4hlscpUxc0vFSAPKI3yuzdzcZOkq7nDPrP2F8Jgw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.26",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.1.tgz",
"integrity": "sha512-5aBRcjHDG/T6jwC3Edl3lP8nl9U2Yo8+oTl5drd1dh9Z1EBfzUKAJFUDTDisDjUwc7N4AjnPGfCA3jl3hY8uDg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"musl": ">=1.2.2",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.1.tgz",
"integrity": "sha512-dcT7inI9DBFK6ovfeWRe3hG30h51cBAP5JXlZfx6pzc/Mnf9HFCQDLtYf4MCBjxaaTfjCCjkBxcy3XzOAo5txw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"musl": ">=1.2.2",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.2.tgz",
"integrity": "sha512-Fndk/4Zq3vAc4G/qyfXASbS3HBZbKrlnKZLEJzPLrXoJuipFNNwTes71+Ki1hwYW5lch26niRYoZFAtZVf3EGA==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.28",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.1"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.2.tgz",
"integrity": "sha512-pz0NNo882vVfqJ0yNInuG9YH71smP4gRSdeL09ukC2YLE6ZyZePAlWKEHgAzJGTiOh8Qkaov6mMIMlEhmLdKew==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.26",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.1"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.2.tgz",
"integrity": "sha512-MBoInDXDppMfhSzbMmOQtGfloVAflS2rP1qPcUIiITMi36Mm5YR7r0ASND99razjQUpHTzjrU1flO76hKvP5RA==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.28",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.1"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.2.tgz",
"integrity": "sha512-xUT82H5IbXewKkeF5aiooajoO1tQV4PnKfS/OZtb5DDdxS/FCI/uXTVZ35GQ97RZXsycojz/AJ0asoz6p2/H/A==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.26",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.1"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.2.tgz",
"integrity": "sha512-F+0z8JCu/UnMzg8IYW1TMeiViIWBVg7IWP6nE0p5S5EPQxlLd76c8jYemG21X99UzFwgkRo5yz2DS+zbrnxZeA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"musl": ">=1.2.2",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.1"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.2.tgz",
"integrity": "sha512-+ZLE3SQmSL+Fn1gmSaM8uFusW5Y3J9VOf+wMGNnTtJUMUxFhv+P4UPaYEYT8tqnyYVaOVGgMN/zsOxn9pSsO2A==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"musl": ">=1.2.2",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.1"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.2.tgz",
"integrity": "sha512-fLbTaESVKuQcpm8ffgBD7jLb/CQLcATju/jxtTXR1XCLwbOQt+OL5zPHSDMmp2JZIeq82e18yE0Vv7zh6+6BfQ==",
"cpu": [
"wasm32"
],
"optional": true,
"dependencies": {
"@emnapi/runtime": "^0.45.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.2.tgz",
"integrity": "sha512-okBpql96hIGuZ4lN3+nsAjGeggxKm7hIRu9zyec0lnfB8E7Z6p95BuRZzDDXZOl2e8UmR4RhYt631i7mfmKU8g==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.2.tgz",
"integrity": "sha512-E4magOks77DK47FwHUIGH0RYWSgRBfGdK56kIHSVeB9uIS4pPFr4N2kIVsXdQQo4LzOsENKV5KAhRlRL7eMAdg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@ioredis/commands": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
@ -6938,6 +7381,11 @@
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true
},
"node_modules/@types/mime-types": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz",
"integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="
},
"node_modules/@types/ms": {
"version": "0.7.34",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz",
@ -9753,6 +10201,18 @@
"integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==",
"dev": true
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -9769,6 +10229,15 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@ -21186,6 +21655,75 @@
"node": ">=8"
}
},
"node_modules/sharp": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.2.tgz",
"integrity": "sha512-WlYOPyyPDiiM07j/UO+E720ju6gtNtHjEGg5vovUk1Lgxyjm2LFO+37Nt/UI3MMh2l6hxTWQWi7qk3cXJTutcQ==",
"hasInstallScript": true,
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.2",
"semver": "^7.5.4"
},
"engines": {
"libvips": ">=8.15.1",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.2",
"@img/sharp-darwin-x64": "0.33.2",
"@img/sharp-libvips-darwin-arm64": "1.0.1",
"@img/sharp-libvips-darwin-x64": "1.0.1",
"@img/sharp-libvips-linux-arm": "1.0.1",
"@img/sharp-libvips-linux-arm64": "1.0.1",
"@img/sharp-libvips-linux-s390x": "1.0.1",
"@img/sharp-libvips-linux-x64": "1.0.1",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.1",
"@img/sharp-libvips-linuxmusl-x64": "1.0.1",
"@img/sharp-linux-arm": "0.33.2",
"@img/sharp-linux-arm64": "0.33.2",
"@img/sharp-linux-s390x": "0.33.2",
"@img/sharp-linux-x64": "0.33.2",
"@img/sharp-linuxmusl-arm64": "0.33.2",
"@img/sharp-linuxmusl-x64": "0.33.2",
"@img/sharp-wasm32": "0.33.2",
"@img/sharp-win32-ia32": "0.33.2",
"@img/sharp-win32-x64": "0.33.2"
}
},
"node_modules/sharp/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp/node_modules/semver": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -21248,6 +21786,19 @@
"node": "*"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
},
"node_modules/sirv": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",

View File

@ -31,6 +31,7 @@
"@types/jsonwebtoken": "^9.0.5",
"@types/lodash": "^4.14.202",
"@types/md5": "^2.3.5",
"@types/mime-types": "^2.1.4",
"@types/remove-markdown": "^0.3.4",
"@types/stripe": "^8.0.417",
"@uiw/react-md-editor": "^4.0.3",
@ -48,6 +49,7 @@
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"mime-types": "^2.1.35",
"multer": "^1.4.5-lts.1",
"nestjs-command": "^3.1.4",
"next": "13.4.4",
@ -66,6 +68,7 @@
"reflect-metadata": "^0.1.13",
"remove-markdown": "^0.5.0",
"rxjs": "^7.8.0",
"sharp": "^0.33.2",
"simple-statistics": "^7.8.3",
"stripe": "^14.14.0",
"sweetalert2": "^11.10.5",