add devto, medium and hashnode

This commit is contained in:
Nevo David 2025-07-12 20:47:31 +07:00
parent 004cf3c319
commit 45f0957e17
15 changed files with 394 additions and 199 deletions

View File

@ -44,7 +44,7 @@ const ActionControls = ({ store }: any) => {
<Button
loading={load}
className="outline-none"
innerClassName="invert outline-none"
innerClassName="invert outline-none text-black"
onClick={async () => {
setLoad(true);
const blob = await store.toBlob();
@ -56,10 +56,10 @@ const ActionControls = ({ store }: any) => {
body: formData,
})
).json();
close.setMedia({
close.setMedia([{
id: data.id,
path: data.path,
});
}]);
close.close();
}}
>
@ -69,7 +69,7 @@ const ActionControls = ({ store }: any) => {
);
};
const Polonto: FC<{
setMedia: (params: { id: string; path: string }) => void;
setMedia: (params: { id: string; path: string }[]) => void;
type?: 'image' | 'video';
closeModal: () => void;
width?: number;

View File

@ -127,7 +127,7 @@ export const Pagination: FC<{
export const ShowMediaBoxModal: FC = () => {
const [showModal, setShowModal] = useState(false);
const [callBack, setCallBack] =
useState<(params: { id: string; path: string }) => void | undefined>();
useState<(params: { id: string; path: string }[]) => void | undefined>();
const closeModal = useCallback(() => {
setShowModal(false);
setCallBack(undefined);
@ -155,7 +155,7 @@ export const showMediaBox = (
};
const CHUNK_SIZE = 1024 * 1024;
export const MediaBox: FC<{
setMedia: (params: { id: string; path: string }) => void;
setMedia: (params: { id: string; path: string }[]) => void;
type?: 'image' | 'video';
closeModal: () => void;
}> = (props) => {
@ -767,12 +767,12 @@ export const MediaComponent: FC<{
const showDesignModal = useCallback(() => {
setMediaModal(true);
}, [modal]);
const changeMedia = useCallback((m: { path: string; id: string }) => {
setCurrentMedia(m);
const changeMedia = useCallback((m: { path: string; id: string }[]) => {
setCurrentMedia(m[0]);
onChange({
target: {
name,
value: m,
value: m[0],
},
});
}, []);
@ -807,7 +807,7 @@ export const MediaComponent: FC<{
<div className="my-[20px] cursor-pointer w-[200px] h-[200px] border-2 border-tableBorder">
<img
className="w-full h-full object-cover"
src={mediaDirectory.set(currentMedia.path)}
src={currentMedia.path}
onClick={() => window.open(mediaDirectory.set(currentMedia.path))}
/>
</div>

View File

@ -90,8 +90,8 @@ export default withProvider({
postComment: PostComment.COMMENT,
minimumCharacters: [],
SettingsComponent: DevtoSettings,
CustomPreviewComponent: DevtoPreview,
CustomPreviewComponent: undefined, // DevtoPreview,
dto: DevToSettingsDto,
checkValidity: undefined,
maximumCharacters: undefined,
maximumCharacters: 100000,
});

View File

@ -16,6 +16,7 @@ export const DevtoTags: FC<{
}) => void;
}> = (props) => {
const { onChange, name, label } = props;
const form = useSettings();
const customFunc = useCustomProviderFunction();
const [tags, setTags] = useState<any[]>([]);
const { getValues } = useSettings();
@ -24,14 +25,9 @@ export const DevtoTags: FC<{
(tagIndex: number) => {
const modify = tagValue.filter((_, i) => i !== tagIndex);
setTagValue(modify);
onChange({
target: {
value: modify,
name,
},
});
form.setValue(name, modify);
},
[tagValue]
[tagValue, name, form]
);
const onAddition = useCallback(
(newTag: any) => {
@ -40,14 +36,9 @@ export const DevtoTags: FC<{
}
const modify = [...tagValue, newTag];
setTagValue(modify);
onChange({
target: {
value: modify,
name,
},
});
form.setValue(name, modify);
},
[tagValue]
[tagValue, name, form]
);
useEffect(() => {
customFunc.get('tags').then((data) => setTags(data));

View File

@ -92,8 +92,8 @@ export default withProvider({
postComment: PostComment.COMMENT,
minimumCharacters: [],
SettingsComponent: HashnodeSettings,
CustomPreviewComponent: HashnodePreview,
CustomPreviewComponent: undefined, // HashnodePreview,
dto: HashnodeSettingsDto,
checkValidity: undefined,
maximumCharacters: undefined,
maximumCharacters: 10000,
});

View File

@ -67,11 +67,11 @@ const MediumSettings: FC = () => {
);
};
export default withProvider({
postComment: PostComment.COMMENT,
postComment: PostComment.POST,
minimumCharacters: [],
SettingsComponent: MediumSettings,
CustomPreviewComponent: MediumPreview,
CustomPreviewComponent: undefined, //MediumPreview,
dto: MediumSettingsDto,
checkValidity: undefined,
maximumCharacters: undefined,
maximumCharacters: 100000,
});

View File

@ -10,6 +10,9 @@ import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-setting
import { InstagramDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/instagram.dto';
import { LinkedinDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/linkedin.dto';
import { IsIn } from 'class-validator';
import { MediumSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/medium.settings.dto';
import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto';
import { HashnodeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/hashnode.settings.dto';
export type ProviderExtension<T extends string, M> = { __type: T } & M;
export type AllProvidersSettings =
@ -26,6 +29,9 @@ export type AllProvidersSettings =
| ProviderExtension<'linkedin-page', LinkedinDto>
| ProviderExtension<'instagram', InstagramDto>
| ProviderExtension<'instagram-standalone', InstagramDto>
| ProviderExtension<'medium', MediumSettingsDto>
| ProviderExtension<'devto', DevToSettingsDto>
| ProviderExtension<'hashnode', HashnodeSettingsDto>
| ProviderExtension<'facebook', None>
| ProviderExtension<'threads', None>
| ProviderExtension<'mastodon', None>
@ -52,6 +58,9 @@ export const allProviders = (setEmpty?: any) => {
{ value: LinkedinDto, name: 'linkedin-page' },
{ value: InstagramDto, name: 'instagram' },
{ value: InstagramDto, name: 'instagram-standalone' },
{ value: MediumSettingsDto, name: 'medium' },
{ value: DevToSettingsDto, name: 'devto' },
{ value: HashnodeSettingsDto, name: 'hashnode' },
{ value: setEmpty, name: 'facebook' },
{ value: setEmpty, name: 'threads' },
{ value: setEmpty, name: 'mastodon' },

View File

@ -41,6 +41,7 @@ export class DevToSettingsDto {
@IsArray()
@ArrayMaxSize(4)
@IsOptional()
@Type(() => DevToTagsSettingsDto)
@ValidateNested({ each: true })
tags: DevToTagsSettingsDto[];
}

View File

@ -53,5 +53,7 @@ export class HashnodeSettingsDto {
@IsArray()
@ArrayMinSize(1)
@Type(() => HashnodeTagsSettings)
@ValidateNested({ each: true })
tags: HashnodeTagsSettings[];
}

View File

@ -1,104 +0,0 @@
import { ArticleProvider } from '@gitroom/nestjs-libraries/integrations/article/article.integrations.interface';
import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto';
export class DevToProvider implements ArticleProvider {
identifier = 'devto';
name = 'Dev.to';
async authenticate(token: string) {
const { name, id, profile_image, username } = await (
await fetch('https://dev.to/api/users/me', {
headers: {
'api-key': token,
},
})
).json();
return {
id,
name,
token,
picture: profile_image,
username,
};
}
async tags(token: string) {
const tags = await (
await fetch('https://dev.to/api/tags?per_page=1000&page=1', {
headers: {
'api-key': token,
},
})
).json();
return tags.map((p: any) => ({ value: p.id, label: p.name }));
}
async organizations(token: string) {
const orgs = await (
await fetch('https://dev.to/api/articles/me/all?per_page=1000', {
headers: {
'api-key': token,
},
})
).json();
const allOrgs: string[] = [
...new Set(
orgs
.flatMap((org: any) => org?.organization?.username)
.filter((f: string) => f)
),
] as string[];
const fullDetails = await Promise.all(
allOrgs.map(async (org: string) => {
return (
await fetch(`https://dev.to/api/organizations/${org}`, {
headers: {
'api-key': token,
},
})
).json();
})
);
return fullDetails.map((org: any) => ({
id: org.id,
name: org.name,
username: org.username,
}));
}
async post(token: string, content: string, settings: DevToSettingsDto) {
const { id, url } = await (
await fetch(`https://dev.to/api/articles`, {
method: 'POST',
body: JSON.stringify({
article: {
title: settings.title,
body_markdown: content,
published: true,
main_image: settings?.main_image?.path
? `${
settings?.main_image?.path.indexOf('http') === -1
? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY}`
: ``
}${settings?.main_image?.path}`
: undefined,
tags: settings?.tags?.map((t) => t.label),
organization_id: settings.organization,
},
}),
headers: {
'Content-Type': 'application/json',
'api-key': token,
},
})
).json();
return {
postId: String(id),
releaseURL: url,
};
}
}

View File

@ -1,36 +1,90 @@
import { ArticleProvider } from '@gitroom/nestjs-libraries/integrations/article/article.integrations.interface';
import { MediumSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/medium.settings.dto';
import {
AuthTokenDetails,
PostDetails,
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
export class MediumProvider implements ArticleProvider {
export class MediumProvider extends SocialAbstract implements SocialProvider {
identifier = 'medium';
name = 'Medium';
isBetweenSteps = false;
scopes = [] as string[];
async authenticate(token: string) {
const {
data: { name, id, imageUrl, username },
} = await (
await fetch('https://api.medium.com/v1/me', {
headers: {
Authorization: `Bearer ${token}`,
},
})
).json();
async generateAuthUrl() {
const state = makeId(6);
return {
id,
name,
token,
picture: imageUrl,
username,
url: '',
codeVerifier: makeId(10),
state,
};
}
async publications(token: string) {
const { id } = await this.authenticate(token);
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
return {
refreshToken: '',
expiresIn: 0,
accessToken: '',
id: '',
name: '',
picture: '',
username: '',
};
}
async customFields() {
return [
{
key: 'apiKey',
label: 'API key',
validation: `/^.{3,}$/`,
type: 'password' as const,
},
];
}
async authenticate(params: {
code: string;
codeVerifier: string;
refresh?: string;
}) {
const body = JSON.parse(Buffer.from(params.code, 'base64').toString());
try {
const {
data: { name, id, imageUrl, username },
} = await (
await fetch('https://api.medium.com/v1/me', {
headers: {
Authorization: `Bearer ${body.apiKey}`,
},
})
).json();
return {
refreshToken: '',
expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
accessToken: body.apiKey,
id,
name,
picture: imageUrl,
username,
};
} catch (err) {
return 'Invalid credentials';
}
}
async publications(accessToken: string, _: any, id: string) {
const { data } = await (
await fetch(`https://api.medium.com/v1/users/${id}/publications`, {
headers: {
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${accessToken}`,
},
})
).json();
@ -38,8 +92,13 @@ export class MediumProvider implements ArticleProvider {
return data;
}
async post(token: string, content: string, settings: MediumSettingsDto) {
const { id } = await this.authenticate(token);
async post(
id: string,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
): Promise<PostResponse[]> {
const { settings } = postDetails?.[0] || { settings: {} };
const { data } = await (
await fetch(
settings?.publication
@ -50,24 +109,28 @@ export class MediumProvider implements ArticleProvider {
body: JSON.stringify({
title: settings.title,
contentFormat: 'markdown',
content,
content: postDetails?.[0].message,
...(settings.canonical ? { canonicalUrl: settings.canonical } : {}),
...(settings?.tags?.length
? { tags: settings?.tags?.map((p) => p.value) }
? { tags: settings?.tags?.map((p: any) => p.value) }
: {}),
publishStatus: settings?.publication ? 'draft' : 'public',
}),
headers: {
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
)
).json();
return {
postId: data.id,
releaseURL: data.url,
};
return [
{
id: postDetails?.[0].id,
status: 'completed',
postId: data.id,
releaseURL: data.url,
},
];
}
}

View File

@ -9,8 +9,8 @@ import { XProvider } from '@gitroom/nestjs-libraries/integrations/social/x.provi
import { SocialProvider } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider';
import { RedditProvider } from '@gitroom/nestjs-libraries/integrations/social/reddit.provider';
import { DevToProvider } from '@gitroom/nestjs-libraries/integrations/article/dev.to.provider';
import { HashnodeProvider } from '@gitroom/nestjs-libraries/integrations/article/hashnode.provider';
import { DevToProvider } from '@gitroom/nestjs-libraries/integrations/social/dev.to.provider';
import { HashnodeProvider } from '@gitroom/nestjs-libraries/integrations/social/hashnode.provider';
import { MediumProvider } from '@gitroom/nestjs-libraries/integrations/article/medium.provider';
import { ArticleProvider } from '@gitroom/nestjs-libraries/integrations/article/article.integrations.interface';
import { FacebookProvider } from '@gitroom/nestjs-libraries/integrations/social/facebook.provider';
@ -54,13 +54,13 @@ export const socialIntegrationList: SocialProvider[] = [
new TelegramProvider(),
new NostrProvider(),
new VkProvider(),
new MediumProvider(),
new DevToProvider(),
new HashnodeProvider(),
// new MastodonCustomProvider(),
];
const articleIntegrationList = [
new DevToProvider(),
new HashnodeProvider(),
new MediumProvider(),
const articleIntegrationList: ArticleProvider[] = [
];
@Injectable()

View File

@ -0,0 +1,178 @@
import {
AuthTokenDetails,
PostDetails,
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
export class DevToProvider extends SocialAbstract implements SocialProvider {
identifier = 'devto';
name = 'Dev.to';
isBetweenSteps = false;
scopes = [] as string[];
async generateAuthUrl() {
const state = makeId(6);
return {
url: '',
codeVerifier: makeId(10),
state,
};
}
override handleErrors(body: string) {
if (body.indexOf('Canonical url has already been taken') > -1) {
return {
type: 'bad-body' as const,
value: 'Canonical URL already exists'
};
}
return undefined;
}
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
return {
refreshToken: '',
expiresIn: 0,
accessToken: '',
id: '',
name: '',
picture: '',
username: '',
};
}
async customFields() {
return [
{
key: 'apiKey',
label: 'API key',
validation: `/^.{3,}$/`,
type: 'password' as const,
},
];
}
async authenticate(params: {
code: string;
codeVerifier: string;
refresh?: string;
}) {
const body = JSON.parse(Buffer.from(params.code, 'base64').toString());
try {
const { name, id, profile_image, username } = await (
await fetch('https://dev.to/api/users/me', {
headers: {
'api-key': body.apiKey,
},
})
).json();
return {
refreshToken: '',
expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
accessToken: body.apiKey,
id,
name,
picture: profile_image,
username,
};
} catch (err) {
return 'Invalid credentials';
}
}
async tags(token: string) {
const tags = await (
await fetch('https://dev.to/api/tags?per_page=1000&page=1', {
headers: {
'api-key': token,
},
})
).json();
return tags.map((p: any) => ({ value: p.id, label: p.name }));
}
async organizations(token: string) {
const orgs = await (
await fetch('https://dev.to/api/articles/me/all?per_page=1000', {
headers: {
'api-key': token,
},
})
).json();
const allOrgs: string[] = [
...new Set(
orgs
.flatMap((org: any) => org?.organization?.username)
.filter((f: string) => f)
),
] as string[];
const fullDetails = await Promise.all(
allOrgs.map(async (org: string) => {
return (
await fetch(`https://dev.to/api/organizations/${org}`, {
headers: {
'api-key': token,
},
})
).json();
})
);
return fullDetails.map((org: any) => ({
id: org.id,
name: org.name,
username: org.username,
}));
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
): Promise<PostResponse[]> {
const { settings } = postDetails?.[0] || { settings: {} };
const { id: postId, url } = await (
await this.fetch(`https://dev.to/api/articles`, {
method: 'POST',
body: JSON.stringify({
article: {
title: settings.title,
body_markdown: postDetails?.[0].message,
published: true,
...(settings?.main_image?.path
? { main_image: settings?.main_image?.path }
: {}),
tags: settings?.tags?.map((t: any) => t.label),
organization_id: settings.organization,
...(settings.canonical
? { canonical_url: settings.canonical }
: {}),
},
}),
headers: {
'Content-Type': 'application/json',
'api-key': accessToken,
},
})
).json();
return [
{
id: postDetails?.[0].id,
status: 'completed',
postId: String(postId),
releaseURL: url,
},
];
}
}

View File

@ -1,12 +1,61 @@
import { ArticleProvider } from '@gitroom/nestjs-libraries/integrations/article/article.integrations.interface';
import {
AuthTokenDetails,
PostDetails,
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { tags } from '@gitroom/nestjs-libraries/integrations/article/hashnode.tags';
import { jsonToGraphQLQuery } from 'json-to-graphql-query';
import { HashnodeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/hashnode.settings.dto';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
export class HashnodeProvider implements ArticleProvider {
export class HashnodeProvider extends SocialAbstract implements SocialProvider {
identifier = 'hashnode';
name = 'Hashnode';
async authenticate(token: string) {
isBetweenSteps = false;
scopes = [] as string[];
async generateAuthUrl() {
const state = makeId(6);
return {
url: '',
codeVerifier: makeId(10),
state,
};
}
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
return {
refreshToken: '',
expiresIn: 0,
accessToken: '',
id: '',
name: '',
picture: '',
username: '',
};
}
async customFields() {
return [
{
key: 'apiKey',
label: 'API key',
validation: `/^.{3,}$/`,
type: 'password' as const,
},
];
}
async authenticate(params: {
code: string;
codeVerifier: string;
refresh?: string;
}) {
const body = JSON.parse(Buffer.from(params.code, 'base64').toString());
try {
const {
data: {
@ -17,7 +66,7 @@ export class HashnodeProvider implements ArticleProvider {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `${token}`,
Authorization: `${body.apiKey}`,
},
body: JSON.stringify({
query: `
@ -35,20 +84,16 @@ export class HashnodeProvider implements ArticleProvider {
).json();
return {
refreshToken: '',
expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
accessToken: body.apiKey,
id,
name,
token,
picture: profilePicture,
username,
};
} catch (err) {
return {
id: '',
name: '',
token: '',
picture: '',
username: '',
};
return 'Invalid credentials';
}
}
@ -56,7 +101,7 @@ export class HashnodeProvider implements ArticleProvider {
return tags.map((tag) => ({ value: tag.objectID, label: tag.name }));
}
async publications(token: string) {
async publications(accessToken: string) {
const {
data: {
me: {
@ -68,7 +113,7 @@ export class HashnodeProvider implements ArticleProvider {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `${token}`,
Authorization: `${accessToken}`,
},
body: JSON.stringify({
query: `
@ -97,7 +142,13 @@ export class HashnodeProvider implements ArticleProvider {
);
}
async post(token: string, content: string, settings: HashnodeSettingsDto) {
async post(
id: string,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
): Promise<PostResponse[]> {
const { settings } = postDetails?.[0] || { settings: {} };
const query = jsonToGraphQLQuery(
{
mutation: {
@ -109,8 +160,8 @@ export class HashnodeProvider implements ArticleProvider {
...(settings.canonical
? { originalArticleURL: settings.canonical }
: {}),
contentMarkdown: content,
tags: settings.tags.map((tag) => ({ id: tag.value })),
contentMarkdown: postDetails?.[0].message,
tags: settings.tags.map((tag: any) => ({ id: tag.value })),
...(settings.subtitle ? { subtitle: settings.subtitle } : {}),
...(settings.main_image
? {
@ -138,15 +189,15 @@ export class HashnodeProvider implements ArticleProvider {
const {
data: {
publishPost: {
post: { id, url },
post: { id: postId, url },
},
},
} = await (
await fetch('https://gql.hashnode.com', {
await this.fetch('https://gql.hashnode.com', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `${token}`,
Authorization: `${accessToken}`,
},
body: JSON.stringify({
query,
@ -154,9 +205,13 @@ export class HashnodeProvider implements ArticleProvider {
})
).json();
return {
postId: id,
releaseURL: url,
};
return [
{
id: postDetails?.[0].id,
status: 'completed',
postId: postId,
releaseURL: url,
},
];
}
}

View File

@ -58,17 +58,17 @@ class CloudflareStorage implements IUploadProvider {
}
async uploadSimple(path: string) {
const loadImage = await axios.get(path, { responseType: 'arraybuffer' });
const loadImage = await fetch(path);
const contentType =
loadImage?.headers?.['content-type'] ||
loadImage?.headers?.['Content-Type'];
loadImage?.headers?.get('content-type') ||
loadImage?.headers?.get('Content-Type');
const extension = getExtension(contentType)!;
const id = makeId(10);
const params = {
Bucket: this._bucketName,
Key: `${id}.${extension}`,
Body: loadImage.data,
Body: Buffer.from(await loadImage.arrayBuffer()),
ContentType: contentType,
ChecksumMode: 'DISABLED',
};