feat: copilot changes

This commit is contained in:
Nevo David 2024-06-20 16:37:26 +02:00
commit 7fc1213e5d
139 changed files with 6020 additions and 2430 deletions

View File

@ -24,4 +24,11 @@ CLOUDFLARE_SECRET_ACCESS_KEY=
CLOUDFLARE_BUCKETNAME=
CLOUDFLARE_BUCKET_URL=
CLOUDFLARE_REGION=
FEE_AMOUNT=
FEE_AMOUNT=
OPENAI_API_KEY=""
FACEBOOK_APP_ID=""
FACEBOOK_APP_SECRET=""
YOUTUBE_CLIENT_ID=""
YOUTUBE_CLIENT_SECRET=""
TIKTOK_CLIENT_ID=""
TIKTOK_CLIENT_SECRET=""

View File

@ -1,42 +1,83 @@
import {Body, Controller, Get, Post} from '@nestjs/common';
import {Organization} from "@prisma/client";
import {GetOrgFromRequest} from "@gitroom/nestjs-libraries/user/org.from.request";
import {StarsService} from "@gitroom/nestjs-libraries/database/prisma/stars/stars.service";
import dayjs from "dayjs";
import {StarsListDto} from "@gitroom/nestjs-libraries/dtos/analytics/stars.list.dto";
import {ApiTags} from "@nestjs/swagger";
import { Body, Controller, Get, Inject, Param, Post, Query } from '@nestjs/common';
import { Organization } from '@prisma/client';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.service';
import dayjs from 'dayjs';
import { StarsListDto } from '@gitroom/nestjs-libraries/dtos/analytics/stars.list.dto';
import { ApiTags } from '@nestjs/swagger';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
@ApiTags('Analytics')
@Controller('/analytics')
export class AnalyticsController {
constructor(
private _starsService: StarsService
) {
}
@Get('/')
async getStars(
@GetOrgFromRequest() org: Organization
) {
return this._starsService.getStars(org.id);
constructor(
private _starsService: StarsService,
private _integrationService: IntegrationService,
private _integrationManager: IntegrationManager
) {}
@Get('/')
async getStars(@GetOrgFromRequest() org: Organization) {
return this._starsService.getStars(org.id);
}
@Get('/trending')
async getTrending() {
const todayTrending = dayjs(dayjs().format('YYYY-MM-DDT12:00:00'));
const last = todayTrending.isAfter(dayjs())
? todayTrending.subtract(1, 'day')
: todayTrending;
const nextTrending = last.add(1, 'day');
return {
last: last.format('YYYY-MM-DD HH:mm:ss'),
predictions: nextTrending.format('YYYY-MM-DD HH:mm:ss'),
};
}
@Post('/stars')
async getStarsFilter(
@GetOrgFromRequest() org: Organization,
@Body() starsFilter: StarsListDto
) {
return {
stars: await this._starsService.getStarsFilter(org.id, starsFilter),
};
}
@Get('/:integration')
async getIntegration(
@GetOrgFromRequest() org: Organization,
@Param('integration') integration: string,
@Query('date') date: string
) {
const getIntegration = await this._integrationService.getIntegrationById(
org.id,
integration
);
if (!getIntegration) {
throw new Error('Invalid integration');
}
@Get('/trending')
async getTrending() {
const todayTrending = dayjs(dayjs().format('YYYY-MM-DDT12:00:00'));
const last = todayTrending.isAfter(dayjs()) ? todayTrending.subtract(1, 'day') : todayTrending;
const nextTrending = last.add(1, 'day');
if (getIntegration.type === 'social') {
const integrationProvider = this._integrationManager.getSocialIntegration(
getIntegration.providerIdentifier
);
return {
last: last.format('YYYY-MM-DD HH:mm:ss'),
predictions: nextTrending.format('YYYY-MM-DD HH:mm:ss'),
}
}
const getIntegrationData = await ioRedis.get(`integration:${org.id}:${integration}:${date}`);
if (getIntegrationData) {
return JSON.parse(getIntegrationData)
}
@Post('/stars')
async getStarsFilter(
@GetOrgFromRequest() org: Organization,
@Body() starsFilter: StarsListDto
) {
return {stars: await this._starsService.getStarsFilter(org.id, starsFilter)};
if (integrationProvider.analytics) {
const loadAnalytics = await integrationProvider.analytics(getIntegration.internalId, getIntegration.token, +date);
await ioRedis.set(`integration:${org.id}:${integration}:${date}`, JSON.stringify(loadAnalytics), 'EX', !process.env.NODE_ENV || process.env.NODE_ENV === 'development' ? 1 : 3600);
return loadAnalytics;
}
return {};
}
}
}

View File

@ -30,6 +30,12 @@ export class AuthController {
getOrgFromCookie
);
if (body.provider === 'LOCAL') {
response.header('activate', 'true');
response.status(200).json({ activate: true });
return;
}
response.cookie('auth', jwt, {
domain:
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
@ -132,6 +138,29 @@ export class AuthController {
return this._authService.oauthLink(provider);
}
@Post('/activate')
async activate(
@Body('code') code: string,
@Res({ passthrough: true }) response: Response
) {
const activate = await this._authService.activate(code);
if (!activate) {
return response.status(200).send({ can: false });
}
response.cookie('auth', activate, {
domain:
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
secure: true,
httpOnly: true,
sameSite: 'none',
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
});
response.header('onboarding', 'true');
return response.status(200).send({ can: true });
}
@Post('/oauth/:provider/exists')
async oauthExists(
@Body('code') code: string,

View File

@ -49,6 +49,7 @@ export class IntegrationsController {
picture: p.picture,
identifier: p.providerIdentifier,
inBetweenSteps: p.inBetweenSteps,
refreshNeeded: p.refreshNeeded,
type: p.type,
})),
};
@ -71,7 +72,10 @@ export class IntegrationsController {
@Get('/social/:integration')
@CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL])
async getIntegrationUrl(@Param('integration') integration: string) {
async getIntegrationUrl(
@Param('integration') integration: string,
@Query('refresh') refresh: string
) {
if (
!this._integrationManager
.getAllowedSocialsIntegrations()
@ -83,7 +87,7 @@ export class IntegrationsController {
const integrationProvider =
this._integrationManager.getSocialIntegration(integration);
const { codeVerifier, state, url } =
await integrationProvider.generateAuthUrl();
await integrationProvider.generateAuthUrl(refresh);
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300);
return { url };
@ -170,7 +174,8 @@ export class IntegrationsController {
token,
'',
undefined,
username
username,
false
);
}
@ -194,6 +199,8 @@ export class IntegrationsController {
throw new Error('Invalid state');
}
await ioRedis.del(`login:${body.state}`);
const integrationProvider =
this._integrationManager.getSocialIntegration(integration);
const {
@ -207,6 +214,7 @@ export class IntegrationsController {
} = await integrationProvider.authenticate({
code: body.code,
codeVerifier: getCodeVerifier,
refresh: body.refresh,
});
if (!id) {
@ -224,7 +232,8 @@ export class IntegrationsController {
refreshToken,
expiresIn,
username,
integrationProvider.isBetweenSteps
integrationProvider.isBetweenSteps,
body.refresh
);
}
@ -239,7 +248,7 @@ export class IntegrationsController {
@Post('/instagram/:id')
async saveInstagram(
@Param('id') id: string,
@Body() body: { pageId: string, id: string },
@Body() body: { pageId: string; id: string },
@GetOrgFromRequest() org: Organization
) {
return this._integrationService.saveInstagram(org.id, id, body);
@ -254,6 +263,15 @@ export class IntegrationsController {
return this._integrationService.saveFacebook(org.id, id, body.page);
}
@Post('/linkedin-page/:id')
async saveLinkedin(
@Param('id') id: string,
@Body() body: { page: string },
@GetOrgFromRequest() org: Organization
) {
return this._integrationService.saveLinkedin(org.id, id, body.page);
}
@Post('/enable')
enableChannel(
@GetOrgFromRequest() org: Organization,

View File

@ -8,6 +8,7 @@ import {
Query,
UploadedFile,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Express } from 'express';
@ -15,6 +16,7 @@ import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.reque
import { Organization } from '@prisma/client';
import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service';
import { ApiTags } from '@nestjs/swagger';
import { CustomFileValidationPipe } from '@gitroom/nestjs-libraries/upload/custom.upload.validation';
@ApiTags('Media')
@Controller('/media')
@ -22,17 +24,10 @@ export class MediaController {
constructor(private _mediaService: MediaService) {}
@Post('/')
@UseInterceptors(FileInterceptor('file'))
@UsePipes(new CustomFileValidationPipe())
async uploadFile(
@GetOrgFromRequest() org: Organization,
@UploadedFile(
'file',
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }),
new FileTypeValidator({ fileType: /^(image\/.+|video\/mp4)$/ }),
],
})
)
@UploadedFile('file')
file: Express.Multer.File
) {
const filePath =

View File

@ -87,6 +87,7 @@ export class PostsController {
@GetOrgFromRequest() org: Organization,
@Body() body: CreatePostDto
) {
console.log(JSON.stringify(body, null, 2));
return this._postsService.createPost(org.id, body);
}

View File

@ -4,6 +4,7 @@ import {
Get,
HttpException,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
@ -12,7 +13,7 @@ import { Organization, User } from '@prisma/client';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { Response } from 'express';
import { Response, Request } from 'express';
import { AuthService } from '@gitroom/backend/services/auth/auth.service';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
@ -25,6 +26,7 @@ import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions
import { ApiTags } from '@nestjs/swagger';
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details.dto';
import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter';
@ApiTags('User')
@Controller('/user')
@ -39,10 +41,11 @@ export class UsersController {
@Get('/self')
async getSelf(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization
@GetOrgFromRequest() organization: Organization,
@Req() req: Request,
) {
if (!organization) {
throw new HttpException('Organization not found', 401);
throw new HttpForbiddenException();
}
return {
@ -56,6 +59,8 @@ export class UsersController {
role: organization?.users[0]?.role,
// @ts-ignore
isLifetime: !!organization?.subscription?.isLifetime,
admin: !!user.isSuperAdmin,
impersonate: !!req.cookies.impersonate,
};
}
@ -64,6 +69,38 @@ export class UsersController {
return this._userService.getPersonal(user.id);
}
@Get('/impersonate')
async getImpersonate(
@GetUserFromRequest() user: User,
@Query('name') name: string
) {
if (!user.isSuperAdmin) {
throw new HttpException('Unauthorized', 400);
}
return this._userService.getImpersonateUser(name);
}
@Post('/impersonate')
async setImpersonate(
@GetUserFromRequest() user: User,
@Body('id') id: string,
@Res({ passthrough: true }) response: Response
) {
if (!user.isSuperAdmin) {
throw new HttpException('Unauthorized', 400);
}
response.cookie('impersonate', id, {
domain:
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
secure: true,
httpOnly: true,
sameSite: 'none',
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
});
}
@Post('/personal')
async changePersonal(
@GetUserFromRequest() user: User,
@ -136,4 +173,39 @@ export class UsersController {
response.status(200).send();
}
@Post('/logout')
logout(@Res({ passthrough: true }) response: Response) {
response.cookie('auth', '', {
domain:
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
secure: true,
httpOnly: true,
maxAge: -1,
expires: new Date(0),
sameSite: 'none',
});
response.cookie('showorg', '', {
domain:
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
secure: true,
httpOnly: true,
maxAge: -1,
expires: new Date(0),
sameSite: 'none',
});
response.cookie('impersonate', '', {
domain:
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
secure: true,
httpOnly: true,
maxAge: -1,
expires: new Date(0),
sameSite: 'none',
});
response.status(200).send();
}
}

View File

@ -7,13 +7,14 @@ import {Logger, ValidationPipe} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import {SubscriptionExceptionFilter} from "@gitroom/backend/services/auth/permissions/subscription.exception";
import { HttpExceptionFilter } from '@gitroom/nestjs-libraries/services/exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
rawBody: true,
cors: {
credentials: true,
exposedHeaders: ['reload', 'onboarding'],
exposedHeaders: ['reload', 'onboarding', 'activate'],
origin: [process.env.FRONTEND_URL],
}
});
@ -24,6 +25,7 @@ async function bootstrap() {
app.use(cookieParser());
app.useGlobalFilters(new SubscriptionExceptionFilter());
app.useGlobalFilters(new HttpExceptionFilter());
loadSwagger(app);

View File

@ -1,45 +1,96 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import {AuthService} from "@gitroom/helpers/auth/auth.service";
import {User} from '@prisma/client';
import {OrganizationService} from "@gitroom/nestjs-libraries/database/prisma/organizations/organization.service";
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { User } from '@prisma/client';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
import { removeSubdomain } from '@gitroom/helpers/subdomain/subdomain.management';
import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter';
export const removeAuth = (res: Response) => {
res.cookie('auth', '', {
domain: '.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
secure: true,
httpOnly: true,
sameSite: 'none',
expires: new Date(0),
maxAge: -1,
});
res.header('logout', 'true');
};
@Injectable()
export class AuthMiddleware implements NestMiddleware {
constructor(
private _organizationService: OrganizationService,
) {
constructor(
private _organizationService: OrganizationService,
private _userService: UsersService
) {}
async use(req: Request, res: Response, next: NextFunction) {
const auth = req.headers.auth || req.cookies.auth;
if (!auth) {
throw new HttpForbiddenException();
}
async use(req: Request, res: Response, next: NextFunction) {
const auth = req.headers.auth || req.cookies.auth;
if (!auth) {
throw new Error('Unauthorized');
try {
let user = AuthService.verifyJWT(auth) as User | null;
const orgHeader = req.cookies.showorg || req.headers.showorg;
if (!user) {
throw new HttpForbiddenException();
}
if (!user.activated) {
throw new HttpForbiddenException();
}
if (user?.isSuperAdmin && req.cookies.impersonate) {
const loadImpersonate = await this._organizationService.getUserOrg(
req.cookies.impersonate
);
if (loadImpersonate) {
user = loadImpersonate.user;
user.isSuperAdmin = true;
delete user.password;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
req.user = user;
// @ts-ignore
loadImpersonate.organization.users =
loadImpersonate.organization.users.filter(
(f) => f.userId === user.id
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
req.org = loadImpersonate.organization;
next();
return;
}
try {
const user = AuthService.verifyJWT(auth) as User | null;
const orgHeader = req.cookies.showorg || req.headers.showorg;
}
if (!user) {
throw new Error('Unauthorized');
}
delete user.password;
const organization = (
await this._organizationService.getOrgsByUserId(user.id)
).filter((f) => !f.users[0].disabled);
const setOrg =
organization.find((org) => org.id === orgHeader) || organization[0];
delete user.password;
const organization = (await this._organizationService.getOrgsByUserId(user.id)).filter(f => !f.users[0].disabled);
const setOrg = organization.find((org) => org.id === orgHeader) || organization[0];
if (!organization) {
throw new HttpForbiddenException();
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
req.user = user;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
req.user = user;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
req.org = setOrg;
}
catch (err) {
throw new Error('Unauthorized');
}
console.log('Request...');
next();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
req.org = setOrg;
} catch (err) {
throw new HttpForbiddenException();
}
next();
}
}

View File

@ -10,13 +10,15 @@ import dayjs from 'dayjs';
import { NewsletterService } from '@gitroom/nestjs-libraries/services/newsletter.service';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto';
import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
@Injectable()
export class AuthService {
constructor(
private _userService: UsersService,
private _organizationService: OrganizationService,
private _notificationService: NotificationService
private _notificationService: NotificationService,
private _emailService: EmailService,
) {}
async routeAuth(
provider: Provider,
@ -31,7 +33,7 @@ export class AuthService {
}
const create = await this._organizationService.createOrgAndUser(body);
NewsletterService.register(body.email);
const addedOrg =
addToOrg && typeof addToOrg !== 'boolean'
? await this._organizationService.addUserToOrg(
@ -41,14 +43,21 @@ export class AuthService {
addToOrg.role
)
: false;
return { addedOrg, jwt: await this.jwt(create.users[0].user) };
const obj = { addedOrg, jwt: await this.jwt(create.users[0].user) };
await this._emailService.sendEmail(body.email, 'Activate your account', `Click <a href="${process.env.FRONTEND_URL}/auth/activate/${obj.jwt}">here</a> to activate your account`);
return obj;
}
if (!user || !AuthChecker.comparePassword(body.password, user.password)) {
throw new Error('Invalid user');
throw new Error('Invalid user name or password');
}
return { jwt: await this.jwt(user) };
if (!user.activated) {
throw new Error('User is not activated');
}
return { addedOrg: false, jwt: await this.jwt(user) };
}
const user = await this.loginOrRegisterProvider(
@ -152,6 +161,22 @@ export class AuthService {
return this._userService.updatePassword(user.id, body.password);
}
async activate(code: string) {
const user = AuthChecker.verifyJWT(code) as { id: string, activated: boolean, email: string };
if (user.id && !user.activated) {
const getUserAgain = await this._userService.getUserByEmail(user.email);
if (getUserAgain.activated) {
return false;
}
await this._userService.activateUser(user.id);
user.activated = true;
await NewsletterService.register(user.email);
return this.jwt(user as any);
}
return false;
}
oauthLink(provider: string) {
const providerInstance = ProvidersFactory.loadProvider(
provider as Provider

View File

@ -82,7 +82,7 @@ export class PermissionsService {
if (section === Sections.CHANNEL) {
const totalChannels = (
await this._integrationService.getIntegrationsList(orgId)
).length;
).filter(f => !f.refreshNeeded).length;
if (
(options.channel && options.channel > totalChannels) ||

View File

@ -5,7 +5,7 @@ export class GithubProvider implements ProvidersInterface {
return `https://github.com/login/oauth/authorize?client_id=${
process.env.GITHUB_CLIENT_ID
}&scope=user:email&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/settings?provider=github`
`${process.env.FRONTEND_URL}/settings`
)}`;
}

View File

@ -0,0 +1,74 @@
import {
AnalyticsData,
AuthTokenDetails,
PostDetails,
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { google } from 'googleapis';
import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client';
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
const clientAndYoutube = () => {
const client = new google.auth.OAuth2({
clientId: process.env.YOUTUBE_CLIENT_ID,
clientSecret: process.env.YOUTUBE_CLIENT_SECRET,
redirectUri: `${process.env.FRONTEND_URL}/integrations/social/youtube`,
});
const youtube = (newClient: OAuth2Client) =>
google.youtube({
version: 'v3',
auth: newClient,
});
const youtubeAnalytics = (newClient: OAuth2Client) =>
google.youtubeAnalytics({
version: 'v2',
auth: newClient,
});
const oauth2 = (newClient: OAuth2Client) =>
google.oauth2({
version: 'v2',
auth: newClient,
});
return { client, youtube, oauth2, youtubeAnalytics };
};
export class GoogleProvider implements ProvidersInterface {
generateLink() {
const state = makeId(7);
const { client } = clientAndYoutube();
return client.generateAuthUrl({
access_type: 'online',
prompt: 'consent',
state,
redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/youtube`,
scope: [
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/userinfo.email',
],
});
}
async getToken(code: string) {
const { client, oauth2 } = clientAndYoutube();
const { tokens } = await client.getToken(code);
return tokens.access_token;
}
async getUser(providerToken: string) {
const { client, oauth2 } = clientAndYoutube();
client.setCredentials({ access_token: providerToken });
const user = oauth2(client);
const { data } = await user.userinfo.get();
return {
id: data.id!,
email: data.email,
};
}
}

View File

@ -1,12 +1,15 @@
import {Provider} from "@prisma/client";
import {GithubProvider} from "@gitroom/backend/services/auth/providers/github.provider";
import {ProvidersInterface} from "@gitroom/backend/services/auth/providers.interface";
import { Provider } from '@prisma/client';
import { GithubProvider } from '@gitroom/backend/services/auth/providers/github.provider';
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google.provider';
export class ProvidersFactory {
static loadProvider(provider: Provider): ProvidersInterface {
switch (provider) {
case Provider.GITHUB:
return new GithubProvider();
}
static loadProvider(provider: Provider): ProvidersInterface {
switch (provider) {
case Provider.GITHUB:
return new GithubProvider();
case Provider.GOOGLE:
return new GoogleProvider();
}
}
}
}

View File

@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { RefreshTokens } from '@gitroom/cron/tasks/refresh.tokens';
import { CheckStars } from '@gitroom/cron/tasks/check.stars';
import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/database.module';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport/bull-mq.module';
@ -16,6 +15,6 @@ import { SyncTrending } from '@gitroom/cron/tasks/sync.trending';
}),
],
controllers: [],
providers: [RefreshTokens, CheckStars, SyncTrending],
providers: [CheckStars, SyncTrending],
})
export class CronModule {}

View File

@ -1,15 +0,0 @@
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

@ -12,13 +12,21 @@ const nextConfig = {
// See: https://github.com/gregberge/svgr
svgr: false,
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
},
env: {
isBillingEnabled: String(!!process.env.STRIPE_PUBLISHABLE_KEY),
isGeneral: String(!!process.env.IS_GENERAL),
}
frontendUrl: String(process.env.FRONTEND_URL),
},
};
const plugins = [
// Add more Next.js plugins to this list if needed.
withNx,

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.67 165.33" height="32" width="45"><path d="M229.763 25.817c-2.699-10.162-10.65-18.165-20.748-20.881C190.716 0 117.333 0 117.333 0S43.951 0 25.651 4.936C15.553 7.652 7.6 15.655 4.903 25.817 0 44.236 0 82.667 0 82.667s0 38.429 4.903 56.85C7.6 149.68 15.553 157.681 25.65 160.4c18.3 4.934 91.682 4.934 91.682 4.934s73.383 0 91.682-4.934c10.098-2.718 18.049-10.72 20.748-20.882 4.904-18.421 4.904-56.85 4.904-56.85s0-38.431-4.904-56.85" fill="red" /><path d="M93.333 117.559l61.333-34.89-61.333-34.894z" fill="#fff" /></svg>

After

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,6 @@
<svg width="366" height="167" viewBox="0 0 366 167" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.9659 30.4263V43.3825C26.9237 41.3095 29.3998 39.582 32.3941 38.2C35.3885 36.7028 39.0162 35.9543 43.2774 35.9543C47.1931 35.9543 50.8784 36.7028 54.3334 38.2C57.9036 39.6972 61.0131 42.1157 63.6619 45.4555C66.4259 48.6802 68.6141 52.9989 70.2264 58.4118C71.8387 63.8246 72.6449 70.3891 72.6449 78.1053C72.6449 83.6333 72.1266 89.1613 71.0902 94.6893C70.1688 100.217 68.4989 105.169 66.0804 109.546C63.6619 113.922 60.3796 117.492 56.2336 120.256C52.2028 122.905 47.1355 124.23 41.0316 124.23C36.6553 124.23 33.2003 123.654 30.6666 122.502C28.1329 121.235 26.2327 119.796 24.9659 118.183V160.162L0.0898438 166.381V30.4263H24.9659ZM32.7396 109.2C35.734 109.2 38.2676 108.221 40.3406 106.264C42.4136 104.191 44.026 101.542 45.1776 98.3171C46.4445 95.0924 47.3082 91.5222 47.7689 87.6066C48.3447 83.5757 48.6326 79.6025 48.6326 75.6868C48.6326 69.3526 48.0568 64.3429 46.9051 60.6575C45.8686 56.9722 44.6018 54.2658 43.1046 52.5383C41.6075 50.6956 40.1103 49.5439 38.6131 49.0833C37.2311 48.6226 36.137 48.3923 35.3309 48.3923C33.2579 48.3923 31.2425 49.1409 29.2846 50.638C27.3268 52.02 25.8872 54.1506 24.9659 57.0298V105.227C25.5417 106.148 26.463 107.07 27.7299 107.991C28.9967 108.797 30.6666 109.2 32.7396 109.2Z" fill="white"/>
<path d="M188.176 31.4627C191.055 42.5188 193.588 51.5593 195.777 58.5845C197.965 65.4945 199.807 71.3105 201.305 76.0323C202.917 80.7541 204.126 84.9001 204.932 88.4703C205.854 92.0405 206.314 96.0137 206.314 100.39C208.272 99.1232 210.172 97.7988 212.015 96.4168C213.858 94.9196 215.413 93.5376 216.679 92.2708H223.935C220.825 96.9926 217.543 100.908 214.088 104.018C210.633 107.012 207.293 109.661 204.069 111.964C201.996 116.456 198.829 119.623 194.567 121.466C190.306 123.308 185.872 124.23 181.266 124.23C176.083 124.23 171.649 123.539 167.964 122.157C164.279 120.659 161.227 118.702 158.808 116.283C156.505 113.749 154.777 110.87 153.626 107.646C152.474 104.421 151.898 101.023 151.898 97.4533C151.898 93.5376 152.819 90.4857 154.662 88.2975C156.62 85.9942 158.866 84.8426 161.399 84.8426C168.424 84.8426 171.937 87.6641 171.937 93.3073C171.937 95.15 171.304 96.7047 170.037 97.9716C168.77 99.2384 167.158 99.8718 165.2 99.8718C164.278 99.8718 163.3 99.7566 162.263 99.5263C161.342 99.1808 160.593 98.5474 160.017 97.6261C160.939 101.657 162.436 104.824 164.509 107.127C166.697 109.431 169.461 110.582 172.801 110.582C175.68 110.582 177.811 109.891 179.193 108.509C180.575 107.012 181.266 104.478 181.266 100.908C181.266 97.1078 180.92 93.7104 180.229 90.7161C179.653 87.6066 178.732 84.2091 177.465 80.5238C176.198 76.8385 174.644 72.4621 172.801 67.3948C170.958 62.2123 168.885 55.5326 166.582 47.3558C160.823 59.6786 153.222 67.5675 143.779 71.0225C143.779 71.9439 143.779 72.8652 143.779 73.7865C143.894 74.5927 143.952 75.4565 143.952 76.3778C143.952 83.0575 143.376 89.334 142.224 95.2076C141.072 100.966 139.115 106.033 136.351 110.41C133.702 114.671 130.247 118.068 125.986 120.602C121.724 123.02 116.484 124.23 110.265 124.23C106.004 124.23 101.916 123.596 98 122.329C94.1995 120.947 90.8021 118.759 87.8078 115.765C84.8134 112.655 82.3949 108.624 80.5523 103.672C78.8248 98.605 77.961 92.4436 77.961 85.188C77.961 80.2359 78.4793 74.9382 79.5158 69.295C80.5523 63.5367 82.4525 58.1814 85.2165 53.2293C87.9805 48.2771 91.7234 44.1887 96.4453 40.964C101.282 37.6242 107.444 35.9543 114.93 35.9543C122.646 35.9543 128.807 38.0273 133.414 42.1733C138.136 46.3193 141.303 52.9989 142.915 62.2123C146.946 61.2909 150.574 58.5269 153.798 53.9203C157.138 49.1984 160.305 42.8643 163.3 34.9177L188.176 31.4627ZM115.102 107.991C117.521 107.991 119.594 107.185 121.321 105.573C123.164 103.845 124.661 101.542 125.813 98.6626C126.964 95.6682 127.771 92.1556 128.231 88.1248C128.807 84.094 129.095 79.7176 129.095 74.9958V72.75C124.488 71.7135 122.185 68.3161 122.185 62.5578C122.185 58.8724 123.682 56.4539 126.677 55.3023C125.41 51.6169 123.855 49.1984 122.012 48.0468C120.285 46.8951 118.788 46.3193 117.521 46.3193C114.987 46.3193 112.799 47.5285 110.956 49.947C109.229 52.2504 107.789 55.2447 106.638 58.93C105.486 62.5002 104.622 66.4734 104.046 70.8498C103.586 75.2261 103.355 79.4297 103.355 83.4605C103.355 88.6431 103.701 92.8466 104.392 96.0713C105.198 99.296 106.177 101.772 107.329 103.5C108.48 105.227 109.747 106.436 111.129 107.127C112.511 107.703 113.835 107.991 115.102 107.991Z" fill="white"/>
<path d="M239.554 9.52348V36.818H250.092V43.728H239.554V95.5531C239.554 100.39 240.187 103.615 241.454 105.227C242.836 106.724 245.197 107.473 248.537 107.473C251.877 107.473 254.641 106.033 256.829 103.154C259.132 100.275 260.457 96.6471 260.802 92.2708H268.058C267.136 99.296 265.524 104.939 263.221 109.2C260.917 113.346 258.326 116.571 255.447 118.874C252.568 121.062 249.631 122.502 246.637 123.193C243.642 123.884 240.993 124.23 238.69 124.23C229.822 124.23 223.603 121.811 220.033 116.974C216.463 112.022 214.678 105.515 214.678 97.4533V43.728H209.15V36.818H214.678V12.9785L239.554 9.52348Z" fill="white"/>
<path d="M258.833 13.8422C258.833 10.0417 260.158 6.81706 262.806 4.16823C265.455 1.40422 268.68 0.0222168 272.48 0.0222168C276.281 0.0222168 279.506 1.40422 282.154 4.16823C284.918 6.81706 286.3 10.0417 286.3 13.8422C286.3 17.6427 284.918 20.8674 282.154 23.5162C279.506 26.1651 276.281 27.4895 272.48 27.4895C268.68 27.4895 265.455 26.1651 262.806 23.5162C260.158 20.8674 258.833 17.6427 258.833 13.8422ZM285.609 36.818V95.5531C285.609 100.39 286.243 103.615 287.51 105.227C288.892 106.724 291.253 107.473 294.592 107.473C296.09 107.473 297.184 107.358 297.875 107.127C298.681 106.897 299.372 106.667 299.948 106.436C300.063 107.012 300.12 107.588 300.12 108.164C300.12 108.74 300.12 109.315 300.12 109.891C300.12 112.77 299.602 115.131 298.566 116.974C297.644 118.817 296.377 120.314 294.765 121.466C293.268 122.502 291.598 123.193 289.755 123.539C288.028 123.999 286.358 124.23 284.746 124.23C275.878 124.23 269.659 121.811 266.089 116.974C262.518 112.022 260.733 105.515 260.733 97.4533V36.818H285.609ZM351.773 107.473C350.391 107.358 349.354 106.897 348.663 106.091C347.972 105.169 347.627 104.133 347.627 102.981C347.627 101.484 348.26 100.045 349.527 98.6626C350.794 97.1654 352.867 96.4168 355.746 96.4168C358.971 96.4168 361.389 97.5109 363.001 99.6991C364.614 101.772 365.42 104.248 365.42 107.127C365.42 108.97 365.074 110.87 364.383 112.828C363.692 114.671 362.598 116.398 361.101 118.011C359.604 119.508 357.761 120.775 355.573 121.811C353.385 122.732 350.851 123.193 347.972 123.193H300.293L334.152 46.1465H321.369C318.835 46.1465 316.704 46.3193 314.977 46.6648C313.365 46.8951 312.558 47.5285 312.558 48.565C312.558 49.0257 312.674 49.256 312.904 49.256C313.249 49.256 313.595 49.3712 313.94 49.6015C314.401 49.8318 314.747 50.2925 314.977 50.9835C315.322 51.6745 315.495 52.8838 315.495 54.6113C315.495 57.1449 314.689 58.9876 313.077 60.1393C311.579 61.2909 309.852 61.8668 307.894 61.8668C305.591 61.8668 303.345 61.1182 301.157 59.621C299.084 58.0087 298.047 55.5902 298.047 52.3655C298.047 50.638 298.393 48.9105 299.084 47.183C299.775 45.3403 300.811 43.6704 302.193 42.1733C303.575 40.5609 305.303 39.2941 307.376 38.3728C309.449 37.3363 311.867 36.818 314.631 36.818H362.138L329.142 109.891C329.833 109.891 330.812 109.949 332.079 110.064C333.346 110.179 334.67 110.294 336.052 110.41C337.55 110.525 338.989 110.64 340.371 110.755C341.868 110.87 343.193 110.928 344.344 110.928C346.417 110.928 348.145 110.697 349.527 110.237C351.024 109.776 351.773 108.855 351.773 107.473Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@ -0,0 +1,6 @@
<svg width="145" height="160" viewBox="0 0 145 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.67 27.5459C23.7772 29.2774 23.9284 31.2308 24.101 33.4605L30.3317 113.972C31.0281 122.971 31.3764 127.47 33.3936 130.772C35.168 133.676 37.8163 135.944 40.9588 137.25C44.5314 138.735 49.0308 138.387 58.0295 137.691L119.694 132.918C125.528 132.467 129.471 132.162 132.422 131.453C131.433 133.777 129.905 135.847 127.952 137.486C124.988 139.972 120.59 140.987 111.796 143.015L51.529 156.917C42.7343 158.945 38.337 159.96 34.583 159.023C31.281 158.199 28.3246 156.351 26.1375 153.743C23.6511 150.779 22.6367 146.382 20.6081 137.587L2.45777 58.901C0.42913 50.1063 -0.585193 45.709 0.351525 41.9551C1.17549 38.653 3.02365 35.6966 5.63114 33.5095C8.59546 31.0231 12.9928 30.0088 21.7875 27.9801L23.67 27.5459Z" fill="#612BD3"/>
<path d="M26.5624 33.2683C26.2111 28.7284 25.9609 25.4713 25.9719 22.8993C25.9828 20.3562 26.2536 18.7014 26.8213 17.3359C27.9276 14.6747 29.848 12.4321 32.3073 10.9295C33.5692 10.1585 35.1627 9.63635 37.6738 9.23431C40.2134 8.82769 43.4702 8.57372 48.0102 8.22237L109.675 3.45015C114.215 3.0988 117.472 2.84866 120.044 2.85968C122.587 2.87059 124.242 3.14131 125.607 3.70902C128.269 4.81537 130.511 6.73578 132.014 9.19502C132.785 10.4569 133.307 12.0504 133.709 14.5615C134.116 17.1012 134.37 20.358 134.721 24.898L140.952 105.41C141.303 109.95 141.553 113.207 141.542 115.779C141.531 118.322 141.261 119.976 140.693 121.342C139.586 124.003 137.666 126.246 135.207 127.748C133.945 128.519 132.351 129.041 129.84 129.444C127.301 129.85 124.044 130.104 119.504 130.455L57.8391 135.228C53.2992 135.579 50.0421 135.829 47.4701 135.818C44.927 135.807 43.2722 135.537 41.9067 134.969C39.2455 133.862 37.0029 131.942 35.5003 129.483C34.7293 128.221 34.2072 126.627 33.8051 124.116C33.3985 121.577 33.1445 118.32 32.7932 113.78L26.5624 33.2683Z" stroke="#131019" stroke-width="4.93733"/>
<path d="M48.2009 10.6828L109.866 5.91061C114.446 5.55612 117.586 5.31699 120.034 5.32748C122.424 5.33773 123.715 5.59492 124.66 5.98769C126.84 6.89391 128.677 8.46693 129.907 10.4813C130.441 11.3544 130.894 12.5911 131.272 14.951C131.659 17.3679 131.905 20.507 132.26 25.0876L138.491 105.599C138.845 110.18 139.084 113.319 139.074 115.767C139.063 118.157 138.806 119.449 138.413 120.393C137.507 122.573 135.934 124.41 133.92 125.641C133.047 126.174 131.81 126.627 129.45 127.005C127.033 127.392 123.894 127.639 119.314 127.993L57.6488 132.766C53.0683 133.12 49.9286 133.359 47.4808 133.349C45.0909 133.338 43.7993 133.081 42.8545 132.688C40.6748 131.782 38.8379 130.209 37.6071 128.195C37.0736 127.322 36.6207 126.085 36.2429 123.725C35.8559 121.308 35.6092 118.169 35.2547 113.589L29.0239 33.077C28.6694 28.4964 28.4303 25.3567 28.4408 22.909C28.451 20.5191 28.7082 19.2275 29.101 18.2827C30.0072 16.1029 31.5802 14.266 33.5946 13.0352C34.4677 12.5018 35.7044 12.0489 38.0642 11.6711C40.4812 11.2841 43.6203 11.0373 48.2009 10.6828Z" fill="white"/>
<path d="M74.8847 29.3257L75.4745 36.9536C76.5328 35.644 77.9119 34.5142 79.6119 33.5643C81.3066 32.5466 83.4083 31.9407 85.9171 31.7468C88.2224 31.5685 90.4261 31.8415 92.5284 32.5657C94.6984 33.2846 96.6392 34.5669 98.3507 36.4127C100.125 38.1853 101.61 40.6284 102.805 43.7417C104.001 46.8551 104.774 50.6832 105.125 55.226C105.377 58.4805 105.324 61.7587 104.965 65.0604C104.674 68.3569 103.916 71.3484 102.692 74.035C101.467 76.7216 99.6973 78.9729 97.3822 80.7889C95.1296 82.5319 92.2066 83.5423 88.613 83.8201C86.0365 84.0193 83.9762 83.8376 82.4321 83.2749C80.8828 82.6443 79.6985 81.8833 78.8792 80.9917L80.79 105.706L66.4276 110.5L60.2393 30.458L74.8847 29.3257ZM83.0471 75.3492C84.81 75.2129 86.2571 74.5213 87.3884 73.2743C88.5145 71.9595 89.3432 70.3266 89.8744 68.3757C90.4735 66.4195 90.8195 64.2783 90.9125 61.952C91.068 59.5527 91.0567 57.2004 90.8784 54.8951C90.5901 51.1659 90.0231 48.2427 89.1773 46.1254C88.3993 44.0029 87.5303 42.4672 86.5702 41.5183C85.6049 40.5016 84.6711 39.8917 83.7686 39.6886C82.934 39.4803 82.2794 39.3945 81.8048 39.4312C80.5843 39.5256 79.4319 40.058 78.3474 41.0286C77.2576 41.9313 76.507 43.2512 76.0957 44.9882L78.2895 73.3639C78.6705 73.8801 79.2548 74.3806 80.0426 74.8653C80.8251 75.2823 81.8266 75.4436 83.0471 75.3492Z" fill="#131019"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,15 +1,20 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import {AnalyticsComponent} from "@gitroom/frontend/components/analytics/analytics.component";
import {Metadata} from "next";
import { AnalyticsComponent } from '@gitroom/frontend/components/analytics/analytics.component';
import { Metadata } from 'next';
import { PlatformAnalytics } from '@gitroom/frontend/components/platform-analytics/platform.analytics';
export const metadata: Metadata = {
title: 'Gitroom Analytics',
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Analytics`,
description: '',
}
};
export default async function Index() {
return (
<AnalyticsComponent />
<>
{isGeneral() ? <PlatformAnalytics /> : <AnalyticsComponent />}
</>
);
}

View File

@ -3,9 +3,10 @@ import { LifetimeDeal } from '@gitroom/frontend/components/billing/lifetime.deal
export const dynamic = 'force-dynamic';
import { Metadata } from 'next';
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const metadata: Metadata = {
title: 'Gitroom Lifetime deal',
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Lifetime deal`,
description: '',
};

View File

@ -1,10 +1,12 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import { BillingComponent } from '@gitroom/frontend/components/billing/billing.component';
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Gitroom Billing',
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Billing`,
description: '',
};

View File

@ -15,6 +15,7 @@ export default async function Page({
...searchParams,
state: searchParams.oauth_token || '',
code: searchParams.oauth_verifier || '',
refresh: searchParams.refresh || '',
};
}
@ -25,7 +26,7 @@ export default async function Page({
})
).json();
if (inBetweenSteps) {
if (inBetweenSteps && !searchParams.refresh) {
return redirect(`/launches?added=${provider}&continue=${id}`);
}

View File

@ -1,10 +1,12 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import {LaunchesComponent} from "@gitroom/frontend/components/launches/launches.component";
import {Metadata} from "next";
export const metadata: Metadata = {
title: 'Gitroom Launches',
title: `${isGeneral() ? 'Postiz Calendar' : 'Gitroom Launches'}`,
description: '',
}

View File

@ -2,9 +2,10 @@ import { Buyer } from '@gitroom/frontend/components/marketplace/buyer';
export const dynamic = 'force-dynamic';
import { Metadata } from 'next';
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const metadata: Metadata = {
title: 'Gitroom Marketplace',
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Marketplace`,
description: '',
};
export default async function Index({

View File

@ -1,10 +1,12 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import { Metadata } from 'next';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export const metadata: Metadata = {
title: 'Gitroom Marketplace',
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Marketplace`,
description: '',
};
export default async function Index({

View File

@ -2,9 +2,10 @@ import { Seller } from '@gitroom/frontend/components/marketplace/seller';
export const dynamic = 'force-dynamic';
import { Metadata } from 'next';
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const metadata: Metadata = {
title: 'Gitroom Marketplace',
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Marketplace`,
description: '',
};
export default async function Index({

View File

@ -3,9 +3,10 @@ import { Messages } from '@gitroom/frontend/components/messages/messages';
export const dynamic = 'force-dynamic';
import { Metadata } from 'next';
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const metadata: Metadata = {
title: 'Gitroom Messages',
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Messages`,
description: '',
};

View File

@ -1,9 +1,11 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import {Metadata} from "next";
export const metadata: Metadata = {
title: 'Gitroom Messages',
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Messages`,
description: '',
}

View File

@ -1,3 +1,5 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import { SettingsComponent } from '@gitroom/frontend/components/settings/settings.component';
@ -7,7 +9,7 @@ import { RedirectType } from 'next/dist/client/components/redirect';
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Gitroom Settings',
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Settings`,
description: '',
};
export default async function Index({

View File

@ -0,0 +1,15 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import { Metadata } from 'next';
import { AfterActivate } from '@gitroom/frontend/components/auth/after.activate';
export const metadata: Metadata = {
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} - Activate your account`,
description: '',
};
export default async function Auth() {
return <AfterActivate />;
}

View File

@ -0,0 +1,17 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import {Metadata} from "next";
import { Activate } from '@gitroom/frontend/components/auth/activate';
export const metadata: Metadata = {
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} - Activate your account`,
description: '',
};
export default async function Auth() {
return (
<Activate />
);
}

View File

@ -1,10 +1,12 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import { ForgotReturn } from '@gitroom/frontend/components/auth/forgot-return';
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Gitroom Forgot Password',
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Forgot Password`,
description: '',
};
export default async function Auth(params: { params: { token: string } }) {

View File

@ -1,10 +1,12 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import {Forgot} from "@gitroom/frontend/components/auth/forgot";
import {Metadata} from "next";
export const metadata: Metadata = {
title: 'Gitroom Forgot Password',
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Forgot Password`,
description: '',
};

View File

@ -1,6 +1,10 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import { ReactNode } from 'react';
import Image from 'next/image';
import clsx from 'clsx';
export default async function AuthLayout({
children,
@ -12,6 +16,25 @@ export default async function AuthLayout({
<div className="absolute left-0 top-0 z-[0] h-[100vh] w-[100vw] overflow-hidden bg-loginBg bg-contain bg-no-repeat bg-left-top" />
<div className="relative z-[1] pr-[100px] flex justify-end items-center h-[100vh] w-[100vw] overflow-hidden">
<div className="w-[557px] flex h-[614px] bg-loginBox bg-contain">
<div className="w-full relative">
<div className="absolute -top-[100px] text-white justify-center items-center w-full flex gap-[10px]">
<Image
src={isGeneral() ? '/postiz.svg' : '/logo.svg'}
width={55}
height={53}
alt="Logo"
/>
<div
className={clsx(!isGeneral() ? 'mt-[12px]' : 'min-w-[80px]')}
>
{isGeneral() ? (
<img src="/postiz-text.svg" className="w-[80px]" />
) : (
<div className="text-[40px]">Gitroom</div>
)}
</div>
</div>
</div>
<div className="p-[32px] absolute w-[557px] h-[614px] text-white">
{children}
</div>

View File

@ -1,10 +1,12 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import {Login} from "@gitroom/frontend/components/auth/login";
import {Metadata} from "next";
export const metadata: Metadata = {
title: 'Gitroom Login',
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Login`,
description: '',
};

View File

@ -1,10 +1,12 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import { Register } from '@gitroom/frontend/components/auth/register';
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Gitroom Register',
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Register`,
description: '',
};

View File

@ -8,6 +8,7 @@ import "@copilotkit/react-ui/styles.css";
import LayoutContext from '@gitroom/frontend/components/layout/layout.context';
import { ReactNode } from 'react';
import { Chakra_Petch } from 'next/font/google';
import { isGeneral } from '@gitroom/react/helpers/is.general';
const chakra = Chakra_Petch({ weight: '400', subsets: ['latin'] });
@ -15,7 +16,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
return (
<html className={interClass}>
<head>
<link rel="icon" href="/favicon.png" sizes="any" />
<link rel="icon" href={!isGeneral() ? "/favicon.png" : "/postiz-fav.png"} sizes="any" />
</head>
<body className={chakra.className}>
<LayoutContext>{children}</LayoutContext>

View File

@ -0,0 +1,84 @@
'use client';
import { FC, useEffect, useMemo, useRef } from 'react';
import DrawChart from 'chart.js/auto';
import { TotalList } from '@gitroom/frontend/components/analytics/stars.and.forks.interface';
import dayjs from 'dayjs';
import { chunk } from 'lodash';
function mergeDataPoints(data: TotalList[], numPoints: number): TotalList[] {
const res = chunk(data, Math.ceil(data.length / numPoints));
return res.map((row) => {
return {
date: `${row[0].date} - ${row?.at(-1)?.date}`,
total: row.reduce((acc, curr) => acc + curr.total, 0),
};
});
}
export const ChartSocial: FC<{ data: TotalList[] }> = (props) => {
const { data } = props;
const list = useMemo(() => {
return mergeDataPoints(data, 7);
}, [data]);
const ref = useRef<any>(null);
const chart = useRef<null | DrawChart>(null);
useEffect(() => {
const gradient = ref.current
.getContext('2d')
.createLinearGradient(0, 0, 0, ref.current.height);
gradient.addColorStop(0, 'rgb(20,101,6)'); // Start color with some transparency
gradient.addColorStop(1, 'rgb(9, 11, 19, 1)');
chart.current = new DrawChart(ref.current!, {
type: 'line',
options: {
maintainAspectRatio: false,
responsive: true,
layout: {
padding: {
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
scales: {
y: {
beginAtZero: true,
display: false,
},
x: {
display: false,
ticks: {
stepSize: 10,
maxTicksLimit: 7,
},
},
},
plugins: {
legend: {
display: false,
},
},
},
data: {
labels: list.map((row) => row.date),
datasets: [
{
borderColor: '#fff',
// @ts-ignore
label: 'Total',
backgroundColor: gradient,
fill: true,
// @ts-ignore
data: list.map((row) => row.total),
},
],
},
});
return () => {
chart?.current?.destroy();
};
}, []);
return <canvas className="w-full h-full" ref={ref} />;
};

View File

@ -3,6 +3,11 @@ export interface StarsList {
date: string;
}
export interface TotalList {
total: number;
date: string;
}
export interface ForksList {
totalForks: number;
date: string;

View File

@ -0,0 +1,16 @@
'use client';
export function Activate() {
return (
<>
<div>
<h1 className="text-3xl font-bold text-left mb-4 cursor-pointer">
Activate your account
</h1>
</div>
<div className="text-white">
Thank you for registering!<br />Please check your email to activate your account.
</div>
</>
);
}

View File

@ -0,0 +1,43 @@
'use client';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
export const AfterActivate = () => {
const fetch = useFetch();
const params = useParams();
const [showLoader, setShowLoader] = useState(true);
const run = useRef(false);
useEffect(() => {
if (!run.current) {
run.current = true;
loadCode();
}
}, []);
const loadCode = useCallback(async () => {
if (params.code) {
const { can } = await (
await fetch(`/auth/activate`, {
method: 'POST',
body: JSON.stringify({ code: params.code }),
headers: {
'Content-Type': 'application/json',
},
})
).json();
if (!can) {
setShowLoader(false);
}
}
}, []);
return (
<>{showLoader ? <LoadingComponent /> : (<>This user is already activated,<br /><Link href="/auth/login" className="underline">Click here to go back to login</Link></>)}</>
);
};

View File

@ -8,7 +8,7 @@ import { Input } from '@gitroom/react/form/input';
import { useMemo, useState } from 'react';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { LoginUserDto } from '@gitroom/nestjs-libraries/dtos/auth/login.user.dto';
import { GithubProvider } from '@gitroom/frontend/app/auth/providers/github.provider';
import { GithubProvider } from '@gitroom/frontend/components/auth/providers/github.provider';
import interClass from '@gitroom/react/helpers/inter.font';
import { isGeneral } from '@gitroom/react/helpers/is.general';
@ -44,7 +44,7 @@ export function Login() {
if (login.status === 400) {
form.setError('email', {
message: 'Invalid email or password',
message: await login.text(),
});
setLoading(false);

View File

@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import interClass from '@gitroom/react/helpers/inter.font';
export const GoogleProvider = () => {
const fetch = useFetch();
const gotoLogin = useCallback(async () => {
const link = await (await fetch('/auth/oauth/GOOGLE')).text();
window.location.href = link;
}, []);
return (
<div
onClick={gotoLogin}
className={`cursor-pointer bg-white h-[44px] rounded-[4px] flex justify-center items-center text-[#121A2D] ${interClass} gap-[4px]`}
>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
width="21px"
height="21px"
>
<path
fill="#FFC107"
d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"
/>
<path
fill="#FF3D00"
d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"
/>
<path
fill="#4CAF50"
d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"
/>
<path
fill="#1976D2"
d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"
/>
</svg>
</div>
<div>Sign in with Google</div>
</div>
);
};

View File

@ -8,11 +8,13 @@ import { Input } from '@gitroom/react/form/input';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto';
import { GithubProvider } from '@gitroom/frontend/app/auth/providers/github.provider';
import { useSearchParams } from 'next/navigation';
import { GithubProvider } from '@gitroom/frontend/components/auth/providers/github.provider';
import { useRouter, useSearchParams } from 'next/navigation';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
import interClass from '@gitroom/react/helpers/inter.font';
import { isGeneral } from '@gitroom/react/helpers/is.general';
import clsx from 'clsx';
import { GoogleProvider } from '@gitroom/frontend/components/auth/providers/google.provider';
type Inputs = {
email: string;
@ -71,6 +73,7 @@ export function RegisterAfter({
}) {
const [loading, setLoading] = useState(false);
const getQuery = useSearchParams();
const router = useRouter();
const isAfterProvider = useMemo(() => {
return !!token && !!provider;
@ -103,8 +106,25 @@ export function RegisterAfter({
setLoading(false);
}
console.log(register.headers.get('activate'), register.headers.get('Activate'));
if (register.headers.get('activate')) {
router.push('/auth/activate');
}
};
const rootDomain = useMemo(() => {
const url = new URL(process.env.frontendUrl!);
const hostname = url.hostname;
const parts = hostname.split('.');
if (parts.length > 2) {
return url.protocol + '//' + url.hostname?.replace(/^[^.]+\./, '');
}
return process.env.frontendUrl;
}, []);
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
@ -113,8 +133,8 @@ export function RegisterAfter({
Sign Up
</h1>
</div>
{!isAfterProvider && !isGeneral() && <GithubProvider />}
{!isAfterProvider && !isGeneral() && (
{!isAfterProvider && (!isGeneral() ? <GithubProvider /> : <GoogleProvider />)}
{!isAfterProvider && (
<div className="h-[20px] mb-[24px] mt-[24px] relative">
<div className="absolute w-full h-[1px] bg-[#28344F] top-[50%] -translate-y-[50%]" />
<div
@ -150,6 +170,22 @@ export function RegisterAfter({
placeholder="Company"
/>
</div>
<div className={clsx('text-[12px]', interClass)}>
By registering you agree to our{' '}
<a
href={`${rootDomain}/terms`}
className="underline hover:font-bold"
>
Terms of Service
</a>{' '}
and{' '}
<a
href={`${rootDomain}/privacy`}
className="underline hover:font-bold"
>
Privacy Policy
</a>
</div>
<div className="text-center mt-6">
<div className="w-full flex">
<Button type="submit" className="flex-1" loading={loading}>

View File

@ -1,11 +1,12 @@
import { FC, useCallback, useState } from 'react';
import clsx from 'clsx';
import interClass from '@gitroom/react/helpers/inter.font';
import { isGeneral } from '@gitroom/react/helpers/is.general';
const list = [
{
title: 'What are channels?',
description: `Gitroom allows you to schedule your posts between different channels.
description: `${isGeneral() ? 'Postiz' : 'Gitroom'} allows you to schedule your posts between different channels.
A channel is a publishing platform where you can schedule your posts.
For example, you can schedule your posts on Twitter, Linkedin, DEV and Hashnode`,
},
@ -13,31 +14,9 @@ For example, you can schedule your posts on Twitter, Linkedin, DEV and Hashnode`
title: 'What are team members?',
description: `If you have a team with multiple members, you can invite them to your workspace to collaborate on your posts and add their personal channels`,
},
{
title: 'What do I need to import content from channels?',
description: `Gitroom can help you schedule your launch, but you might write your content on other platforms such as Notion, Google Docs, etc.
You may experience problems copy your content with different formats or uploaded images.
That's why we have a feature to import your content from different platforms.
`,
},
{
title: 'What can I find in the community features?',
description: `Gitroom is all about the community, You can enjoy features such as: exchanging posts with other members,
exchanging links as part of the "Gitroom Friends" and buy social media services from other members`,
},
{
title: 'What is AI auto-complete?',
description: `We automate ChatGPT to help you write your social posts based on the articles you schedule`,
},
{
title: 'Why would I want to become featured by Gitroom?',
description: `Gitroom will feature your posts on our social media platforms and our website to help you get more exposure and followers`,
},
{
title: 'Can I get everything for free?',
description: `Gitroom is 100% open-source, you can deploy it on your own server and use it for free.
However, you might not be able to enjoy the community features Click <a class="underline font-bold" target="_blank" href="https://github.com/gitroomhq/gitroom">here for the open-source</a>
`,
description: `We automate ChatGPT to help you write your social posts and articles`,
},
];

View File

@ -54,7 +54,7 @@ export const LifetimeDeal = () => {
const currentPricing = user?.tier;
const channelsOr = currentPricing.channel;
const list = [];
list.push(`${channelsOr} ${channelsOr === 1 ? 'channel' : 'channels'}`);
list.push(`${user.totalChannels} ${user.totalChannels === 1 ? 'channel' : 'channels'}`);
list.push(
`${
currentPricing.posts_per_month > 10000
@ -66,20 +66,8 @@ export const LifetimeDeal = () => {
list.push(`Unlimited team members`);
}
if (currentPricing.import_from_channels) {
list.push(`Import content from channels (coming soon)`);
}
if (currentPricing.community_features) {
list.push(`Community features (coming soon)`);
}
if (currentPricing.ai) {
list.push(`AI auto-complete (coming soon)`);
}
if (currentPricing.featured_by_gitroom) {
list.push(`Become featured by Gitroom (coming soon)`);
list.push(`AI auto-complete`);
}
return list;
@ -104,22 +92,10 @@ export const LifetimeDeal = () => {
list.push(`Unlimited team members`);
}
if (currentPricing.import_from_channels) {
list.push(`Import content from channels (coming soon)`);
}
if (currentPricing.community_features) {
list.push(`Community features (coming soon)`);
}
if (currentPricing.ai) {
list.push(`AI auto-complete`);
}
if (currentPricing.featured_by_gitroom) {
list.push(`Become featured by Gitroom (coming soon)`);
}
return list;
}, [user, nextPackage]);
@ -136,7 +112,7 @@ export const LifetimeDeal = () => {
<div className="flex gap-[30px]">
<div className="border border-[#172034] bg-[#0B101B] p-[24px] flex flex-col gap-[20px] flex-1 rounded-[4px]">
<div className="text-[30px]">
Current Package: {user?.tier?.current}
Current Package: {user?.totalChannels > 8 ? 'EXTRA' : user?.tier?.current}
</div>
<div className="flex flex-col gap-[10px] justify-center text-[16px] text-[#AAA]">
@ -162,51 +138,55 @@ export const LifetimeDeal = () => {
</div>
</div>
{user?.tier?.current !== 'PRO' && (
<div className="border border-[#172034] bg-[#0B101B] p-[24px] flex flex-col gap-[20px] flex-1 rounded-[4px]">
<div className="text-[30px]">Next Package: {nextPackage}</div>
<div className="border border-[#172034] bg-[#0B101B] p-[24px] flex flex-col gap-[20px] flex-1 rounded-[4px]">
<div className="text-[30px]">
Next Package:{' '}
{user?.tier?.current === 'PRO' ? 'EXTRA' : !user?.tier?.current ? 'FREE' : user?.tier?.current === 'STANDARD' ? 'PRO' : 'STANDARD'}
</div>
<div className="flex flex-col gap-[10px] justify-center text-[16px] text-[#AAA]">
{nextFeature.map((feature) => (
<div key={feature} className="flex gap-[20px]">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M16.2806 9.21937C16.3504 9.28903 16.4057 9.37175 16.4434 9.46279C16.4812 9.55384 16.5006 9.65144 16.5006 9.75C16.5006 9.84856 16.4812 9.94616 16.4434 10.0372C16.4057 10.1283 16.3504 10.211 16.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.289 9.14964 15.3718 9.09432 15.4628 9.05658C15.5538 9.01884 15.6514 8.99941 15.75 8.99941C15.8486 8.99941 15.9462 9.01884 16.0372 9.05658C16.1283 9.09432 16.211 9.14964 16.2806 9.21937ZM21.75 12C21.75 13.9284 21.1782 15.8134 20.1068 17.4168C19.0355 19.0202 17.5127 20.2699 15.7312 21.0078C13.9496 21.7458 11.9892 21.9389 10.0979 21.5627C8.20656 21.1865 6.46928 20.2579 5.10571 18.8943C3.74215 17.5307 2.81355 15.7934 2.43735 13.9021C2.06114 12.0108 2.25422 10.0504 2.99218 8.26884C3.73013 6.48726 4.97982 4.96451 6.58319 3.89317C8.18657 2.82183 10.0716 2.25 12 2.25C14.585 2.25273 17.0634 3.28084 18.8913 5.10872C20.7192 6.93661 21.7473 9.41498 21.75 12ZM20.25 12C20.25 10.3683 19.7661 8.77325 18.8596 7.41655C17.9531 6.05984 16.6646 5.00242 15.1571 4.37799C13.6497 3.75357 11.9909 3.59019 10.3905 3.90852C8.79017 4.22685 7.32016 5.01259 6.16637 6.16637C5.01259 7.32015 4.22685 8.79016 3.90853 10.3905C3.5902 11.9908 3.75358 13.6496 4.378 15.1571C5.00242 16.6646 6.05984 17.9531 7.41655 18.8596C8.77326 19.7661 10.3683 20.25 12 20.25C14.1873 20.2475 16.2843 19.3775 17.8309 17.8309C19.3775 16.2843 20.2475 14.1873 20.25 12Z"
fill="#06ff00"
/>
</svg>
</div>
<div>{feature}</div>
</div>
))}
<div className="mt-[20px] flex items-center gap-[10px]">
<div className="flex-1">
<Input
label="Code"
placeholder="Enter your code"
disableForm={true}
name="code"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
<div className="flex flex-col gap-[10px] justify-center text-[16px] text-[#AAA]">
{(user?.tier?.current === 'PRO'
? [`${(user?.totalChannels || 0) + 5} channels`]
: nextFeature
).map((feature) => (
<div key={feature} className="flex gap-[20px]">
<div>
<Button disabled={code.length < 4} onClick={claim}>
Claim
</Button>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M16.2806 9.21937C16.3504 9.28903 16.4057 9.37175 16.4434 9.46279C16.4812 9.55384 16.5006 9.65144 16.5006 9.75C16.5006 9.84856 16.4812 9.94616 16.4434 10.0372C16.4057 10.1283 16.3504 10.211 16.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.289 9.14964 15.3718 9.09432 15.4628 9.05658C15.5538 9.01884 15.6514 8.99941 15.75 8.99941C15.8486 8.99941 15.9462 9.01884 16.0372 9.05658C16.1283 9.09432 16.211 9.14964 16.2806 9.21937ZM21.75 12C21.75 13.9284 21.1782 15.8134 20.1068 17.4168C19.0355 19.0202 17.5127 20.2699 15.7312 21.0078C13.9496 21.7458 11.9892 21.9389 10.0979 21.5627C8.20656 21.1865 6.46928 20.2579 5.10571 18.8943C3.74215 17.5307 2.81355 15.7934 2.43735 13.9021C2.06114 12.0108 2.25422 10.0504 2.99218 8.26884C3.73013 6.48726 4.97982 4.96451 6.58319 3.89317C8.18657 2.82183 10.0716 2.25 12 2.25C14.585 2.25273 17.0634 3.28084 18.8913 5.10872C20.7192 6.93661 21.7473 9.41498 21.75 12ZM20.25 12C20.25 10.3683 19.7661 8.77325 18.8596 7.41655C17.9531 6.05984 16.6646 5.00242 15.1571 4.37799C13.6497 3.75357 11.9909 3.59019 10.3905 3.90852C8.79017 4.22685 7.32016 5.01259 6.16637 6.16637C5.01259 7.32015 4.22685 8.79016 3.90853 10.3905C3.5902 11.9908 3.75358 13.6496 4.378 15.1571C5.00242 16.6646 6.05984 17.9531 7.41655 18.8596C8.77326 19.7661 10.3683 20.25 12 20.25C14.1873 20.2475 16.2843 19.3775 17.8309 17.8309C19.3775 16.2843 20.2475 14.1873 20.25 12Z"
fill="#06ff00"
/>
</svg>
</div>
<div>{feature}</div>
</div>
))}
<div className="mt-[20px] flex items-center gap-[10px]">
<div className="flex-1">
<Input
label="Code"
placeholder="Enter your code"
disableForm={true}
name="code"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
<div>
<Button disabled={code.length < 4} onClick={claim}>
Claim
</Button>
</div>
</div>
</div>
)}
</div>
</div>
);
};

View File

@ -104,20 +104,8 @@ export const Features: FC<{
list.push(`Unlimited team members`);
}
if (currentPricing.import_from_channels) {
list.push(`Import content from channels (coming soon)`);
}
if (currentPricing.community_features) {
list.push(`Community features (coming soon)`);
}
if (currentPricing.ai) {
list.push(`AI auto-complete (coming soon)`);
}
if (currentPricing.featured_by_gitroom) {
list.push(`Become featured by Gitroom (coming soon)`);
list.push(`AI auto-complete`);
}
return list;

View File

@ -51,6 +51,7 @@ import { AddPostButton } from '@gitroom/frontend/components/launches/add.post.bu
import { useStateCallback } from '@gitroom/react/helpers/use.state.callback';
import { CopilotPopup } from '@copilotkit/react-ui';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { capitalize } from 'lodash';
export const AddEditModal: FC<{
date: dayjs.Dayjs;
@ -252,6 +253,7 @@ export const AddEditModal: FC<{
}
const values = getValues();
const allKeys = Object.keys(values).map((v) => ({
integration: integrations.find((p) => p.id === v),
value: values[v].posts,
@ -259,9 +261,19 @@ export const AddEditModal: FC<{
group: existingData?.group,
trigger: values[v].trigger,
settings: values[v].settings(),
checkValidity: values[v].checkValidity
}));
for (const key of allKeys) {
if (key.checkValidity) {
const check = await key.checkValidity(key?.value.map((p: any) => p.image || []));
if (typeof check === 'string') {
toaster.show(check, 'warning');
return;
}
}
if (key.value.some((p) => !p.content || p.content.length < 6)) {
setShowError(true);
return;
@ -294,7 +306,7 @@ export const AddEditModal: FC<{
);
modal.closeAll();
},
[postFor, dateState, value, integrations, existingData]
[postFor, dateState, value, integrations, existingData, selectedIntegrations]
);
const getPostsMarketplace = useCallback(async () => {

View File

@ -195,13 +195,21 @@ export const AddProviderComponent: FC<{
<div
key={item.identifier}
onClick={getSocialLink(item.identifier)}
className="w-[120px] h-[100px] bg-input text-white justify-center items-center flex flex-col gap-[10px] cursor-pointer"
className={
'w-[120px] h-[100px] bg-input text-white justify-center items-center flex flex-col gap-[10px] cursor-pointer'
}
>
<div>
<img
className="w-[32px] h-[32px] rounded-full"
src={`/icons/platforms/${item.identifier}.png`}
/>
{item.identifier === 'youtube' ? (
<img
src={`/icons/platforms/youtube.svg`}
/>
) : (
<img
className="w-[32px] h-[32px] rounded-full"
src={`/icons/platforms/${item.identifier}.png`}
/>
)}
</div>
<div>{item.name}</div>
</div>

View File

@ -274,7 +274,6 @@ const CalendarColumnRender: FC<{ day: number; hour: string }> = (props) => {
}));
const getIntegration = useCallback(async (post: Post & { integration: Integration }) => {
console.log('hello');
return (
await fetch(
`/integrations/${post.integration.id}?order=${post.submittedForOrderId}`,

View File

@ -3,6 +3,8 @@ import type { MDEditorProps } from '@uiw/react-md-editor/src/Types';
import { RefMDEditor } from '@uiw/react-md-editor/src/Editor';
import MDEditor from '@uiw/react-md-editor';
import { useCopilotAction, useCopilotReadable } from '@copilotkit/react-core';
import { timer } from '@gitroom/helpers/utils/timer';
import dayjs from 'dayjs';
export const Editor = forwardRef<
RefMDEditor,
@ -26,7 +28,8 @@ export const Editor = forwardRef<
type: 'string',
},
],
handler: ({ content }) => {
handler: async ({ content }) => {
console.log('editPost_' + props.order, content, dayjs().unix());
props?.onChange?.(content);
},
});

View File

@ -22,11 +22,6 @@ export const DatePicker: FC<{
const changeDate = useCallback(
(type: 'date' | 'time') => (day: Date) => {
console.log(
type === 'time'
? date.format('YYYY-MM-DD') + ' ' + dayjs(day).format('HH:mm:ss')
: dayjs(day).format('YYYY-MM-DD') + ' ' + date.format('HH:mm:ss')
);
onChange(
dayjs(
type === 'time'

View File

@ -181,7 +181,6 @@ export const linkedinCompany = (identifier: string, id: string): ICommand[] => {
const state1 = api.setSelectionRange(newSelectionRange);
const media = await showPostSelector(id);
console.log(media);
executeCommand({
api,
selectedText: state1.selectedText,

View File

@ -6,6 +6,8 @@ import clsx from 'clsx';
import Image from 'next/image';
import { useCopilotAction, useCopilotReadable } from '@copilotkit/react-core';
import { useStateCallback } from '@gitroom/react/helpers/use.state.callback';
import { timer } from '@gitroom/helpers/utils/timer';
import dayjs from 'dayjs';
export const PickPlatforms: FC<{
integrations: Integrations[];
@ -137,6 +139,7 @@ export const PickPlatforms: FC<{
);
}
await timer(500);
await Promise.all(promises);
},
[selectedAccounts]
@ -144,15 +147,17 @@ export const PickPlatforms: FC<{
const handler = useCallback(
async ({ integrationId }: { integrationId: string }) => {
console.log('setSelectedIntegration', integrations, integrationId, dayjs().unix());
const findIntegration = integrations.find((p) => p.id === integrationId)!;
console.log(findIntegration);
await addPlatform(findIntegration)();
},
[selectedAccounts, integrations, selectedAccounts]
);
useCopilotReadable({
description: isMain ? 'All available platforms channels' : 'Possible platforms channels to edit',
description: isMain
? 'All available platforms channels'
: 'Possible platforms channels to edit',
value: JSON.stringify(integrations),
});
@ -237,20 +242,28 @@ export const PickPlatforms: FC<{
: ''
)}
>
<img
<Image
src={integration.picture}
className="rounded-full"
alt={integration.identifier}
width={32}
height={32}
/>
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
{integration.identifier === 'youtube' ? (
<img
src="/icons/platforms/youtube.svg"
className="absolute z-10 -bottom-[5px] -right-[5px]"
width={20}
/>
) : (
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
)}
</div>
</div>
) : (

View File

@ -7,16 +7,20 @@ export const useCustomProviderFunction = () => {
const fetch = useFetch();
const get = useCallback(
async (funcName: string, customData?: any) => {
return (
await fetch('/integrations/function', {
method: 'POST',
body: JSON.stringify({
name: funcName,
id: integration?.id!,
data: customData,
}),
})
).json();
const load = await fetch('/integrations/function', {
method: 'POST',
body: JSON.stringify({
name: funcName,
id: integration?.id!,
data: customData,
}),
});
if (load.status !== 200 && load.status !== 201) {
throw new Error('Failed to fetch');
}
return load.json();
},
[integration]
);

View File

@ -1,11 +1,24 @@
import {useEffect, useMemo} from 'react';
import {useForm, useFormContext} from 'react-hook-form';
import {classValidatorResolver} from "@hookform/resolvers/class-validator";
import { useEffect, useMemo } from 'react';
import { useForm, useFormContext } from 'react-hook-form';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
const finalInformation = {} as {
[key: string]: { posts: Array<{id?: string, content: string, media?: Array<string>}>; settings: () => object; trigger: () => Promise<boolean>; isValid: boolean };
[key: string]: {
posts: Array<{ id?: string; content: string; media?: Array<string> }>;
settings: () => object;
trigger: () => Promise<boolean>;
isValid: boolean;
checkValidity?: (value: Array<Array<{path: string}>>) => Promise<string|true>;
};
};
export const useValues = (initialValues: object, integration: string, identifier: string, value: Array<{id?: string, content: string, media?: Array<string>}>, dto: any) => {
export const useValues = (
initialValues: object,
integration: string,
identifier: string,
value: Array<{ id?: string; content: string; media?: Array<string> }>,
dto: any,
checkValidity?: (value: Array<Array<{path: string}>>) => Promise<string|true>,
) => {
const resolver = useMemo(() => {
return classValidatorResolver(dto);
}, [integration]);
@ -18,15 +31,20 @@ export const useValues = (initialValues: object, integration: string, identifier
});
const getValues = useMemo(() => {
return () => ({...form.getValues(), __type: identifier});
return () => ({ ...form.getValues(), __type: identifier });
}, [form, integration]);
finalInformation[integration]= finalInformation[integration] || {};
finalInformation[integration] = finalInformation[integration] || {};
finalInformation[integration].posts = value;
finalInformation[integration].isValid = form.formState.isValid;
finalInformation[integration].settings = getValues;
finalInformation[integration].trigger = form.trigger;
if (checkValidity) {
finalInformation[integration].checkValidity =
checkValidity;
}
useEffect(() => {
return () => {
delete finalInformation[integration];

View File

@ -15,6 +15,8 @@ import { useUser } from '../layout/user.context';
import { Menu } from '@gitroom/frontend/components/launches/menu/menu';
import { GeneratorComponent } from '@gitroom/frontend/components/launches/generator/generator';
import { useRouter } from 'next/navigation';
import { Integration } from '@prisma/client';
import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback';
export const LaunchesComponent = () => {
const fetch = useFetch();
@ -69,6 +71,22 @@ export const LaunchesComponent = () => {
[]
);
const refreshChannel = useCallback(
(integration: Integration & { identifier: string }) => async () => {
const { url } = await (
await fetch(
`/integrations/social/${integration.identifier}?refresh=${integration.internalId}`,
{
method: 'GET',
}
)
).json();
window.location.href = url;
},
[]
);
useEffect(() => {
if (typeof window !== 'undefined' && window.opener) {
window.close();
@ -93,6 +111,11 @@ export const LaunchesComponent = () => {
)}
{sortedIntegrations.map((integration) => (
<div
{...(integration.refreshNeeded && {
'data-tooltip-id': 'tooltip',
'data-tooltip-content':
'Channel disconnected, click to reconnect.',
})}
key={integration.id}
className="flex gap-[8px] items-center"
>
@ -102,10 +125,15 @@ export const LaunchesComponent = () => {
integration.disabled && 'opacity-50'
)}
>
{integration.inBetweenSteps && (
{(integration.inBetweenSteps ||
integration.refreshNeeded) && (
<div
className="absolute left-0 top-0 w-[39px] h-[46px] cursor-pointer"
onClick={continueIntegration(integration)}
onClick={
integration.refreshNeeded
? refreshChannel(integration)
: continueIntegration(integration)
}
>
<div className="bg-red-500 w-[15px] h-[15px] rounded-full -left-[5px] -top-[5px] absolute z-[200] text-[10px] flex justify-center items-center">
!
@ -113,20 +141,29 @@ export const LaunchesComponent = () => {
<div className="bg-black/60 w-[39px] h-[46px] left-0 top-0 absolute rounded-full z-[199]" />
</div>
)}
<img
<ImageWithFallback
fallbackSrc={`/icons/platforms/${integration.identifier}.png`}
src={integration.picture}
className="rounded-full"
alt={integration.identifier}
width={32}
height={32}
/>
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
{integration.identifier === 'youtube' ? (
<img
src="/icons/platforms/youtube.svg"
className="absolute z-10 -bottom-[5px] -right-[5px]"
width={20}
/>
) : (
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
)}
</div>
<div
{...(integration.disabled &&

View File

@ -6,15 +6,23 @@ import { Button } from '@gitroom/react/form/button';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
export const FacebookContinue: FC<{closeModal: () => void, existingId: string[]}> = (props) => {
export const FacebookContinue: FC<{
closeModal: () => void;
existingId: string[];
}> = (props) => {
const { closeModal, existingId } = props;
const call = useCustomProviderFunction();
const { integration } = useIntegration();
const [page, setSelectedPage] = useState<null | string>(null);
const fetch = useFetch();
const loadPages = useCallback(() => {
return call.get('pages');
const loadPages = useCallback(async () => {
try {
const pages = await call.get('pages');
return pages;
} catch (e) {
closeModal();
}
}, []);
const setPage = useCallback(
@ -44,7 +52,9 @@ export const FacebookContinue: FC<{closeModal: () => void, existingId: string[]}
}, [integration, page]);
const filteredData = useMemo(() => {
return data?.filter((p: { id: string }) => !existingId.includes(p.id)) || [];
return (
data?.filter((p: { id: string }) => !existingId.includes(p.id)) || []
);
}, [data]);
return (

View File

@ -13,15 +13,23 @@ export const InstagramContinue: FC<{
const { closeModal, existingId } = props;
const call = useCustomProviderFunction();
const { integration } = useIntegration();
const [page, setSelectedPage] = useState<null | {id: string, pageId: string}>(null);
const [page, setSelectedPage] = useState<null | {
id: string;
pageId: string;
}>(null);
const fetch = useFetch();
const loadPages = useCallback(() => {
return call.get('pages');
const loadPages = useCallback(async () => {
try {
const pages = await call.get('pages');
return pages;
} catch (e) {
closeModal();
}
}, []);
const setPage = useCallback(
(param: {id: string, pageId: string}) => () => {
(param: { id: string; pageId: string }) => () => {
setSelectedPage(param);
},
[]

View File

@ -0,0 +1,102 @@
import { FC, useCallback, useMemo, useState } from 'react';
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
import useSWR from 'swr';
import clsx from 'clsx';
import { Button } from '@gitroom/react/form/button';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
export const LinkedinContinue: FC<{
closeModal: () => void;
existingId: string[];
}> = (props) => {
const { closeModal, existingId } = props;
const call = useCustomProviderFunction();
const { integration } = useIntegration();
const [page, setSelectedPage] = useState<null | {
id: string;
pageId: string;
}>(null);
const fetch = useFetch();
const loadPages = useCallback(async () => {
try {
const pages = await call.get('companies');
return pages;
} catch (e) {
closeModal();
}
}, []);
const setPage = useCallback(
(param: { id: string; pageId: string }) => () => {
setSelectedPage(param);
},
[]
);
const { data } = useSWR('load-pages', loadPages, {
refreshWhenHidden: false,
refreshWhenOffline: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnMount: true,
revalidateOnReconnect: false,
refreshInterval: 0,
});
const saveLinkedin = useCallback(async () => {
await fetch(`/integrations/linkedin-page/${integration?.id}`, {
method: 'POST',
body: JSON.stringify(page),
});
closeModal();
}, [integration, page]);
const filteredData = useMemo(() => {
return (
data?.filter((p: { id: string }) => !existingId.includes(p.id)) || []
);
}, [data]);
return (
<div className="flex flex-col gap-[20px]">
<div>Select Linkedin Account:</div>
<div className="grid grid-cols-3 justify-items-center select-none cursor-pointer">
{filteredData?.map(
(p: {
id: string;
pageId: string;
username: string;
name: string;
picture: string;
}) => (
<div
key={p.id}
className={clsx(
'flex flex-col w-full text-center gap-[10px] border border-input p-[10px] hover:bg-seventh',
page?.id === p.id && 'bg-seventh'
)}
onClick={setPage(p)}
>
<div>
<img
className="w-full"
src={p.picture}
alt="profile"
/>
</div>
<div>{p.name}</div>
</div>
)
)}
</div>
<div>
<Button disabled={!page} onClick={saveLinkedin}>
Save
</Button>
</div>
</div>
);
};

View File

@ -1,7 +1,9 @@
import { InstagramContinue } from '@gitroom/frontend/components/launches/providers/continue-provider/instagram/instagram.continue';
import { FacebookContinue } from '@gitroom/frontend/components/launches/providers/continue-provider/facebook/facebook.continue';
import { LinkedinContinue } from '@gitroom/frontend/components/launches/providers/continue-provider/linkedin/linkedin.continue';
export const continueProviderList = {
instagram: InstagramContinue,
facebook: FacebookContinue,
}
'linkedin-page': LinkedinContinue,
};

View File

@ -0,0 +1,160 @@
import { FC } from 'react';
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory';
import {
afterLinkedinCompanyPreventRemove,
linkedinCompanyPreventRemove,
} from '@gitroom/helpers/utils/linkedin.company.prevent.remove';
import { VideoOrImage } from '@gitroom/react/helpers/video.or.image';
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
import { Input } from '@gitroom/react/form/input';
import { DribbbleTeams } from '@gitroom/frontend/components/launches/providers/dribbble/dribbble.teams';
import { DribbbleDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dribbble.dto';
const DribbbleSettings: FC = () => {
const { register, control } = useSettings();
return (
<div className="flex flex-col">
<Input label={'Title'} {...register('title')} />
<DribbbleTeams {...register('team')} />
</div>
);
};
const DribbblePreview: FC = (props) => {
const { value: topValue, integration } = useIntegration();
const mediaDir = useMediaDirectory();
const newValues = useFormatting(topValue, {
removeMarkdown: true,
saveBreaklines: true,
beforeSpecialFunc: (text: string) => {
return linkedinCompanyPreventRemove(text);
},
specialFunc: (text: string) => {
return afterLinkedinCompanyPreventRemove(text.slice(0, 280));
},
});
const [firstPost, ...morePosts] = newValues;
if (!firstPost) {
return null;
}
return (
<div className="rounded-[8px] flex flex-col gap-[8px] border border-black/90 w-[555px] pt-[12px] pl-[16px] pb-[12px] pr-[40px] bg-white text-black font-['helvetica']">
<div className="flex gap-[8px]">
<div className="w-[48px] h-[48px]">
<img
src={integration?.picture}
alt="x"
className="rounded-full w-full h-full relative z-[2]"
/>
</div>
<div className="flex flex-col leading-[16px]">
<div className="text-[14px] font-[600]">{integration?.name}</div>
<div className="text-[12px] font-[400] text-black/60">
CEO @ Gitroom
</div>
<div className="text-[12px] font-[400] text-black/60">1m</div>
</div>
</div>
<div>
<pre
className="font-['helvetica'] text-[14px] font-[400] text-wrap"
dangerouslySetInnerHTML={{ __html: firstPost?.text }}
/>
{!!firstPost?.images?.length && (
<div className="-ml-[16px] -mr-[40px] flex-1 h-[555px] flex overflow-hidden mt-[12px] gap-[2px]">
{firstPost.images.map((image, index) => (
<a
key={`image_${index}`}
href={mediaDir.set(image.path)}
className="flex-1"
target="_blank"
>
<VideoOrImage autoplay={true} src={mediaDir.set(image.path)} />
</a>
))}
</div>
)}
</div>
{morePosts.map((p, index) => (
<div className="flex gap-[8px]" key={index}>
<div className="w-[40px] h-[40px]">
<img
src={integration?.picture}
alt="x"
className="rounded-full w-full h-full relative z-[2]"
/>
</div>
<div className="flex-1 flex flex-col leading-[16px] bg-[#F2F2F2] w-full pt-[8px] pr-[64px] pl-[12px] pb-[8px] rounded-[8px]">
<div className="text-[14px] font-[600]">{integration?.name}</div>
<div className="text-[12px] font-[400] text-black/60">
CEO @ Gitroom
</div>
<div className="text-[14px] mt-[8px] font-[400] text-black/90">
{p.text}
</div>
{!!p?.images?.length && (
<div className="w-full h-[120px] flex overflow-hidden mt-[12px] gap-[3px]">
{p.images.map((image, index) => (
<a
key={`image_${index}`}
href={mediaDir.set(image.path)}
target="_blank"
>
<div className="w-[120px] h-full">
<VideoOrImage
autoplay={true}
src={mediaDir.set(image.path)}
/>
</div>
</a>
))}
</div>
)}
</div>
</div>
))}
</div>
);
};
export default withProvider(
DribbbleSettings,
DribbblePreview,
DribbbleDto,
async ([firstItem, ...otherItems]) => {
const isMp4 = firstItem?.find((item) => item.path.indexOf('mp4') > -1);
if (firstItem.length !== 1) {
return 'Dribbble requires one item';
}
if (isMp4) {
return 'Dribbble does not support mp4 files';
}
const details = await new Promise<{width: number, height: number}>((resolve, reject) => {
const url = new Image();
url.onload = function() {
// @ts-ignore
resolve({width: this.width, height: this.height});
}
url.src = firstItem[0].path;
});
if (
(details?.width === 400 && details?.height === 300) ||
(details?.width === 800 && details?.height === 600)
) {
return true;
}
return 'Invalid image size. Dribbble requires 400x300 or 800x600 px images.';
}
);

View File

@ -0,0 +1,47 @@
import { FC, useEffect, useState } from 'react';
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
import { Select } from '@gitroom/react/form/select';
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
export const DribbbleTeams: FC<{
name: string;
onChange: (event: { target: { value: string; name: string } }) => void;
}> = (props) => {
const { onChange, name } = props;
const customFunc = useCustomProviderFunction();
const [orgs, setOrgs] = useState<undefined|any[]>();
const { getValues } = useSettings();
const [currentMedia, setCurrentMedia] = useState<string|undefined>();
const onChangeInner = (event: { target: { value: string, name: string } }) => {
setCurrentMedia(event.target.value);
onChange(event);
};
useEffect(() => {
customFunc.get('teams').then((data) => setOrgs(data));
const settings = getValues()[props.name];
if (settings) {
setCurrentMedia(settings);
}
}, []);
if (!orgs) {
return null;
}
if (!orgs.length) {
return <></>;
}
return (
<Select name={name} label="Select a team" onChange={onChangeInner} value={currentMedia}>
<option value="">--Select--</option>
{orgs.map((org: any) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</Select>
);
};

View File

@ -69,7 +69,8 @@ export const EditorWrapper: FC<{ children: ReactNode }> = ({ children }) => {
export const withProvider = (
SettingsComponent: FC | null,
PreviewComponent: FC,
dto?: any
dto?: any,
checkValidity?: (value: Array<Array<{path: string}>>) => Promise<string|true>
) => {
return (props: {
identifier: string;
@ -124,7 +125,8 @@ export const withProvider = (
props.id,
props.identifier,
editInPlace ? InPlaceValue : props.value,
dto
dto,
checkValidity
);
// change editor value

View File

@ -109,4 +109,10 @@ const InstagramPreview: FC = (props) => {
);
};
export default withProvider(null, InstagramPreview);
export default withProvider(null, InstagramPreview, undefined, async ([firstPost, ...otherPosts]) => {
if (!firstPost.length) {
return 'Instagram should have at least one media';
}
return true;
});

View File

@ -0,0 +1,47 @@
import { FC, useEffect, useState } from 'react';
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
import { Select } from '@gitroom/react/form/select';
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
export const PinterestBoard: FC<{
name: string;
onChange: (event: { target: { value: string; name: string } }) => void;
}> = (props) => {
const { onChange, name } = props;
const customFunc = useCustomProviderFunction();
const [orgs, setOrgs] = useState<undefined|any[]>();
const { getValues } = useSettings();
const [currentMedia, setCurrentMedia] = useState<string|undefined>();
const onChangeInner = (event: { target: { value: string, name: string } }) => {
setCurrentMedia(event.target.value);
onChange(event);
};
useEffect(() => {
customFunc.get('boards').then((data) => setOrgs(data));
const settings = getValues()[props.name];
if (settings) {
setCurrentMedia(settings);
}
}, []);
if (!orgs) {
return null;
}
if (!orgs.length) {
return 'No boards found, you have to create a board first';
}
return (
<Select name={name} label="Select board" onChange={onChangeInner} value={currentMedia}>
<option value="">--Select--</option>
{orgs.map((org: any) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</Select>
);
};

View File

@ -0,0 +1,176 @@
import { FC } from 'react';
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory';
import {
afterLinkedinCompanyPreventRemove,
linkedinCompanyPreventRemove,
} from '@gitroom/helpers/utils/linkedin.company.prevent.remove';
import { VideoOrImage } from '@gitroom/react/helpers/video.or.image';
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
import { PinterestBoard } from '@gitroom/frontend/components/launches/providers/pinterest/pinterest.board';
import { PinterestSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/pinterest.dto';
import { Input } from '@gitroom/react/form/input';
import { ColorPicker } from '@gitroom/react/form/color.picker';
const PinterestSettings: FC = () => {
const { register, control } = useSettings();
return (
<div className="flex flex-col">
<Input label={'Title'} {...register('title')} />
<Input label={'Description'} {...register('description')} />
<PinterestBoard {...register('board')} />
<ColorPicker label="Select Pin Color" name="dominant_color" enabled={false} canBeCancelled={true} />
</div>
);
};
const PinterestPreview: FC = (props) => {
const { value: topValue, integration } = useIntegration();
const mediaDir = useMediaDirectory();
const newValues = useFormatting(topValue, {
removeMarkdown: true,
saveBreaklines: true,
beforeSpecialFunc: (text: string) => {
return linkedinCompanyPreventRemove(text);
},
specialFunc: (text: string) => {
return afterLinkedinCompanyPreventRemove(text.slice(0, 280));
},
});
const [firstPost, ...morePosts] = newValues;
if (!firstPost) {
return null;
}
return (
<div className="rounded-[8px] flex flex-col gap-[8px] border border-black/90 w-[555px] pt-[12px] pl-[16px] pb-[12px] pr-[40px] bg-white text-black font-['helvetica']">
<div className="flex gap-[8px]">
<div className="w-[48px] h-[48px]">
<img
src={integration?.picture}
alt="x"
className="rounded-full w-full h-full relative z-[2]"
/>
</div>
<div className="flex flex-col leading-[16px]">
<div className="text-[14px] font-[600]">{integration?.name}</div>
<div className="text-[12px] font-[400] text-black/60">
CEO @ Gitroom
</div>
<div className="text-[12px] font-[400] text-black/60">1m</div>
</div>
</div>
<div>
<pre
className="font-['helvetica'] text-[14px] font-[400] text-wrap"
dangerouslySetInnerHTML={{ __html: firstPost?.text }}
/>
{!!firstPost?.images?.length && (
<div className="-ml-[16px] -mr-[40px] flex-1 h-[555px] flex overflow-hidden mt-[12px] gap-[2px]">
{firstPost.images.map((image, index) => (
<a
key={`image_${index}`}
href={mediaDir.set(image.path)}
className="flex-1"
target="_blank"
>
<VideoOrImage autoplay={true} src={mediaDir.set(image.path)} />
</a>
))}
</div>
)}
</div>
{morePosts.map((p, index) => (
<div className="flex gap-[8px]" key={index}>
<div className="w-[40px] h-[40px]">
<img
src={integration?.picture}
alt="x"
className="rounded-full w-full h-full relative z-[2]"
/>
</div>
<div className="flex-1 flex flex-col leading-[16px] bg-[#F2F2F2] w-full pt-[8px] pr-[64px] pl-[12px] pb-[8px] rounded-[8px]">
<div className="text-[14px] font-[600]">{integration?.name}</div>
<div className="text-[12px] font-[400] text-black/60">
CEO @ Gitroom
</div>
<div className="text-[14px] mt-[8px] font-[400] text-black/90">
{p.text}
</div>
{!!p?.images?.length && (
<div className="w-full h-[120px] flex overflow-hidden mt-[12px] gap-[3px]">
{p.images.map((image, index) => (
<a
key={`image_${index}`}
href={mediaDir.set(image.path)}
target="_blank"
>
<div className="w-[120px] h-full">
<VideoOrImage
autoplay={true}
src={mediaDir.set(image.path)}
/>
</div>
</a>
))}
</div>
)}
</div>
</div>
))}
</div>
);
};
export default withProvider(
PinterestSettings,
PinterestPreview,
PinterestSettingsDto,
async ([firstItem, ...otherItems]) => {
const isMp4 = firstItem?.find((item) => item.path.indexOf('mp4') > -1);
const isPicture = firstItem?.find((item) => item.path.indexOf('mp4') === -1);
if (firstItem.length === 0) {
return 'Pinterest requires at least one media';
}
if (isMp4 && firstItem.length !== 2 && !isPicture) {
return 'If posting a video to Pinterest you have to also include a cover image as second media';
}
if (isMp4 && firstItem.length > 2) {
return 'If posting a video to Pinterest you can only have two media items';
}
if (otherItems.length) {
return 'Pinterest can only have one post';
}
if (firstItem.length > 1 && firstItem.every(p => p.path.indexOf('mp4') == -1)) {
const loadAll: Array<{width: number, height: number}> = await Promise.all(firstItem.map(p => {
return new Promise((resolve, reject) => {
const url = new Image();
url.onload = function() {
// @ts-ignore
resolve({width: this.width, height: this.height});
}
url.src = p.path;
});
})) as any;
const checkAllTheSameWidthHeight = loadAll.every((p, i, arr) => {
return p.width === arr[0].width && p.height === arr[0].height;
});
if (!checkAllTheSameWidthHeight) {
return 'Pinterest requires all images to have the same width and height';
}
}
return true;
}
);

View File

@ -8,20 +8,28 @@ import MediumProvider from "@gitroom/frontend/components/launches/providers/medi
import HashnodeProvider from "@gitroom/frontend/components/launches/providers/hashnode/hashnode.provider";
import FacebookProvider from '@gitroom/frontend/components/launches/providers/facebook/facebook.provider';
import InstagramProvider from '@gitroom/frontend/components/launches/providers/instagram/instagram.provider';
import YoutubeProvider from '@gitroom/frontend/components/launches/providers/youtube/youtube.provider';
import TiktokProvider from '@gitroom/frontend/components/launches/providers/tiktok/tiktok.provider';
import PinterestProvider from '@gitroom/frontend/components/launches/providers/pinterest/pinterest.provider';
import DribbbleProvider from '@gitroom/frontend/components/launches/providers/dribbble/dribbble.provider';
export const Providers = [
{identifier: 'devto', component: DevtoProvider},
{identifier: 'x', component: XProvider},
{identifier: 'linkedin', component: LinkedinProvider},
{identifier: 'linkedin-page', component: LinkedinProvider},
{identifier: 'reddit', component: RedditProvider},
{identifier: 'medium', component: MediumProvider},
{identifier: 'hashnode', component: HashnodeProvider},
{identifier: 'facebook', component: FacebookProvider},
{identifier: 'instagram', component: InstagramProvider},
{identifier: 'youtube', component: YoutubeProvider},
{identifier: 'tiktok', component: TiktokProvider},
{identifier: 'pinterest', component: PinterestProvider},
{identifier: 'dribbble', component: DribbbleProvider},
];
export const ShowAllProviders: FC<{integrations: Integrations[], value: Array<{content: string, id?: string}>, selectedProvider?: Integrations}> = (props) => {
const {integrations, value, selectedProvider} = props;
return (

View File

@ -0,0 +1,113 @@
import { FC } from 'react';
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory';
import {
afterLinkedinCompanyPreventRemove,
linkedinCompanyPreventRemove,
} from '@gitroom/helpers/utils/linkedin.company.prevent.remove';
import { VideoOrImage } from '@gitroom/react/helpers/video.or.image';
const TikTokPreview: FC = (props) => {
const { value: topValue, integration } = useIntegration();
const mediaDir = useMediaDirectory();
const newValues = useFormatting(topValue, {
removeMarkdown: true,
saveBreaklines: true,
beforeSpecialFunc: (text: string) => {
return linkedinCompanyPreventRemove(text);
},
specialFunc: (text: string) => {
return afterLinkedinCompanyPreventRemove(text.slice(0, 280));
},
});
const [firstPost, ...morePosts] = newValues;
if (!firstPost) {
return null;
}
return (
<div className="rounded-[8px] flex flex-col gap-[8px] border border-black/90 w-[555px] pt-[12px] pl-[16px] pb-[12px] pr-[40px] bg-white text-black font-['helvetica']">
<div className="flex gap-[8px]">
<div className="w-[48px] h-[48px]">
<img
src={integration?.picture}
alt="x"
className="rounded-full w-full h-full relative z-[2]"
/>
</div>
<div className="flex flex-col leading-[16px]">
<div className="text-[14px] font-[600]">{integration?.name}</div>
<div className="text-[12px] font-[400] text-black/60">
CEO @ Gitroom
</div>
<div className="text-[12px] font-[400] text-black/60">1m</div>
</div>
</div>
<div>
<pre
className="font-['helvetica'] text-[14px] font-[400] text-wrap"
dangerouslySetInnerHTML={{ __html: firstPost?.text }}
/>
{!!firstPost?.images?.length && (
<div className="-ml-[16px] -mr-[40px] flex-1 h-[555px] flex overflow-hidden mt-[12px] gap-[2px]">
{firstPost.images.map((image, index) => (
<a
key={`image_${index}`}
href={mediaDir.set(image.path)}
className="flex-1"
target="_blank"
>
<VideoOrImage autoplay={true} src={mediaDir.set(image.path)} />
</a>
))}
</div>
)}
</div>
{morePosts.map((p, index) => (
<div className="flex gap-[8px]" key={index}>
<div className="w-[40px] h-[40px]">
<img
src={integration?.picture}
alt="x"
className="rounded-full w-full h-full relative z-[2]"
/>
</div>
<div className="flex-1 flex flex-col leading-[16px] bg-[#F2F2F2] w-full pt-[8px] pr-[64px] pl-[12px] pb-[8px] rounded-[8px]">
<div className="text-[14px] font-[600]">{integration?.name}</div>
<div className="text-[12px] font-[400] text-black/60">
CEO @ Gitroom
</div>
<div className="text-[14px] mt-[8px] font-[400] text-black/90">
{p.text}
</div>
{!!p?.images?.length && (
<div className="w-full h-[120px] flex overflow-hidden mt-[12px] gap-[2px]">
{p.images.map((image, index) => (
<a
key={`image_${index}`}
href={mediaDir.set(image.path)}
target="_blank"
>
<div className="w-[120px] h-full">
<VideoOrImage
autoplay={true}
src={mediaDir.set(image.path)}
/>
</div>
</a>
))}
</div>
)}
</div>
</div>
))}
</div>
);
};
export default withProvider(null, TikTokPreview);

View File

@ -0,0 +1,170 @@
import { FC } from 'react';
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory';
import {
afterLinkedinCompanyPreventRemove,
linkedinCompanyPreventRemove,
} from '@gitroom/helpers/utils/linkedin.company.prevent.remove';
import { VideoOrImage } from '@gitroom/react/helpers/video.or.image';
import { YoutubeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/youtube.settings.dto';
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
import { Input } from '@gitroom/react/form/input';
import { MediumTags } from '@gitroom/frontend/components/launches/providers/medium/medium.tags';
import { MediaComponent } from '@gitroom/frontend/components/media/media.component';
import { Select } from '@gitroom/react/form/select';
const type = [
{ label: 'Public', value: 'public' },
{ label: 'Private', value: 'private' },
{ label: 'Unlisted', value: 'unlisted' },
];
const YoutubeSettings: FC = () => {
const { register, control } = useSettings();
return (
<div className="flex flex-col">
<Input label="Title" {...register('title')} />
<Select label="Type" {...register('type', {value: 'public'})}>
{type.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
<MediumTags label="Tags" {...register('tags')} />
<div className="mt-[20px]">
<MediaComponent
type="image"
label="Thumbnail"
description="Thumbnail picture (optional)"
{...register('thumbnail')}
/>
</div>
</div>
);
};
const YoutubePreview: FC = (props) => {
const { value: topValue, integration } = useIntegration();
const mediaDir = useMediaDirectory();
const newValues = useFormatting(topValue, {
removeMarkdown: true,
saveBreaklines: true,
beforeSpecialFunc: (text: string) => {
return linkedinCompanyPreventRemove(text);
},
specialFunc: (text: string) => {
return afterLinkedinCompanyPreventRemove(text.slice(0, 280));
},
});
const [firstPost, ...morePosts] = newValues;
if (!firstPost) {
return null;
}
return (
<div className="rounded-[8px] flex flex-col gap-[8px] border border-black/90 w-[555px] pt-[12px] pl-[16px] pb-[12px] pr-[40px] bg-white text-black font-['helvetica']">
<div className="flex gap-[8px]">
<div className="w-[48px] h-[48px]">
<img
src={integration?.picture}
alt="x"
className="rounded-full w-full h-full relative z-[2]"
/>
</div>
<div className="flex flex-col leading-[16px]">
<div className="text-[14px] font-[600]">{integration?.name}</div>
<div className="text-[12px] font-[400] text-black/60">
CEO @ Gitroom
</div>
<div className="text-[12px] font-[400] text-black/60">1m</div>
</div>
</div>
<div>
<pre
className="font-['helvetica'] text-[14px] font-[400] text-wrap"
dangerouslySetInnerHTML={{ __html: firstPost?.text }}
/>
{!!firstPost?.images?.length && (
<div className="-ml-[16px] -mr-[40px] flex-1 h-[555px] flex overflow-hidden mt-[12px] gap-[2px]">
{firstPost.images.map((image, index) => (
<a
key={`image_${index}`}
href={mediaDir.set(image.path)}
className="flex-1"
target="_blank"
>
<VideoOrImage autoplay={true} src={mediaDir.set(image.path)} />
</a>
))}
</div>
)}
</div>
{morePosts.map((p, index) => (
<div className="flex gap-[8px]" key={index}>
<div className="w-[40px] h-[40px]">
<img
src={integration?.picture}
alt="x"
className="rounded-full w-full h-full relative z-[2]"
/>
</div>
<div className="flex-1 flex flex-col leading-[16px] bg-[#F2F2F2] w-full pt-[8px] pr-[64px] pl-[12px] pb-[8px] rounded-[8px]">
<div className="text-[14px] font-[600]">{integration?.name}</div>
<div className="text-[12px] font-[400] text-black/60">
CEO @ Gitroom
</div>
<div className="text-[14px] mt-[8px] font-[400] text-black/90">
{p.text}
</div>
{!!p?.images?.length && (
<div className="w-full h-[120px] flex overflow-hidden mt-[12px] gap-[3px]">
{p.images.map((image, index) => (
<a
key={`image_${index}`}
href={mediaDir.set(image.path)}
target="_blank"
>
<div className="w-[120px] h-full">
<VideoOrImage
autoplay={true}
src={mediaDir.set(image.path)}
/>
</div>
</a>
))}
</div>
)}
</div>
</div>
))}
</div>
);
};
export default withProvider(
YoutubeSettings,
YoutubePreview,
YoutubeSettingsDto,
async (items) => {
const [firstItems] = items;
if (items.length !== 1) {
return 'Youtube items should be one';
}
if (items[0].length !== 1) {
return 'You need one item';
}
if (firstItems[0].path.indexOf('mp4') === -1) {
return 'Item must be a video';
}
return true;
}
);

View File

@ -1,4 +1,4 @@
import React, { FC, useCallback, useMemo } from 'react';
import React, { FC, useCallback, useEffect, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { continueProviderList } from '@gitroom/frontend/components/launches/providers/continue-provider/list';
@ -38,7 +38,7 @@ export const ContinueProvider: FC = () => {
if (!added) {
return Null;
}
return continueProviderList[added as keyof typeof continueProviderList];
return continueProviderList[added as keyof typeof continueProviderList] || Null;
}, [added]);
if (!added || !continueId || !integrations) {

View File

@ -0,0 +1,117 @@
import { Input } from '@gitroom/react/form/input';
import { useCallback, useMemo, useState } from 'react';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
export const Impersonate = () => {
const fetch = useFetch();
const [name, setName] = useState('');
const user = useUser();
const load = useCallback(async () => {
if (!name) {
return [];
}
const value = await (await fetch(`/user/impersonate?name=${name}`)).json();
return value;
}, [name]);
const stopImpersonating = useCallback(async () => {
await fetch(`/user/impersonate`, {
method: 'POST',
body: JSON.stringify({ id: '' }),
});
window.location.reload();
}, []);
const setUser = useCallback(
(userId: string) => async () => {
await fetch(`/user/impersonate`, {
method: 'POST',
body: JSON.stringify({ id: userId }),
});
window.location.reload();
},
[]
);
const { data } = useSWR(`/impersonate-${name}`, load, {
refreshWhenHidden: false,
revalidateOnMount: true,
revalidateOnReconnect: false,
revalidateOnFocus: false,
refreshWhenOffline: false,
revalidateIfStale: false,
refreshInterval: 0,
});
const mapData = useMemo(() => {
return data?.map(
(curr: any) => ({
id: curr.id,
name: curr.user.name,
email: curr.user.email,
}),
[]
);
}, [data]);
return (
<div className="px-[23px]">
<div className="bg-forth h-[52px] flex justify-center items-center border-input border rounded-[8px]">
<div className="relative flex flex-col w-[600px]">
<div className="relative z-[999]">
{user?.impersonate ? (
<div className="text-center flex justify-center items-center gap-[20px]">
<div>Currently Impersonating</div>
<div>
<div
className="px-[10px] rounded-[4px] bg-red-500 text-white cursor-pointer"
onClick={stopImpersonating}
>
X
</div>
</div>
</div>
) : (
<Input
autoComplete="off"
placeholder="Write the user details"
name="impersonate"
disableForm={true}
label=""
removeError={true}
value={name}
onChange={(e) => setName(e.target.value)}
/>
)}
</div>
{!!data?.length && (
<>
<div
className="bg-black/80 fixed left-0 top-0 w-full h-full z-[998]"
onClick={() => setName('')}
/>
<div className="absolute top-[100%] w-full left-0 bg-sixth border border-[#172034] text-white z-[999]">
{mapData?.map((user: any) => (
<div
onClick={setUser(user.id)}
key={user.id}
className="p-[10px] border-b border-[#172034] hover:bg-tableBorder cursor-pointer"
>
user: {user.id.split('-').at(-1)} - {user.name} -{' '}
{user.email}
</div>
))}
</div>
</>
)}
</div>
</div>
</div>
);
};

View File

@ -17,13 +17,19 @@ function LayoutContextInner(params: { children: ReactNode }) {
const afterRequest = useCallback(
async (url: string, options: RequestInit, response: Response) => {
if (response?.headers?.get('onboarding')) {
window.location.href = isGeneral() ? '/launches?onboarding=true' : '/analytics?onboarding=true';
window.location.href = isGeneral()
? '/launches?onboarding=true'
: '/analytics?onboarding=true';
}
if (response?.headers?.get('reload')) {
window.location.reload();
}
if (response.status === 401) {
window.location.href = '/';
}
if (response.status === 402) {
if (
await deleteDialog(

View File

@ -27,6 +27,8 @@ import { Support } from '@gitroom/frontend/components/layout/support';
import { ContinueProvider } from '@gitroom/frontend/components/layout/continue.provider';
import { isGeneral } from '@gitroom/react/helpers/is.general';
import { CopilotKit } from '@copilotkit/react-core';
import { Impersonate } from '@gitroom/frontend/components/layout/impersonate';
import clsx from 'clsx';
dayjs.extend(utc);
dayjs.extend(weekOfYear);
@ -53,40 +55,59 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => {
credentials="include"
runtimeUrl={process.env.NEXT_PUBLIC_BACKEND_URL + '/copilot/chat'}
>
<MantineWrapper>
<ToolTip />
<ShowMediaBoxModal />
<ShowLinkedinCompany />
<Toaster />
<ShowPostSelector />
<Onboarding />
<Support />
<ContinueProvider />
<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">
<Link href="/" className="text-2xl flex items-center gap-[10px]">
<div>
<Image src="/logo.svg" width={55} height={53} alt="Logo" />
<ContextWrapper user={user}>
<MantineWrapper>
<ToolTip />
<ShowMediaBoxModal />
<ShowLinkedinCompany />
<Toaster />
<ShowPostSelector />
<Onboarding />
<Support />
<ContinueProvider />
<div className="min-h-[100vh] w-full max-w-[1440px] mx-auto bg-primary px-[12px] text-white flex flex-col">
{user?.admin && <Impersonate />}
<div className="px-[23px] flex h-[80px] items-center justify-between z-[200] sticky top-0 bg-primary">
<Link
href="/"
className="text-2xl flex items-center gap-[10px]"
>
<div className="min-w-[55px]">
<Image
src={isGeneral() ? '/postiz.svg' : '/logo.svg'}
width={55}
height={53}
alt="Logo"
/>
</div>
<div
className={clsx(
!isGeneral() ? 'mt-[12px]' : 'min-w-[80px]'
)}
>
{isGeneral() ? (
<img src="/postiz-text.svg" className="w-[80px]" />
) : (
'Gitroom'
)}
</div>
</Link>
{user?.orgId ? <TopMenu /> : <div />}
<div className="flex items-center gap-[8px]">
<SettingsComponent />
<NotificationComponent />
<OrganizationSelector />
</div>
<div className="mt-[12px]">
{isGeneral() ? 'Postiz' : 'Gitroom'}
</div>
<div className="flex-1 flex">
<div className="flex-1 rounded-3xl px-[23px] py-[17px] flex flex-col">
<Title />
<div className="flex flex-1 flex-col">{children}</div>
</div>
</Link>
{user?.orgId ? <TopMenu /> : <div />}
<div className="flex items-center gap-[8px]">
<SettingsComponent />
<NotificationComponent />
<OrganizationSelector />
</div>
</div>
<div className="flex-1 flex">
<div className="flex-1 rounded-3xl px-[23px] py-[17px] flex flex-col">
<Title />
<div className="flex flex-1 flex-col">{children}</div>
</div>
</div>
</div>
</MantineWrapper>
</MantineWrapper>
</ContextWrapper>
</CopilotKit>
</ContextWrapper>
);

View File

@ -0,0 +1,19 @@
import { isGeneral } from '@gitroom/react/helpers/is.general';
import { useCallback } from 'react';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
export const LogoutComponent = () => {
const fetch = useFetch();
const logout = useCallback(async () => {
if (await deleteDialog('Are you sure you want to logout?', 'Yes logout')) {
await fetch('/user/logout', {
method: 'POST',
});
window.location.href = '/';
}
}, []);
return <div className="text-red-400 cursor-pointer" onClick={logout}>Logout from {isGeneral() ? 'Postiz' : 'Gitroom'}</div>;
};

View File

@ -14,6 +14,7 @@ import clsx from 'clsx';
import { TeamsComponent } from '@gitroom/frontend/components/settings/teams.component';
import { isGeneral } from '@gitroom/react/helpers/is.general';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { LogoutComponent } from '@gitroom/frontend/components/layout/logout.component';
export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
const { getRef } = props;
@ -187,6 +188,7 @@ export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
</div>
)}
{!!user?.tier?.team_members && isGeneral() && <TeamsComponent />}
<LogoutComponent />
</div>
</form>
</FormProvider>

View File

@ -17,6 +17,26 @@ export const Support = () => {
if (!process.env.NEXT_PUBLIC_DISCORD_SUPPORT || !show) return null
return (
<div className="bg-[#612AD5] fixed right-[15px] bottom-[15px] z-[500] p-[20px] text-white rounded-[20px] cursor-pointer" onClick={() => window.open(process.env.NEXT_PUBLIC_DISCORD_SUPPORT)}>Discord Support</div>
)
}
<div
className="bg-[#fff] w-[194px] h-[58px] fixed right-[20px] bottom-[20px] z-[500] text-[16px] text-[#0E0E0E] rounded-[30px] !rounded-br-[0] cursor-pointer flex justify-center items-center gap-[10px]"
onClick={() => window.open(process.env.NEXT_PUBLIC_DISCORD_SUPPORT)}
>
<div>
<svg
width="32"
height="33"
viewBox="0 0 32 33"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="mb-[4px]"
>
<path
d="M26.1303 11.347C24.3138 9.93899 22.134 9.23502 19.8331 9.11768L19.4697 9.4697C21.5284 9.93899 23.345 10.8776 25.0404 12.1683C22.9817 11.1123 20.6807 10.4084 18.2587 10.1737C17.5321 10.0563 16.9266 10.0563 16.2 10.0563C15.4734 10.0563 14.8679 10.0563 14.1413 10.1737C11.7193 10.4084 9.41833 11.1123 7.35963 12.1683C9.05501 10.8776 10.8716 9.93899 12.9303 9.4697L12.5669 9.11768C10.266 9.23502 8.08621 9.93899 6.26972 11.347C4.21101 15.1017 3.1211 19.3257 3 23.6669C4.81649 25.5443 7.35963 26.7177 10.0239 26.7177C10.0239 26.7177 10.8716 25.779 11.477 24.9576C9.90277 24.6057 8.44954 23.7843 7.48074 22.4937C8.32843 22.963 9.17611 23.4323 10.0239 23.7843C11.1138 24.2537 12.2037 24.4883 13.2936 24.723C14.2624 24.8403 15.2312 24.9576 16.2 24.9576C17.1688 24.9576 18.1376 24.8403 19.1064 24.723C20.1963 24.4883 21.2862 24.2537 22.3761 23.7843C23.2239 23.4323 24.0716 22.963 24.9193 22.4937C23.9505 23.7843 22.4972 24.6057 20.923 24.9576C21.5284 25.779 22.3761 26.7177 22.3761 26.7177C25.0404 26.7177 27.5835 25.5443 29.4 23.6669C29.2789 19.3257 28.189 15.1017 26.1303 11.347ZM12.2037 21.555C10.9927 21.555 9.90278 20.499 9.90278 19.2084C9.90278 17.9177 10.9927 16.8617 12.2037 16.8617C13.4147 16.8617 14.5046 17.9177 14.5046 19.2084C14.5046 20.499 13.4147 21.555 12.2037 21.555ZM20.1963 21.555C18.9853 21.555 17.8954 20.499 17.8954 19.2084C17.8954 17.9177 18.9853 16.8617 20.1963 16.8617C21.4073 16.8617 22.4972 17.9177 22.4972 19.2084C22.4972 20.499 21.4073 21.555 20.1963 21.555Z"
fill="#0E0E0E"
/>
</svg>
</div>
<div>Discord Support</div>
</div>
);
};

View File

@ -24,6 +24,15 @@ export const menuItems = [
icon: 'launches',
path: '/launches',
},
...(general
? [
{
name: 'Analytics',
icon: 'analytics',
path: '/analytics',
},
]
: []),
...(!general
? [
{

View File

@ -15,6 +15,7 @@ export const UserContext = createContext<
role: 'USER' | 'ADMIN' | 'SUPERADMIN';
totalChannels: number;
isLifetime?: boolean;
impersonate: boolean;
})
>(undefined);

View File

@ -12,6 +12,8 @@ import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.titl
import clsx from 'clsx';
import interClass from '@gitroom/react/helpers/inter.font';
import { VideoFrame } from '@gitroom/react/helpers/video.frame';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
const showModalEmitter = new EventEmitter();
export const ShowMediaBoxModal: FC = () => {
@ -50,13 +52,16 @@ export const showMediaBox = (
export const MediaBox: FC<{
setMedia: (params: { id: string; path: string }) => void;
type?: 'image' | 'video';
closeModal: () => void;
}> = (props) => {
const { setMedia, closeModal } = props;
const { setMedia, type, closeModal } = props;
const [pages, setPages] = useState(0);
const [mediaList, setListMedia] = useState<Media[]>([]);
const fetch = useFetch();
const mediaDirectory = useMediaDirectory();
const toaster = useToaster();
const [loading, setLoading] = useState(false);
const loadMedia = useCallback(async () => {
return (await fetch('/media')).json();
@ -64,21 +69,31 @@ export const MediaBox: FC<{
const uploadMedia = useCallback(
async (file: ChangeEvent<HTMLInputElement>) => {
const maxFileSize = 10 * 1024 * 1024;
const maxFileSize =
(file?.target?.files?.[0].name.indexOf('mp4') || -1) > -1
? 100 * 1024 * 1024
: 10 * 1024 * 1024;
if (
!file?.target?.files?.length ||
file?.target?.files?.[0]?.size > maxFileSize
)
) {
toaster.show(
`Maximum file size ${maxFileSize / 1024 / 1024}mb`,
'warning'
);
return;
}
const formData = new FormData();
formData.append('file', file?.target?.files?.[0]);
setLoading(true);
const data = await (
await fetch('/media', {
method: 'POST',
body: formData,
})
).json();
setLoading(false);
setListMedia([...mediaList, data]);
},
[mediaList]
@ -140,7 +155,13 @@ export const MediaBox: FC<{
<input
type="file"
className="absolute left-0 top-0 w-full h-full opacity-0"
accept="image/*,video/mp4"
accept={
type === 'video'
? 'video/mp4'
: type === 'image'
? 'image/*'
: 'image/*,video/mp4'
}
onChange={uploadMedia}
/>
<button
@ -207,22 +228,38 @@ export const MediaBox: FC<{
</div>
</div>
)}
{mediaList.map((media) => (
<div
key={media.id}
className="w-[200px] h-[200px] border-tableBorder border-2 cursor-pointer"
onClick={setNewMedia(media)}
>
{media.path.indexOf('mp4') > -1 ? (
<VideoFrame url={mediaDirectory.set(media.path)} />
) : (
<img
className="w-full h-full object-cover"
src={mediaDirectory.set(media.path)}
/>
)}
{mediaList
.filter((f) => {
if (type === 'video') {
return f.path.indexOf('mp4') > -1;
} else if (type === 'image') {
return f.path.indexOf('mp4') === -1;
}
return true;
})
.map((media) => (
<div
key={media.id}
className="w-[200px] h-[200px] flex border-tableBorder border-2 cursor-pointer"
onClick={setNewMedia(media)}
>
{media.path.indexOf('mp4') > -1 ? (
<VideoFrame url={mediaDirectory.set(media.path)} />
) : (
<img
className="w-full h-full object-cover"
src={mediaDirectory.set(media.path)}
/>
)}
</div>
))}
{loading && (
<div className="w-[200px] h-[200px] flex border-tableBorder border-2 cursor-pointer relative">
<div className="absolute left-0 top-0 w-full h-full -mt-[50px] flex justify-center items-center">
<LoadingComponent />
</div>
</div>
))}
)}
</div>
</div>
</div>
@ -303,7 +340,7 @@ export const MultiMediaComponent: FC<{
{!!currentMedia &&
currentMedia.map((media, index) => (
<>
<div className="cursor-pointer w-[40px] h-[40px] border-2 border-tableBorder relative">
<div className="cursor-pointer w-[40px] h-[40px] border-2 border-tableBorder relative flex">
<div
onClick={() => window.open(mediaDirectory.set(media.path))}
>
@ -340,8 +377,9 @@ export const MediaComponent: FC<{
onChange: (event: {
target: { name: string; value?: { id: string; path: string } };
}) => void;
type?: 'image' | 'video';
}> = (props) => {
const { name, label, description, onChange, value } = props;
const { name, type, label, description, onChange, value } = props;
const { getValues } = useSettings();
useEffect(() => {
const settings = getValues()[props.name];
@ -369,7 +407,9 @@ export const MediaComponent: FC<{
return (
<div className="flex flex-col gap-[8px]">
{modal && <MediaBox setMedia={changeMedia} closeModal={showModal} />}
{modal && (
<MediaBox setMedia={changeMedia} closeModal={showModal} type={type} />
)}
<div className="text-[14px]">{label}</div>
<div className="text-[12px]">{description}</div>
{!!currentMedia && (

View File

@ -0,0 +1,220 @@
'use client';
import useSWR from 'swr';
import { useCallback, useMemo, useState } from 'react';
import { capitalize, orderBy } from 'lodash';
import clsx from 'clsx';
import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback';
import Image from 'next/image';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { RenderAnalytics } from '@gitroom/frontend/components/platform-analytics/render.analytics';
import { Select } from '@gitroom/react/form/select';
import { Button } from '@gitroom/react/form/button';
import { useRouter } from 'next/navigation';
const allowedIntegrations = [
'facebook',
'instagram',
'linkedin-page',
'tiktok',
'youtube',
'pinterest',
];
export const PlatformAnalytics = () => {
const fetch = useFetch();
const router = useRouter();
const [current, setCurrent] = useState(0);
const [key, setKey] = useState(7);
const load = useCallback(async () => {
const int = (await (await fetch('/integrations/list')).json()).integrations;
return int.filter((f: any) => allowedIntegrations.includes(f.identifier));
}, []);
const { data, isLoading } = useSWR('analytics-list', load, {
fallbackData: [],
});
const sortedIntegrations = useMemo(() => {
return orderBy(
data,
['type', 'disabled', 'identifier'],
['desc', 'asc', 'asc']
);
}, [data]);
const currentIntegration = useMemo(() => {
return sortedIntegrations[current];
}, [current, sortedIntegrations]);
const options = useMemo(() => {
if (!currentIntegration) {
return [];
}
const arr = [];
if (
[
'facebook',
'instagram',
'linkedin-page',
'pinterest',
'youtube',
].indexOf(currentIntegration.identifier) !== -1
) {
arr.push({
key: 7,
value: '7 Days',
});
}
if (
[
'facebook',
'instagram',
'linkedin-page',
'pinterest',
'youtube',
].indexOf(currentIntegration.identifier) !== -1
) {
arr.push({
key: 30,
value: '30 Days',
});
}
if (
['facebook', 'linkedin-page', 'pinterest', 'youtube'].indexOf(
currentIntegration.identifier
) !== -1
) {
arr.push({
key: 90,
value: '90 Days',
});
}
return arr;
}, [currentIntegration]);
const keys = useMemo(() => {
if (!currentIntegration) {
return 7;
}
if (options.find((p) => p.key === key)) {
return key;
}
return options[0]?.key;
}, [key, currentIntegration]);
if (isLoading) {
return null;
}
if (!sortedIntegrations.length && !isLoading) {
return (
<div className="flex flex-col items-center mt-[100px] gap-[27px] text-center">
<div>
<img src="/peoplemarketplace.svg" />
</div>
<div className="text-[48px]">
Can{"'"}t show analytics yet
<br />
You have to add Social Media channels
</div>
<div className="text-[20px]">
Supported: {allowedIntegrations.map(p => capitalize(p)).join(', ')}
</div>
<Button onClick={() => router.push('/launches')}>
Go to the calendar to add channels
</Button>
</div>
);
}
return (
<div className="flex gap-[30px] flex-1">
<div className="p-[16px] bg-[#080B14] overflow-hidden flex w-[220px]">
<div className="flex gap-[16px] flex-col overflow-hidden">
<div className="text-[20px] mb-[8px]">Channels</div>
{sortedIntegrations.map((integration, index) => (
<div
key={integration.id}
onClick={() => setCurrent(index)}
className={clsx(
'flex gap-[8px] items-center',
currentIntegration.id !== integration.id &&
'opacity-20 hover:opacity-100 cursor-pointer'
)}
>
<div
className={clsx(
'relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth',
integration.disabled && 'opacity-50'
)}
>
{(integration.inBetweenSteps || integration.refreshNeeded) && (
<div className="absolute left-0 top-0 w-[39px] h-[46px] cursor-pointer">
<div className="bg-red-500 w-[15px] h-[15px] rounded-full -left-[5px] -top-[5px] absolute z-[200] text-[10px] flex justify-center items-center">
!
</div>
<div className="bg-black/60 w-[39px] h-[46px] left-0 top-0 absolute rounded-full z-[199]" />
</div>
)}
<ImageWithFallback
fallbackSrc={`/icons/platforms/${integration.identifier}.png`}
src={integration.picture}
className="rounded-full"
alt={integration.identifier}
width={32}
height={32}
/>
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
</div>
<div
className={clsx(
'flex-1 whitespace-nowrap text-ellipsis overflow-hidden',
integration.disabled && 'opacity-50'
)}
>
{integration.name}
</div>
</div>
))}
</div>
</div>
{!!options.length && (
<div className="flex-1 flex flex-col gap-[14px]">
<div className="max-w-[200px]">
<Select
className="bg-[#0A0B14] !border-0"
label=""
name="date"
disableForm={true}
hideErrors={true}
onChange={(e) => setKey(+e.target.value)}
>
{options.map((option) => (
<option key={option.key} value={option.key}>
{option.value}
</option>
))}
</Select>
</div>
<div className="flex-1">
{!!keys && !!currentIntegration && (
<RenderAnalytics integration={currentIntegration} date={keys} />
)}
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,77 @@
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { Integration } from '@prisma/client';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { ChartSocial } from '@gitroom/frontend/components/analytics/chart-social';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
export const RenderAnalytics: FC<{ integration: Integration; date: number }> = (
props
) => {
const { integration, date } = props;
const [loading, setLoading] = useState(true);
const fetch = useFetch();
const load = useCallback(async () => {
setLoading(true);
const load = (
await fetch(`/analytics/${integration.id}?date=${date}`)
).json();
setLoading(false);
return load;
}, [integration, date]);
const { data } = useSWR(`/analytics-${integration?.id}-${date}`, load, {
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
refreshWhenOffline: false,
revalidateOnMount: true,
});
const total = useMemo(() => {
return data?.map(
(p: any) => {
const value = (p?.data.reduce((acc: number, curr: any) => acc + curr.total, 0) || 0) /
(p.average ? p.data.length : 1);
if (p.average) {
return value.toFixed(2) + '%';
}
return value;
}
);
}, [data]);
if (loading) {
return (
<>
<LoadingComponent />
</>
);
}
return (
<div className="grid grid-cols-3 gap-[20px]">
{data?.map((p: any, index: number) => (
<div key={`pl-${index}`} className="flex">
<div className="flex-1 bg-secondary py-[10px] px-[16px] gap-[10px] flex flex-col">
<div className="flex items-center gap-[14px]">
<div className="text-[20px]">{p.label}</div>
</div>
<div className="flex-1">
<div className="h-[156px] relative">
<ChartSocial {...p} key={`p-${index}`} />
</div>
</div>
<div className="text-[50px] leading-[60px]">{total[index]}</div>
</div>
</div>
))}
</div>
);
};

View File

@ -189,7 +189,7 @@ export const TeamsComponent = () => {
<h2 className="text-[24px] mb-[24px]">Team Members</h2>
<h3 className="text-[20px]">Account Managers</h3>
<div className="text-[#AAA] mt-[4px]">
Invite your assistant or team member to manage your Gitroom account
Invite your assistant or team member to manage your account
</div>
<div className="my-[16px] mt-[16px] bg-sixth border-fifth border rounded-[4px] p-[24px] flex flex-col gap-[24px]">
<div className="flex flex-col gap-[16px]">

View File

@ -30,7 +30,10 @@ export async function middleware(request: NextRequest) {
const url = new URL(nextUrl).search;
if (nextUrl.href.indexOf('/auth') === -1 && !authCookie) {
return NextResponse.redirect(new URL(`/auth${url}`, nextUrl.href));
const providers = ['google', 'github'];
const findIndex = providers.find(p => nextUrl.href.indexOf(p) > -1);
const additional = !findIndex ? '' : (url.indexOf('?') > -1 ? '&' : '?') + `provider=${findIndex.toUpperCase()}`;
return NextResponse.redirect(new URL(`/auth${url}${additional}`, nextUrl.href));
}
// If the url is /auth and the cookie exists, redirect to /
@ -87,12 +90,6 @@ export async function middleware(request: NextRequest) {
return redirect;
}
if (isGeneral() && (nextUrl.pathname.indexOf('/analytics') > -1 || nextUrl.pathname.indexOf('/settings') > -1)) {
return NextResponse.redirect(
new URL('/launches', nextUrl.href)
);
}
if (nextUrl.pathname === '/') {
return NextResponse.redirect(
new URL(isGeneral() ? '/launches' : `/analytics`, nextUrl.href)

File diff suppressed because one or more lines are too long

View File

@ -34,7 +34,8 @@ export class IntegrationRepository {
refreshToken = '',
expiresIn = 999999999,
username?: string,
isBetweenSteps = false
isBetweenSteps = false,
refresh?: string
) {
return this._integration.model.integration.upsert({
where: {
@ -57,15 +58,20 @@ export class IntegrationRepository {
: {}),
internalId,
organizationId: org,
refreshNeeded: false,
},
update: {
type: type as any,
...(!refresh
? {
inBetweenSteps: isBetweenSteps,
}
: {}),
name,
providerIdentifier: provider,
inBetweenSteps: isBetweenSteps,
token,
picture,
profile: username,
providerIdentifier: provider,
token,
refreshToken,
...(expiresIn
? { tokenExpiration: new Date(Date.now() + expiresIn * 1000) }
@ -73,6 +79,7 @@ export class IntegrationRepository {
internalId,
organizationId: org,
deletedAt: null,
refreshNeeded: false,
},
});
}
@ -85,6 +92,19 @@ export class IntegrationRepository {
},
inBetweenSteps: false,
deletedAt: null,
refreshNeeded: false,
},
});
}
refreshNeeded(org: string, id: string) {
return this._integration.model.integration.update({
where: {
id,
organizationId: org,
},
data: {
refreshNeeded: true,
},
});
}
@ -104,7 +124,6 @@ export class IntegrationRepository {
user: string,
org: string
) {
console.log(id, order, user, org);
const integration = await this._posts.model.post.findFirst({
where: {
integrationId: id,
@ -204,7 +223,7 @@ export class IntegrationRepository {
},
data: {
internalId: makeId(10),
}
},
});
}

View File

@ -3,12 +3,17 @@ import { IntegrationRepository } from '@gitroom/nestjs-libraries/database/prisma
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { InstagramProvider } from '@gitroom/nestjs-libraries/integrations/social/instagram.provider';
import { FacebookProvider } from '@gitroom/nestjs-libraries/integrations/social/facebook.provider';
import { SocialProvider } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { Integration } from '@prisma/client';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.page.provider';
@Injectable()
export class IntegrationService {
constructor(
private _integrationRepository: IntegrationRepository,
private _integrationManager: IntegrationManager
private _integrationManager: IntegrationManager,
private _notificationService: NotificationService
) {}
createOrUpdateIntegration(
org: string,
@ -21,7 +26,8 @@ export class IntegrationService {
refreshToken = '',
expiresIn?: number,
username?: string,
isBetweenSteps = false
isBetweenSteps = false,
refresh?: string
) {
return this._integrationRepository.createOrUpdateIntegration(
org,
@ -34,7 +40,8 @@ export class IntegrationService {
refreshToken,
expiresIn,
username,
isBetweenSteps
isBetweenSteps,
refresh
);
}
@ -55,6 +62,34 @@ export class IntegrationService {
return this._integrationRepository.getIntegrationById(org, id);
}
async refreshToken(provider: SocialProvider, refresh: string) {
try {
const { refreshToken, accessToken, expiresIn } =
await provider.refreshToken(refresh);
if (!refreshToken || !accessToken || !expiresIn) {
return false;
}
return { refreshToken, accessToken, expiresIn };
} catch (e) {
return false;
}
}
async informAboutRefreshError(orgId: string, integration: Integration) {
await this._notificationService.inAppNotification(
orgId,
`Could not refresh your ${integration.providerIdentifier} channel`,
`Could not refresh your ${integration.providerIdentifier} channel. Please go back to the system and connect it again ${process.env.FRONTEND_URL}/launches`,
true
);
}
async refreshNeeded(org: string, id: string) {
return this._integrationRepository.refreshNeeded(org, id);
}
async refreshTokens() {
const integrations = await this._integrationRepository.needsToBeRefreshed();
for (const integration of integrations) {
@ -62,8 +97,21 @@ export class IntegrationService {
integration.providerIdentifier
);
const { refreshToken, accessToken, expiresIn } =
await provider.refreshToken(integration.refreshToken!);
const data = await this.refreshToken(provider, integration.refreshToken!);
if (!data) {
await this.informAboutRefreshError(
integration.organizationId,
integration
);
await this._integrationRepository.refreshNeeded(
integration.organizationId,
integration.id
);
return;
}
const { refreshToken, accessToken, expiresIn } = data;
await this.createOrUpdateIntegration(
integration.organizationId,
@ -117,7 +165,11 @@ export class IntegrationService {
return this._integrationRepository.checkForDeletedOnceAndUpdate(org, page);
}
async saveInstagram(org: string, id: string, data: { pageId: string, id: string }) {
async saveInstagram(
org: string,
id: string,
data: { pageId: string; id: string }
) {
const getIntegration = await this._integrationRepository.getIntegrationById(
org,
id
@ -141,6 +193,39 @@ export class IntegrationService {
name: getIntegrationInformation.name,
inBetweenSteps: false,
token: getIntegrationInformation.access_token,
profile: getIntegrationInformation.username,
});
return { success: true };
}
async saveLinkedin(org: string, id: string, page: string) {
const getIntegration = await this._integrationRepository.getIntegrationById(
org,
id
);
if (getIntegration && !getIntegration.inBetweenSteps) {
throw new HttpException('Invalid request', HttpStatus.BAD_REQUEST);
}
const linkedin = this._integrationManager.getSocialIntegration(
'linkedin-page'
) as LinkedinPageProvider;
const getIntegrationInformation = await linkedin.fetchPageInformation(
getIntegration?.token!,
page
);
await this.checkForDeletedOnceAndUpdate(org, String(getIntegrationInformation.id));
await this._integrationRepository.updateIntegration(String(id), {
picture: getIntegrationInformation.picture,
internalId: String(getIntegrationInformation.id),
name: getIntegrationInformation.name,
inBetweenSteps: false,
token: getIntegrationInformation.access_token,
profile: getIntegrationInformation.username,
});
return { success: true };
@ -170,6 +255,7 @@ export class IntegrationService {
name: getIntegrationInformation.name,
inBetweenSteps: false,
token: getIntegrationInformation.access_token,
profile: getIntegrationInformation.username,
});
return { success: true };

View File

@ -12,6 +12,77 @@ export class OrganizationRepository {
private _user: PrismaRepository<'user'>
) {}
getUserOrg(id: string) {
return this._userOrg.model.userOrganization.findFirst({
where: {
id,
},
select: {
user: true,
organization: {
include: {
users: {
select: {
id: true,
disabled: true,
role: true,
userId: true,
},
},
subscription: {
select: {
subscriptionTier: true,
totalChannels: true,
isLifetime: true,
},
},
},
},
},
});
}
getImpersonateUser(name: string) {
return this._userOrg.model.userOrganization.findMany({
where: {
user: {
OR: [
{
name: {
contains: name,
},
},
{
email: {
contains: name,
},
},
{
id: {
contains: name,
},
},
],
},
},
select: {
id: true,
organization: {
select: {
id: true,
},
},
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
}
async getOrgsByUserId(userId: string) {
return this._organization.model.organization.findMany({
where: {
@ -115,6 +186,7 @@ export class OrganizationRepository {
role: Role.SUPERADMIN,
user: {
create: {
activated: body.provider !== 'LOCAL',
email: body.email,
password: body.password
? AuthService.hashPassword(body.password)

View File

@ -33,6 +33,10 @@ export class OrganizationService {
return this._organizationRepository.getOrgById(id);
}
getUserOrg(id: string) {
return this._organizationRepository.getUserOrg(id);
}
getOrgsByUserId(userId: string) {
return this._organizationRepository.getOrgsByUserId(userId);
}

View File

@ -1,7 +1,7 @@
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Post as PostBody } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto';
import { APPROVED_SUBMIT_FOR_ORDER, Post } from '@prisma/client';
import { APPROVED_SUBMIT_FOR_ORDER, Post, State } from '@prisma/client';
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
@ -75,7 +75,7 @@ export class PostsRepository {
},
{
submittedForOrganizationId: orgId,
}
},
],
publishDate: {
gte: startDate,
@ -163,6 +163,17 @@ export class PostsRepository {
});
}
changeState(id: string, state: State) {
return this._post.model.post.update({
where: {
id,
},
data: {
state,
},
});
}
async changeDate(orgId: string, id: string, date: string) {
return this._post.model.post.update({
where: {

View File

@ -16,6 +16,7 @@ import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
import { CreateGeneratedPostsDto } from '@gitroom/nestjs-libraries/dtos/generator/create.generated.posts.dto';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract';
type PostWithConditionals = Post & {
integration?: Integration;
@ -89,6 +90,16 @@ export class PostsService {
return;
}
if (firstPost.integration?.refreshNeeded) {
await this._notificationService.inAppNotification(
firstPost.organizationId,
`We couldn't post to ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name}`,
`We couldn't post to ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name} because you need to reconnect it. Please enable it and try again.`,
true
);
return;
}
if (firstPost.integration?.disabled) {
await this._notificationService.inAppNotification(
firstPost.organizationId,
@ -112,6 +123,13 @@ export class PostsService {
]);
if (!finalPost?.postId || !finalPost?.releaseURL) {
await this._postRepository.changeState(firstPost.id, 'ERROR');
await this._notificationService.inAppNotification(
firstPost.organizationId,
`Error posting on ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name}`,
`An error occurred while posting on ${firstPost.integration?.providerIdentifier}`,
true
);
return;
}
@ -124,10 +142,13 @@ export class PostsService {
});
}
} catch (err: any) {
await this._postRepository.changeState(firstPost.id, 'ERROR');
await this._notificationService.inAppNotification(
firstPost.organizationId,
`Error posting on ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name}`,
`An error occurred while posting on ${firstPost.integration?.providerIdentifier}: ${err.message}`,
`An error occurred while posting on ${
firstPost.integration?.providerIdentifier
} ${JSON.stringify(err)}`,
true
);
}
@ -155,61 +176,103 @@ export class PostsService {
return this.updateTags(orgId, JSON.parse(newPlainText) as Post[]);
}
private async postSocial(integration: Integration, posts: Post[]) {
private async postSocial(
integration: Integration,
posts: Post[],
forceRefresh = false
): Promise<Partial<{ postId: string; releaseURL: string }>> {
const getIntegration = this._integrationManager.getSocialIntegration(
integration.providerIdentifier
);
if (!getIntegration) {
return;
return {};
}
if (dayjs(integration?.tokenExpiration).isBefore(dayjs()) || forceRefresh) {
const { accessToken, expiresIn, refreshToken } =
await getIntegration.refreshToken(integration.refreshToken!);
if (!accessToken) {
await this._integrationService.refreshNeeded(
integration.organizationId,
integration.id
);
await this._integrationService.informAboutRefreshError(integration.organizationId, integration);
return {};
}
await this._integrationService.createOrUpdateIntegration(
integration.organizationId,
integration.name,
integration.picture!,
'social',
integration.internalId,
integration.providerIdentifier,
accessToken,
refreshToken,
expiresIn
);
integration.token = accessToken;
}
const newPosts = await this.updateTags(integration.organizationId, posts);
const publishedPosts = await getIntegration.post(
integration.internalId,
integration.token,
newPosts.map((p) => ({
id: p.id,
message: p.content,
settings: JSON.parse(p.settings || '{}'),
media: (JSON.parse(p.image || '[]') as Media[]).map((m) => ({
url:
m.path.indexOf('http') === -1
? process.env.FRONTEND_URL +
'/' +
process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY +
m.path
: m.path,
type: 'image',
path:
m.path.indexOf('http') === -1
? process.env.UPLOAD_DIRECTORY + m.path
: m.path,
})),
}))
);
for (const post of publishedPosts) {
await this._postRepository.updatePost(
post.id,
post.postId,
post.releaseURL
try {
const publishedPosts = await getIntegration.post(
integration.internalId,
integration.token,
newPosts.map((p) => ({
id: p.id,
message: p.content,
settings: JSON.parse(p.settings || '{}'),
media: (JSON.parse(p.image || '[]') as Media[]).map((m) => ({
url:
m.path.indexOf('http') === -1
? process.env.FRONTEND_URL +
'/' +
process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY +
m.path
: m.path,
type: 'image',
path:
m.path.indexOf('http') === -1
? process.env.UPLOAD_DIRECTORY + m.path
: m.path,
})),
}))
);
for (const post of publishedPosts) {
await this._postRepository.updatePost(
post.id,
post.postId,
post.releaseURL
);
}
await this._notificationService.inAppNotification(
integration.organizationId,
`Your post has been published on ${capitalize(
integration.providerIdentifier
)}`,
`Your post has been published at ${publishedPosts[0].releaseURL}`,
true
);
return {
postId: publishedPosts[0].postId,
releaseURL: publishedPosts[0].releaseURL,
};
} catch (err) {
if (err instanceof RefreshToken) {
return this.postSocial(integration, posts, true);
}
throw err;
}
await this._notificationService.inAppNotification(
integration.organizationId,
`Your post has been published on ${capitalize(
integration.providerIdentifier
)}`,
`Your post has been published at ${publishedPosts[0].releaseURL}`,
true
);
return {
postId: publishedPosts[0].postId,
releaseURL: publishedPosts[0].releaseURL,
};
}
private async postArticle(integration: Integration, posts: Post[]) {
@ -262,7 +325,6 @@ export class PostsService {
integrationId: string
) {
if (!(await this._messagesService.canAddPost(id, order, integrationId))) {
console.log('hello');
throw new Error('You can not add a post to this publication');
}
const getOrgByOrder = await this._messagesService.getOrgByOrder(order);

Some files were not shown because too many files have changed in this diff Show More