diff --git a/apps/frontend/public/icons/platforms/ghost.png b/apps/frontend/public/icons/platforms/ghost.png
new file mode 100644
index 0000000000..2d1e4ba523
Binary files /dev/null and b/apps/frontend/public/icons/platforms/ghost.png differ
diff --git a/apps/frontend/src/components/new-launch/providers/ghost/ghost.provider.tsx b/apps/frontend/src/components/new-launch/providers/ghost/ghost.provider.tsx
new file mode 100644
index 0000000000..85e0e8e2bf
--- /dev/null
+++ b/apps/frontend/src/components/new-launch/providers/ghost/ghost.provider.tsx
@@ -0,0 +1,46 @@
+'use client';
+
+import { FC } from 'react';
+import {
+ PostComment,
+ withProvider,
+} from '@gitroom/frontend/components/new-launch/providers/high.order.provider';
+import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
+import { Input } from '@gitroom/react/form/input';
+import { Select } from '@gitroom/react/form/select';
+import { MediaComponent } from '@gitroom/frontend/components/media/media.component';
+import { GhostTags } from '@gitroom/frontend/components/new-launch/providers/ghost/ghost.tags';
+import { GhostDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/ghost.dto';
+
+const GhostSettings: FC = () => {
+ const form = useSettings();
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
+
+export default withProvider({
+ postComment: PostComment.POST,
+ minimumCharacters: [],
+ SettingsComponent: GhostSettings,
+ CustomPreviewComponent: undefined,
+ dto: GhostDto,
+ checkValidity: undefined,
+ maximumCharacters: 100000,
+});
diff --git a/apps/frontend/src/components/new-launch/providers/ghost/ghost.tags.tsx b/apps/frontend/src/components/new-launch/providers/ghost/ghost.tags.tsx
new file mode 100644
index 0000000000..b8234b84a5
--- /dev/null
+++ b/apps/frontend/src/components/new-launch/providers/ghost/ghost.tags.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { FC, useCallback, useEffect, useState } from 'react';
+import { ReactTags, Tag, TagSelected } from 'react-tag-autocomplete';
+import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
+import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
+
+interface GhostTag {
+ value: string;
+ label: string;
+}
+
+export const GhostTags: FC<{
+ name: string;
+ label: string;
+ onChange: (event: {
+ target: {
+ value: string[];
+ name: string;
+ };
+ }) => void;
+}> = (props) => {
+ const { name, label } = props;
+ const form = useSettings();
+ const { getValues } = useSettings();
+ const customFunc = useCustomProviderFunction();
+ const [suggestions, setSuggestions] = useState([]);
+ const [tagValue, setTagValue] = useState([]);
+
+ const onDelete = useCallback(
+ (tagIndex: number) => {
+ const modify = tagValue.filter((_, i) => i !== tagIndex);
+ setTagValue(modify);
+ form.setValue(
+ name,
+ modify.map((t) => String(t.label))
+ );
+ },
+ [tagValue, name, form]
+ );
+
+ const onAddition = useCallback(
+ (newTag: Tag) => {
+ const modify = [...tagValue, newTag];
+ setTagValue(modify);
+ form.setValue(
+ name,
+ modify.map((t) => String(t.label))
+ );
+ },
+ [tagValue, name, form]
+ );
+
+ useEffect(() => {
+ // Fetch existing tags from Ghost
+ customFunc.get('tags').then((data: GhostTag[]) => {
+ setSuggestions(data || []);
+ });
+
+ // Load existing values
+ const settings = getValues()[name];
+ if (settings && Array.isArray(settings)) {
+ setTagValue(settings.map((t: string) => ({ value: t, label: t })));
+ }
+ }, []);
+
+ return (
+
+ );
+};
diff --git a/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx b/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx
index 521eb8ae50..3508bf9be4 100644
--- a/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx
+++ b/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx
@@ -33,6 +33,7 @@ import { PostComment } from '@gitroom/frontend/components/new-launch/providers/h
import WordpressProvider from '@gitroom/frontend/components/new-launch/providers/wordpress/wordpress.provider';
import ListmonkProvider from '@gitroom/frontend/components/new-launch/providers/listmonk/listmonk.provider';
import GmbProvider from '@gitroom/frontend/components/new-launch/providers/gmb/gmb.provider';
+import GhostProvider from '@gitroom/frontend/components/new-launch/providers/ghost/ghost.provider';
export const Providers = [
{
@@ -143,6 +144,10 @@ export const Providers = [
identifier: 'gmb',
component: GmbProvider,
},
+ {
+ identifier: 'ghost',
+ component: GhostProvider,
+ },
];
export const ShowAllProviders = forwardRef((props, ref) => {
const { date, current, global, selectedIntegrations, allIntegrations } =
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts
index 8ad6b8b8ea..ffa6b755ec 100644
--- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts
@@ -18,6 +18,7 @@ import { ListmonkDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-sett
import { GmbSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/gmb.settings.dto';
import { FarcasterDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/farcaster.dto';
import { FacebookDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/facebook.dto';
+import { GhostDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/ghost.dto';
export type ProviderExtension = { __type: T } & M;
export type AllProvidersSettings =
@@ -42,6 +43,7 @@ export type AllProvidersSettings =
| ProviderExtension<'gmb', GmbSettingsDto>
| ProviderExtension<'facebook', FacebookDto>
| ProviderExtension<'wrapcast', FarcasterDto>
+ | ProviderExtension<'ghost', GhostDto>
| ProviderExtension<'threads', None>
| ProviderExtension<'mastodon', None>
| ProviderExtension<'bluesky', None>
@@ -74,6 +76,7 @@ export const allProviders = (setEmpty?: any) => {
{ value: GmbSettingsDto, name: 'gmb' },
{ value: FarcasterDto, name: 'wrapcast' },
{ value: FacebookDto, name: 'facebook' },
+ { value: GhostDto, name: 'ghost' },
{ value: setEmpty, name: 'threads' },
{ value: setEmpty, name: 'mastodon' },
{ value: setEmpty, name: 'bluesky' },
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/ghost.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/ghost.dto.ts
new file mode 100644
index 0000000000..625b69e454
--- /dev/null
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/ghost.dto.ts
@@ -0,0 +1,42 @@
+import {
+ IsArray,
+ IsDefined,
+ IsEnum,
+ IsOptional,
+ IsString,
+ MinLength,
+ ValidateNested,
+} from 'class-validator';
+import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto';
+import { Type } from 'class-transformer';
+
+export enum GhostPostStatus {
+ PUBLISHED = 'published',
+ DRAFT = 'draft',
+ SCHEDULED = 'scheduled',
+}
+
+export class GhostDto {
+ @IsString()
+ @MinLength(1)
+ @IsDefined()
+ title: string;
+
+ @IsOptional()
+ @IsString()
+ slug?: string;
+
+ @IsEnum(GhostPostStatus)
+ @IsDefined()
+ status: GhostPostStatus;
+
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => MediaDto)
+ feature_image?: MediaDto;
+
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ tags?: string[];
+}
diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts
index a8ef22a439..a4ca5bbe39 100644
--- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts
+++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts
@@ -29,6 +29,7 @@ import { VkProvider } from '@gitroom/nestjs-libraries/integrations/social/vk.pro
import { WordpressProvider } from '@gitroom/nestjs-libraries/integrations/social/wordpress.provider';
import { ListmonkProvider } from '@gitroom/nestjs-libraries/integrations/social/listmonk.provider';
import { GmbProvider } from '@gitroom/nestjs-libraries/integrations/social/gmb.provider';
+import { GhostProvider } from '@gitroom/nestjs-libraries/integrations/social/ghost.provider';
export const socialIntegrationList: SocialProvider[] = [
new XProvider(),
@@ -58,6 +59,7 @@ export const socialIntegrationList: SocialProvider[] = [
new HashnodeProvider(),
new WordpressProvider(),
new ListmonkProvider(),
+ new GhostProvider(),
// new MastodonCustomProvider(),
];
diff --git a/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts b/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts
new file mode 100644
index 0000000000..b096a4ebf4
--- /dev/null
+++ b/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts
@@ -0,0 +1,290 @@
+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';
+import { GhostDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/ghost.dto';
+import slugify from 'slugify';
+import { sign } from 'jsonwebtoken';
+import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
+
+interface GhostCredentials {
+ domain: string;
+ apiKey: string;
+}
+
+export class GhostProvider extends SocialAbstract implements SocialProvider {
+ identifier = 'ghost';
+ name = 'Ghost';
+ isBetweenSteps = false;
+ editor = 'html' as const;
+ scopes = [] as string[];
+ override maxConcurrentJob = 5;
+ dto = GhostDto;
+
+ maxLength() {
+ return 100000;
+ }
+
+ async generateAuthUrl() {
+ const state = makeId(6);
+ return {
+ url: '',
+ codeVerifier: makeId(10),
+ state,
+ };
+ }
+
+ async refreshToken(refreshToken: string): Promise {
+ return {
+ refreshToken: '',
+ expiresIn: 0,
+ accessToken: '',
+ id: '',
+ name: '',
+ picture: '',
+ username: '',
+ };
+ }
+
+ async customFields() {
+ return [
+ {
+ key: 'domain',
+ label: 'Ghost Site URL',
+ validation: `/^https?:\\/\\/(?:www\\.)?[\\w\\-]+(\\.[\\w\\-]+)+([\\/?#][^\\s]*)?$/`,
+ type: 'text' as const,
+ },
+ {
+ key: 'apiKey',
+ label: 'Admin API Key',
+ validation: `/^[a-f0-9]+:[a-f0-9]+$/`,
+ type: 'password' as const,
+ },
+ ];
+ }
+
+ private generateGhostJWT(apiKey: string): string {
+ const [id, secret] = apiKey.split(':');
+ const secretBytes = Buffer.from(secret, 'hex');
+
+ return sign({}, secretBytes, {
+ algorithm: 'HS256',
+ keyid: id,
+ expiresIn: '5m',
+ audience: '/admin/',
+ });
+ }
+
+ private parseCredentials(accessToken: string): GhostCredentials {
+ return JSON.parse(Buffer.from(accessToken, 'base64').toString()) as GhostCredentials;
+ }
+
+ private getApiUrl(domain: string): string {
+ const cleanDomain = domain.replace(/\/$/, '');
+ return `${cleanDomain}/ghost/api/admin`;
+ }
+
+ async authenticate(params: {
+ code: string;
+ codeVerifier: string;
+ refresh?: string;
+ }) {
+ const credentials = JSON.parse(
+ Buffer.from(params.code, 'base64').toString()
+ ) as GhostCredentials;
+
+ try {
+ const token = this.generateGhostJWT(credentials.apiKey);
+ const apiUrl = this.getApiUrl(credentials.domain);
+
+ // Use /site/ endpoint instead of /users/me/ because integrations
+ // don't have a "me" user context - they authenticate as the integration itself
+ const response = await fetch(`${apiUrl}/site/`, {
+ headers: {
+ Authorization: `Ghost ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ console.error('Ghost authentication failed:', error);
+ return 'Invalid credentials or API key';
+ }
+
+ const data = await response.json();
+ const site = data.site;
+
+ if (!site) {
+ return 'Could not retrieve site information';
+ }
+
+ return {
+ refreshToken: '',
+ expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
+ accessToken: params.code,
+ id: `ghost_${site.title?.toLowerCase().replace(/\s+/g, '_') || 'site'}`,
+ name: site.title || 'Ghost Site',
+ picture: site.logo || site.icon || '',
+ username: new URL(credentials.domain).hostname,
+ };
+ } catch (err) {
+ console.error('Ghost authentication error:', err);
+ return 'Invalid credentials or connection error';
+ }
+ }
+
+ @Tool({ description: 'Get Ghost tags', dataSchema: [] })
+ async tags(token: string): Promise> {
+ try {
+ const credentials = this.parseCredentials(token);
+ const jwtToken = this.generateGhostJWT(credentials.apiKey);
+ const apiUrl = this.getApiUrl(credentials.domain);
+
+ const response = await fetch(`${apiUrl}/tags/?limit=all`, {
+ headers: {
+ Authorization: `Ghost ${jwtToken}`,
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ console.error('Failed to fetch Ghost tags:', await response.text());
+ return [];
+ }
+
+ const data = await response.json();
+ return (
+ data.tags?.map((tag: { slug: string; name: string }) => ({
+ value: tag.slug,
+ label: tag.name,
+ })) || []
+ );
+ } catch (err) {
+ console.error('Ghost tags fetch error:', err);
+ return [];
+ }
+ }
+
+ private async uploadImage(
+ apiUrl: string,
+ token: string,
+ imageUrl: string
+ ): Promise {
+ try {
+ const imageResponse = await fetch(imageUrl);
+ if (!imageResponse.ok) {
+ console.error('Failed to fetch image:', imageUrl);
+ return null;
+ }
+
+ const blob = await imageResponse.blob();
+ const filename = imageUrl.split('/').pop() || 'image.jpg';
+
+ const formData = new FormData();
+ formData.append('file', blob, filename);
+ formData.append('purpose', 'image');
+
+ const uploadResponse = await this.fetch(`${apiUrl}/images/upload/`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Ghost ${token}`,
+ },
+ body: formData,
+ });
+
+ const uploadData = await uploadResponse.json();
+ return uploadData.images?.[0]?.url || null;
+ } catch (err) {
+ console.error('Ghost image upload error:', err);
+ return null;
+ }
+ }
+
+ async post(
+ id: string,
+ accessToken: string,
+ postDetails: PostDetails[],
+ integration: Integration
+ ): Promise {
+ const credentials = this.parseCredentials(accessToken);
+ const token = this.generateGhostJWT(credentials.apiKey);
+ const apiUrl = this.getApiUrl(credentials.domain);
+
+ const firstPost = postDetails[0];
+ const settings = firstPost.settings;
+
+ let featureImageUrl: string | undefined;
+ if (settings?.feature_image?.path) {
+ const uploadedUrl = await this.uploadImage(
+ apiUrl,
+ token,
+ settings.feature_image.path
+ );
+ if (uploadedUrl) {
+ featureImageUrl = uploadedUrl;
+ }
+ }
+
+ // Extract title from first line of message if not provided in settings
+ // This handles cases where frontend doesn't pass Ghost-specific settings
+ const messageLines = firstPost.message.split('\n').filter((l) => l.trim());
+ const extractedTitle =
+ messageLines[0]?.replace(/<[^>]*>/g, '').trim() || 'Untitled';
+ const postTitle = settings?.title || extractedTitle;
+
+ const postSlug = settings?.slug
+ ? slugify(settings.slug, { lower: true, strict: true, trim: true })
+ : slugify(postTitle, {
+ lower: true,
+ strict: true,
+ trim: true,
+ });
+
+ const ghostPost: Record = {
+ title: postTitle,
+ html: firstPost.message,
+ slug: postSlug,
+ status: settings?.status || 'published',
+ };
+
+ if (featureImageUrl) {
+ ghostPost.feature_image = featureImageUrl;
+ }
+
+ if (settings?.tags && settings.tags.length > 0) {
+ ghostPost.tags = settings.tags.map((tag) => ({ name: tag }));
+ }
+
+ const response = await this.fetch(`${apiUrl}/posts/`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Ghost ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ posts: [ghostPost] }),
+ });
+
+ const responseData = await response.json();
+ const createdPost = responseData.posts?.[0];
+
+ if (!createdPost) {
+ throw new Error('Failed to create Ghost post');
+ }
+
+ return [
+ {
+ id: firstPost.id,
+ status: 'completed',
+ postId: String(createdPost.id),
+ releaseURL: createdPost.url,
+ },
+ ];
+ }
+}