feat: dev integration
This commit is contained in:
parent
c215375bea
commit
23e96e8e03
|
|
@ -1,48 +1,60 @@
|
|||
import {MiddlewareConsumer, Module, NestModule} from '@nestjs/common';
|
||||
import {AuthController} from "@gitroom/backend/api/routes/auth.controller";
|
||||
import {AuthService} from "@gitroom/backend/services/auth/auth.service";
|
||||
import {UsersController} from "@gitroom/backend/api/routes/users.controller";
|
||||
import {AuthMiddleware} from "@gitroom/backend/services/auth/auth.middleware";
|
||||
import {StripeController} from "@gitroom/backend/api/routes/stripe.controller";
|
||||
import {StripeService} from "@gitroom/nestjs-libraries/services/stripe.service";
|
||||
import {AnalyticsController} from "@gitroom/backend/api/routes/analytics.controller";
|
||||
import {PoliciesGuard} from "@gitroom/backend/services/auth/permissions/permissions.guard";
|
||||
import {PermissionsService} from "@gitroom/backend/services/auth/permissions/permissions.service";
|
||||
import {IntegrationsController} from "@gitroom/backend/api/routes/integrations.controller";
|
||||
import {IntegrationManager} from "@gitroom/nestjs-libraries/integrations/integration.manager";
|
||||
import {SettingsController} from "@gitroom/backend/api/routes/settings.controller";
|
||||
import {BullMqModule} from "@gitroom/nestjs-libraries/bull-mq-transport/bull-mq.module";
|
||||
import {ioRedis} from "@gitroom/nestjs-libraries/redis/redis.service";
|
||||
import {PostsController} from "@gitroom/backend/api/routes/posts.controller";
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { AuthController } from '@gitroom/backend/api/routes/auth.controller';
|
||||
import { AuthService } from '@gitroom/backend/services/auth/auth.service';
|
||||
import { UsersController } from '@gitroom/backend/api/routes/users.controller';
|
||||
import { AuthMiddleware } from '@gitroom/backend/services/auth/auth.middleware';
|
||||
import { StripeController } from '@gitroom/backend/api/routes/stripe.controller';
|
||||
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
|
||||
import { AnalyticsController } from '@gitroom/backend/api/routes/analytics.controller';
|
||||
import { PoliciesGuard } from '@gitroom/backend/services/auth/permissions/permissions.guard';
|
||||
import { PermissionsService } from '@gitroom/backend/services/auth/permissions/permissions.service';
|
||||
import { IntegrationsController } from '@gitroom/backend/api/routes/integrations.controller';
|
||||
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
|
||||
import { SettingsController } from '@gitroom/backend/api/routes/settings.controller';
|
||||
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport/bull-mq.module';
|
||||
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
|
||||
import { PostsController } from '@gitroom/backend/api/routes/posts.controller';
|
||||
import { MediaController } from '@gitroom/backend/api/routes/media.controller';
|
||||
import { UploadModule } from '@gitroom/nestjs-libraries/upload/upload.module';
|
||||
import {ServeStaticModule} from "@nestjs/serve-static";
|
||||
|
||||
const authenticatedController = [
|
||||
UsersController,
|
||||
AnalyticsController,
|
||||
IntegrationsController,
|
||||
SettingsController,
|
||||
PostsController
|
||||
UsersController,
|
||||
AnalyticsController,
|
||||
IntegrationsController,
|
||||
SettingsController,
|
||||
PostsController,
|
||||
MediaController,
|
||||
];
|
||||
@Module({
|
||||
imports: [BullMqModule.forRoot({
|
||||
connection: ioRedis
|
||||
})],
|
||||
controllers: [StripeController, AuthController, ...authenticatedController],
|
||||
providers: [
|
||||
AuthService,
|
||||
StripeService,
|
||||
AuthMiddleware,
|
||||
PoliciesGuard,
|
||||
PermissionsService,
|
||||
IntegrationManager,
|
||||
],
|
||||
get exports() {
|
||||
return [...this.imports, ...this.providers];
|
||||
}
|
||||
imports: [
|
||||
UploadModule,
|
||||
BullMqModule.forRoot({
|
||||
connection: ioRedis,
|
||||
}),
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: process.env.UPLOAD_DIRECTORY,
|
||||
serveRoot: '/' + process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY,
|
||||
serveStaticOptions: {
|
||||
index: false,
|
||||
}
|
||||
}),
|
||||
],
|
||||
controllers: [StripeController, AuthController, ...authenticatedController],
|
||||
providers: [
|
||||
AuthService,
|
||||
StripeService,
|
||||
AuthMiddleware,
|
||||
PoliciesGuard,
|
||||
PermissionsService,
|
||||
IntegrationManager,
|
||||
],
|
||||
get exports() {
|
||||
return [...this.imports, ...this.providers];
|
||||
},
|
||||
})
|
||||
export class ApiModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(AuthMiddleware)
|
||||
.forRoutes(...authenticatedController);
|
||||
}
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(AuthMiddleware).forRoutes(...authenticatedController);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,95 +1,181 @@
|
|||
import {Body, Controller, Get, Param, Post} from '@nestjs/common';
|
||||
import {ioRedis} from "@gitroom/nestjs-libraries/redis/redis.service";
|
||||
import {ConnectIntegrationDto} from "@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto";
|
||||
import {IntegrationManager} from "@gitroom/nestjs-libraries/integrations/integration.manager";
|
||||
import {IntegrationService} from "@gitroom/nestjs-libraries/database/prisma/integrations/integration.service";
|
||||
import {GetOrgFromRequest} from "@gitroom/nestjs-libraries/user/org.from.request";
|
||||
import {Organization} from "@prisma/client";
|
||||
import {ApiKeyDto} from "@gitroom/nestjs-libraries/dtos/integrations/api.key.dto";
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
|
||||
import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto';
|
||||
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
|
||||
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
|
||||
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
|
||||
import { Organization } from '@prisma/client';
|
||||
import { ApiKeyDto } from '@gitroom/nestjs-libraries/dtos/integrations/api.key.dto';
|
||||
import { IntegrationFunctionDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.function.dto';
|
||||
|
||||
@Controller('/integrations')
|
||||
export class IntegrationsController {
|
||||
constructor(
|
||||
private _integrationManager: IntegrationManager,
|
||||
private _integrationService: IntegrationService
|
||||
constructor(
|
||||
private _integrationManager: IntegrationManager,
|
||||
private _integrationService: IntegrationService
|
||||
) {}
|
||||
@Get('/')
|
||||
getIntegration() {
|
||||
return this._integrationManager.getAllIntegrations();
|
||||
}
|
||||
|
||||
@Get('/list')
|
||||
async getIntegrationList(@GetOrgFromRequest() org: Organization) {
|
||||
return {
|
||||
integrations: (
|
||||
await this._integrationService.getIntegrationsList(org.id)
|
||||
).map((p) => ({
|
||||
name: p.name,
|
||||
id: p.id,
|
||||
picture: p.picture,
|
||||
identifier: p.providerIdentifier,
|
||||
type: p.type,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('/social/:integration')
|
||||
async getIntegrationUrl(@Param('integration') integration: string) {
|
||||
if (
|
||||
!this._integrationManager
|
||||
.getAllowedSocialsIntegrations()
|
||||
.includes(integration)
|
||||
) {
|
||||
}
|
||||
@Get('/')
|
||||
getIntegration() {
|
||||
return this._integrationManager.getAllIntegrations();
|
||||
throw new Error('Integration not allowed');
|
||||
}
|
||||
|
||||
@Get('/list')
|
||||
async getIntegrationList(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
const integrationProvider =
|
||||
this._integrationManager.getSocialIntegration(integration);
|
||||
const { codeVerifier, state, url } =
|
||||
await integrationProvider.generateAuthUrl();
|
||||
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300);
|
||||
|
||||
return { url };
|
||||
}
|
||||
|
||||
@Post('/function')
|
||||
async functionIntegration(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Body() body: IntegrationFunctionDto
|
||||
) {
|
||||
const getIntegration = await this._integrationService.getIntegrationById(
|
||||
org.id,
|
||||
body.id
|
||||
);
|
||||
if (!getIntegration) {
|
||||
throw new Error('Invalid integration');
|
||||
}
|
||||
|
||||
if (getIntegration.type === 'social') {
|
||||
const integrationProvider = this._integrationManager.getSocialIntegration(
|
||||
getIntegration.providerIdentifier
|
||||
);
|
||||
if (!integrationProvider) {
|
||||
throw new Error('Invalid provider');
|
||||
}
|
||||
|
||||
if (integrationProvider[body.name]) {
|
||||
return integrationProvider[body.name](getIntegration.token, body.data);
|
||||
}
|
||||
throw new Error('Function not found');
|
||||
}
|
||||
|
||||
if (getIntegration.type === 'article') {
|
||||
const integrationProvider =
|
||||
this._integrationManager.getArticlesIntegration(
|
||||
getIntegration.providerIdentifier
|
||||
);
|
||||
if (!integrationProvider) {
|
||||
throw new Error('Invalid provider');
|
||||
}
|
||||
|
||||
if (integrationProvider[body.name]) {
|
||||
return integrationProvider[body.name](getIntegration.token, body.data);
|
||||
}
|
||||
throw new Error('Function not found');
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/article/:integration/connect')
|
||||
async connectArticle(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('integration') integration: string,
|
||||
@Body() api: ApiKeyDto
|
||||
) {
|
||||
if (
|
||||
!this._integrationManager
|
||||
.getAllowedArticlesIntegrations()
|
||||
.includes(integration)
|
||||
) {
|
||||
return {integrations: (await this._integrationService.getIntegrationsList(org.id)).map(p => ({name: p.name, id: p.id, picture: p.picture, identifier: p.providerIdentifier, type: p.type}))};
|
||||
throw new Error('Integration not allowed');
|
||||
}
|
||||
|
||||
@Get('/social/:integration')
|
||||
async getIntegrationUrl(
|
||||
@Param('integration') integration: string
|
||||
if (!api) {
|
||||
throw new Error('Missing api');
|
||||
}
|
||||
|
||||
const integrationProvider =
|
||||
this._integrationManager.getArticlesIntegration(integration);
|
||||
const { id, name, token, picture } = await integrationProvider.authenticate(
|
||||
api.api
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
throw new Error('Invalid api key');
|
||||
}
|
||||
|
||||
return this._integrationService.createIntegration(
|
||||
org.id,
|
||||
name,
|
||||
picture,
|
||||
'article',
|
||||
String(id),
|
||||
integration,
|
||||
token
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/social/:integration/connect')
|
||||
async connectSocialMedia(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('integration') integration: string,
|
||||
@Body() body: ConnectIntegrationDto
|
||||
) {
|
||||
if (
|
||||
!this._integrationManager
|
||||
.getAllowedSocialsIntegrations()
|
||||
.includes(integration)
|
||||
) {
|
||||
if (!this._integrationManager.getAllowedSocialsIntegrations().includes(integration)) {
|
||||
throw new Error('Integration not allowed');
|
||||
}
|
||||
|
||||
const integrationProvider = this._integrationManager.getSocialIntegration(integration);
|
||||
const {codeVerifier, state, url} = await integrationProvider.generateAuthUrl();
|
||||
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300);
|
||||
|
||||
return {url};
|
||||
throw new Error('Integration not allowed');
|
||||
}
|
||||
|
||||
@Post('/article/:integration/connect')
|
||||
async connectArticle(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('integration') integration: string,
|
||||
@Body() api: ApiKeyDto
|
||||
) {
|
||||
if (!this._integrationManager.getAllowedArticlesIntegrations().includes(integration)) {
|
||||
throw new Error('Integration not allowed');
|
||||
}
|
||||
|
||||
if (!api) {
|
||||
throw new Error('Missing api');
|
||||
}
|
||||
|
||||
const integrationProvider = this._integrationManager.getArticlesIntegration(integration);
|
||||
const {id, name, token, picture} = await integrationProvider.authenticate(api.api);
|
||||
|
||||
if (!id) {
|
||||
throw new Error('Invalid api key');
|
||||
}
|
||||
|
||||
return this._integrationService.createIntegration(org.id, name, picture,'article', String(id), integration, token);
|
||||
const getCodeVerifier = await ioRedis.get(`login:${body.state}`);
|
||||
if (!getCodeVerifier) {
|
||||
throw new Error('Invalid state');
|
||||
}
|
||||
|
||||
@Post('/social/:integration/connect')
|
||||
async connectSocialMedia(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('integration') integration: string,
|
||||
@Body() body: ConnectIntegrationDto
|
||||
) {
|
||||
if (!this._integrationManager.getAllowedSocialsIntegrations().includes(integration)) {
|
||||
throw new Error('Integration not allowed');
|
||||
}
|
||||
const integrationProvider =
|
||||
this._integrationManager.getSocialIntegration(integration);
|
||||
const { accessToken, expiresIn, refreshToken, id, name, picture } =
|
||||
await integrationProvider.authenticate({
|
||||
code: body.code,
|
||||
codeVerifier: getCodeVerifier,
|
||||
});
|
||||
|
||||
const getCodeVerifier = await ioRedis.get(`login:${body.state}`);
|
||||
if (!getCodeVerifier) {
|
||||
throw new Error('Invalid state');
|
||||
}
|
||||
|
||||
const integrationProvider = this._integrationManager.getSocialIntegration(integration);
|
||||
const {accessToken, expiresIn, refreshToken, id, name, picture} = await integrationProvider.authenticate({
|
||||
code: body.code,
|
||||
codeVerifier: getCodeVerifier
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
throw new Error('Invalid api key');
|
||||
}
|
||||
|
||||
return this._integrationService.createIntegration(org.id, name, picture, 'social', String(id), integration, accessToken, refreshToken, expiresIn);
|
||||
if (!id) {
|
||||
throw new Error('Invalid api key');
|
||||
}
|
||||
|
||||
return this._integrationService.createIntegration(
|
||||
org.id,
|
||||
name,
|
||||
picture,
|
||||
'social',
|
||||
String(id),
|
||||
integration,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import {
|
||||
Controller, FileTypeValidator, Get, MaxFileSizeValidator, ParseFilePipe, Post, Query, UploadedFile, UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Express } from 'express';
|
||||
import {GetOrgFromRequest} from "@gitroom/nestjs-libraries/user/org.from.request";
|
||||
import {Organization} from "@prisma/client";
|
||||
import {MediaService} from "@gitroom/nestjs-libraries/database/prisma/media/media.service";
|
||||
|
||||
@Controller('/media')
|
||||
export class MediaController {
|
||||
constructor(
|
||||
private _mediaService: MediaService
|
||||
) {
|
||||
}
|
||||
@Post('/')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadFile(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@UploadedFile(
|
||||
'file',
|
||||
new ParseFilePipe({
|
||||
validators: [
|
||||
new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }),
|
||||
new FileTypeValidator({ fileType: 'image/*' }),
|
||||
],
|
||||
})
|
||||
)
|
||||
file: Express.Multer.File
|
||||
) {
|
||||
const filePath = file.path.replace(process.env.UPLOAD_DIRECTORY, '');
|
||||
return this._mediaService.saveFile(org.id, file.originalname, filePath);
|
||||
}
|
||||
|
||||
@Get('/')
|
||||
getMedia(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Query('page') page: number,
|
||||
) {
|
||||
return this._mediaService.getMedia(org.id, page);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import {Body, Controller, Post} from '@nestjs/common';
|
||||
import {Body, Controller, Get, Param, Post, Put, Query} from '@nestjs/common';
|
||||
import {PostsService} from "@gitroom/nestjs-libraries/database/prisma/posts/posts.service";
|
||||
import {GetOrgFromRequest} from "@gitroom/nestjs-libraries/user/org.from.request";
|
||||
import {Organization} from "@prisma/client";
|
||||
import {CreatePostDto} from "@gitroom/nestjs-libraries/dtos/posts/create.post.dto";
|
||||
import {GetPostsDto} from "@gitroom/nestjs-libraries/dtos/posts/get.posts.dto";
|
||||
|
||||
@Controller('/posts')
|
||||
export class PostsController {
|
||||
|
|
@ -11,6 +12,22 @@ export class PostsController {
|
|||
) {
|
||||
}
|
||||
|
||||
@Get('/')
|
||||
getPosts(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Query() query: GetPostsDto
|
||||
) {
|
||||
return this._postsService.getPosts(org.id, query);
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
getPost(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this._postsService.getPost(org.id, id);
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
createPost(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
|
|
@ -18,4 +35,13 @@ export class PostsController {
|
|||
) {
|
||||
return this._postsService.createPost(org.id, body);
|
||||
}
|
||||
|
||||
@Put('/:id/date')
|
||||
changeDate(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('id') id: string,
|
||||
@Body('date') date: string
|
||||
) {
|
||||
return this._postsService.changeDate(org.id, id, date);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ async function bootstrap() {
|
|||
cors: {
|
||||
credentials: true,
|
||||
exposedHeaders: ['reload'],
|
||||
origin: [process.env.FRONTEND_URL]
|
||||
origin: [process.env.FRONTEND_URL],
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["node"],
|
||||
"types": ["node", "Multer"],
|
||||
"emitDecoratorMetadata": true,
|
||||
"target": "es2021"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
export default async function Page() {
|
||||
return (
|
||||
<div>We are experiencing some difficulty, try to refresh the page</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,75 +2,253 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body, html {
|
||||
background-color: black;
|
||||
body,
|
||||
html {
|
||||
background-color: black;
|
||||
}
|
||||
.box {
|
||||
position: relative;
|
||||
padding: 8px 24px;
|
||||
position: relative;
|
||||
padding: 8px 24px;
|
||||
}
|
||||
.box span {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.box:after {
|
||||
border-radius: 50px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: white;
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
transition: all 0.3s ease-in-out;
|
||||
border-radius: 50px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: white;
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.showbox {
|
||||
color: black;
|
||||
color: black;
|
||||
}
|
||||
.showbox:after {
|
||||
opacity: 1;
|
||||
background: white;
|
||||
transition: all 0.3s ease-in-out;
|
||||
opacity: 1;
|
||||
background: white;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
.table1 {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table1 thead {
|
||||
background-color: #0F1524;
|
||||
height: 44px;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #28344F;
|
||||
background-color: #0f1524;
|
||||
height: 44px;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #28344f;
|
||||
}
|
||||
.table1 thead th, .table1 tbody td {
|
||||
text-align: left;
|
||||
padding: 14px 24px;
|
||||
.table1 thead th,
|
||||
.table1 tbody td {
|
||||
text-align: left;
|
||||
padding: 14px 24px;
|
||||
}
|
||||
|
||||
.table1 tbody td {
|
||||
padding: 16px 24px;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
padding: 16px 24px;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.swal2-modal {
|
||||
background-color: black !important;
|
||||
border: 2px solid #0B101B;
|
||||
background-color: black !important;
|
||||
border: 2px solid #0b101b;
|
||||
}
|
||||
|
||||
.swal2-modal * {
|
||||
color: white !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.swal2-icon {
|
||||
color: white !important;
|
||||
border-color: white !important;
|
||||
color: white !important;
|
||||
border-color: white !important;
|
||||
}
|
||||
|
||||
.swal2-confirm {
|
||||
background-color: #262373 !important;
|
||||
}
|
||||
background-color: #262373 !important;
|
||||
}
|
||||
|
||||
.w-md-editor-text {
|
||||
min-height: 100% !important;
|
||||
}
|
||||
|
||||
.react-tags {
|
||||
position: relative;
|
||||
border: 1px solid #28344f;
|
||||
padding-left: 16px;
|
||||
height: 44px;
|
||||
border-radius: 4px;
|
||||
background: #131b2c;
|
||||
/* shared font styles */
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
/* clicking anywhere will focus the input */
|
||||
cursor: text;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.react-tags.is-active {
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
.react-tags.is-disabled {
|
||||
opacity: 0.75;
|
||||
background-color: #eaeef2;
|
||||
/* Prevent any clicking on the component */
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.react-tags.is-invalid {
|
||||
border-color: #fd5956;
|
||||
box-shadow: 0 0 0 2px rgba(253, 86, 83, 0.25);
|
||||
}
|
||||
|
||||
.react-tags__label {
|
||||
position: absolute;
|
||||
left: -10000px;
|
||||
top: auto;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.react-tags__list {
|
||||
/* Do not use display: contents, it's too buggy */
|
||||
display: inline;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.react-tags__list-item {
|
||||
display: inline;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.react-tags__tag {
|
||||
margin: 0 0.25rem 0 0;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: #7236f1;
|
||||
/* match the font styles */
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.react-tags__tag:hover {
|
||||
color: #ffffff;
|
||||
background-color: #4f46e5;
|
||||
}
|
||||
|
||||
.react-tags__tag::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
clip-path: polygon(
|
||||
10% 0,
|
||||
0 10%,
|
||||
40% 50%,
|
||||
0 90%,
|
||||
10% 100%,
|
||||
50% 60%,
|
||||
90% 100%,
|
||||
100% 90%,
|
||||
60% 50%,
|
||||
100% 10%,
|
||||
90% 0,
|
||||
50% 40%
|
||||
);
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background-color: #7c7d86;
|
||||
}
|
||||
|
||||
.react-tags__tag:hover::after {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.react-tags__combobox {
|
||||
display: inline-block;
|
||||
/* match tag layout */
|
||||
/* prevents autoresize overflowing the container */
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.react-tags__combobox-input {
|
||||
/* prevent autoresize overflowing the container */
|
||||
max-width: 100%;
|
||||
/* remove styles and layout from this element */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: none;
|
||||
/* match the font styles */
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.react-tags__combobox-input::placeholder {
|
||||
color: #7c7d86;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.react-tags__listbox {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: calc(100% + 5px);
|
||||
/* Negate the border width on the container */
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
max-height: 12.5rem;
|
||||
overflow-y: auto;
|
||||
background: #131b2c;
|
||||
border: 1px solid #afb8c1;
|
||||
border-radius: 6px;
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -4px,
|
||||
rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
.react-tags__listbox-option {
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
|
||||
.react-tags__listbox-option:hover {
|
||||
cursor: pointer;
|
||||
background: #080b13;
|
||||
}
|
||||
|
||||
.react-tags__listbox-option:not([aria-disabled='true']).is-active {
|
||||
background: #4f46e5;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.react-tags__listbox-option[aria-disabled='true'] {
|
||||
color: #7c7d86;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.react-tags__listbox-option[aria-selected='true']::after {
|
||||
content: '✓';
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.react-tags__listbox-option[aria-selected='true']:not(.is-active)::after {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.react-tags__listbox-option-highlight {
|
||||
background-color: #ffdd00;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import './global.css';
|
||||
import 'react-tooltip/dist/react-tooltip.css'
|
||||
|
||||
import LayoutContext from "@gitroom/frontend/components/layout/layout.context";
|
||||
import {ReactNode} from "react";
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
useMoveToIntegration,
|
||||
useMoveToIntegrationListener,
|
||||
} from '@gitroom/frontend/components/launches/helpers/use.move.to.integration';
|
||||
import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
|
||||
|
||||
export const PickPlatforms: FC<{
|
||||
integrations: Integrations[];
|
||||
|
|
@ -157,7 +158,7 @@ export const PickPlatforms: FC<{
|
|||
|
||||
export const PreviewComponent: FC<{
|
||||
integrations: Integrations[];
|
||||
editorValue: string[];
|
||||
editorValue: Array<{ id?: string; content: string }>;
|
||||
}> = (props) => {
|
||||
const { integrations, editorValue } = props;
|
||||
const [selectedIntegrations, setSelectedIntegrations] = useState([
|
||||
|
|
@ -201,7 +202,9 @@ export const AddEditModal: FC<{
|
|||
>([]);
|
||||
|
||||
// value of each editor
|
||||
const [value, setValue] = useState<string[]>(['']);
|
||||
const [value, setValue] = useState<Array<{ content: string; id?: string }>>([
|
||||
{ content: '' },
|
||||
]);
|
||||
|
||||
const fetch = useFetch();
|
||||
|
||||
|
|
@ -217,6 +220,18 @@ export const AddEditModal: FC<{
|
|||
// hook to open a new modal
|
||||
const modal = useModals();
|
||||
|
||||
// are we in edit mode?
|
||||
const existingData = useExistingData();
|
||||
|
||||
// if it's edit just set the current integration
|
||||
useEffect(() => {
|
||||
if (existingData.integration) {
|
||||
setSelectedIntegrations([
|
||||
integrations.find((p) => p.id === existingData.integration)!,
|
||||
]);
|
||||
}
|
||||
}, [existingData.integration]);
|
||||
|
||||
// if the user exit the popup we reset the global variable with all the values
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -228,7 +243,7 @@ export const AddEditModal: FC<{
|
|||
const changeValue = useCallback(
|
||||
(index: number) => (newValue: string) => {
|
||||
return setValue((prev) => {
|
||||
prev[index] = newValue;
|
||||
prev[index].content = newValue;
|
||||
return [...prev];
|
||||
});
|
||||
},
|
||||
|
|
@ -239,7 +254,7 @@ export const AddEditModal: FC<{
|
|||
const addValue = useCallback(
|
||||
(index: number) => () => {
|
||||
setValue((prev) => {
|
||||
prev.splice(index + 1, 0, '');
|
||||
prev.splice(index + 1, 0, { content: '' });
|
||||
return [...prev];
|
||||
});
|
||||
},
|
||||
|
|
@ -307,20 +322,22 @@ export const AddEditModal: FC<{
|
|||
</svg>
|
||||
</button>
|
||||
<div className="flex flex-col gap-[20px]">
|
||||
<PickPlatforms
|
||||
integrations={integrations}
|
||||
selectedIntegrations={[]}
|
||||
singleSelect={false}
|
||||
onChange={setSelectedIntegrations}
|
||||
/>
|
||||
{!showHide.hideTopEditor ? (
|
||||
{!existingData.integration && (
|
||||
<PickPlatforms
|
||||
integrations={integrations}
|
||||
selectedIntegrations={[]}
|
||||
singleSelect={false}
|
||||
onChange={setSelectedIntegrations}
|
||||
/>
|
||||
)}
|
||||
{!existingData.integration && !showHide.hideTopEditor ? (
|
||||
<>
|
||||
{value.map((p, index) => (
|
||||
<>
|
||||
<MDEditor
|
||||
key={`edit_${index}`}
|
||||
height={value.length > 1 ? 150 : 500}
|
||||
value={p}
|
||||
value={p.content}
|
||||
preview="edit"
|
||||
// @ts-ignore
|
||||
onChange={changeValue(index)}
|
||||
|
|
@ -332,9 +349,11 @@ export const AddEditModal: FC<{
|
|||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-[100px] flex justify-center items-center bg-sixth border-tableBorder border-2">
|
||||
Global Editor Hidden
|
||||
</div>
|
||||
!existingData.integration && (
|
||||
<div className="h-[100px] flex justify-center items-center bg-sixth border-tableBorder border-2">
|
||||
Global Editor Hidden
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!!selectedIntegrations.length && (
|
||||
<PreviewComponent
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
'use client';
|
||||
import "reflect-metadata";
|
||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
|
||||
import {createContext, FC, ReactNode, useContext, useState} from 'react';
|
||||
import {createContext, FC, ReactNode, useCallback, useContext, useEffect, useMemo, useState} from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import useSWR from "swr";
|
||||
import {useFetch} from "@gitroom/helpers/utils/custom.fetch";
|
||||
import {Post, Integration} from '@prisma/client';
|
||||
|
||||
dayjs.extend(weekOfYear);
|
||||
dayjs.extend(isoWeek);
|
||||
|
|
@ -13,7 +17,9 @@ dayjs.extend(utc);
|
|||
const CalendarContext = createContext({
|
||||
currentWeek: dayjs().week(),
|
||||
integrations: [] as Integrations[],
|
||||
setFilters: (filters: { currentWeek: number }) => {},
|
||||
posts: [] as Array<Post & {integration: Integration}>,
|
||||
setFilters: (filters: { currentWeek: number, currentYear: number }) => {},
|
||||
changeDate: (id: string, date: dayjs.Dayjs) => {},
|
||||
});
|
||||
|
||||
export interface Integrations {
|
||||
|
|
@ -27,11 +33,44 @@ export const CalendarWeekProvider: FC<{ children: ReactNode, integrations: Integ
|
|||
children,
|
||||
integrations
|
||||
}) => {
|
||||
const fetch = useFetch();
|
||||
const [internalData, setInternalData] = useState([] as any[]);
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
currentWeek: dayjs().week(),
|
||||
currentYear: dayjs().year(),
|
||||
});
|
||||
|
||||
const params = useMemo(() => {
|
||||
return new URLSearchParams({
|
||||
week: filters.currentWeek.toString(),
|
||||
year: filters.currentYear.toString(),
|
||||
}).toString();
|
||||
}, [filters]);
|
||||
|
||||
const loadData = useCallback(async(url: string) => {
|
||||
return (await fetch(url)).json();
|
||||
}, [filters]);
|
||||
|
||||
const {data, isLoading} = useSWR(`/posts?${params}`, loadData);
|
||||
|
||||
const changeDate = useCallback((id: string, date: dayjs.Dayjs) => {
|
||||
setInternalData(d => d.map((post: Post) => {
|
||||
if (post.id === id) {
|
||||
return {...post, publishDate: date.format('YYYY-MM-DDTHH:mm:ss')};
|
||||
}
|
||||
return post;
|
||||
}));
|
||||
}, [data, internalData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setInternalData(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<CalendarContext.Provider value={{ ...filters, integrations, setFilters }}>
|
||||
<CalendarContext.Provider value={{ ...filters, posts: isLoading ? [] : internalData, integrations, setFilters, changeDate }}>
|
||||
{children}
|
||||
</CalendarContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,18 @@
|
|||
|
||||
import { FC, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Integrations,
|
||||
useCalendar,
|
||||
} from '@gitroom/frontend/components/launches/calendar.context';
|
||||
import dayjs from 'dayjs';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import {AddEditModal} from "@gitroom/frontend/components/launches/add.edit.model";
|
||||
import clsx from "clsx";
|
||||
import { AddEditModal } from '@gitroom/frontend/components/launches/add.edit.model';
|
||||
import clsx from 'clsx';
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
import { ExistingDataContextProvider } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { DNDProvider } from '@gitroom/frontend/components/launches/helpers/dnd.provider';
|
||||
import { Integration, Post } from '@prisma/client';
|
||||
|
||||
const days = [
|
||||
'',
|
||||
|
|
@ -46,44 +52,179 @@ const hours = [
|
|||
'23:00',
|
||||
];
|
||||
|
||||
const CalendarItem: FC<{
|
||||
date: dayjs.Dayjs;
|
||||
editPost: () => void;
|
||||
integrations: Integrations[];
|
||||
post: Post & { integration: Integration };
|
||||
}> = (props) => {
|
||||
const { editPost, post, date, integrations } = props;
|
||||
const [{ opacity }, dragRef] = useDrag(
|
||||
() => ({
|
||||
type: 'post',
|
||||
item: { id: post.id, date },
|
||||
collect: (monitor) => ({
|
||||
opacity: monitor.isDragging() ? 0 : 1,
|
||||
}),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={dragRef}
|
||||
onClick={editPost}
|
||||
className="relative"
|
||||
data-tooltip-id="tooltip"
|
||||
style={{ opacity }}
|
||||
data-tooltip-content={`${
|
||||
integrations.find(
|
||||
(p) => p.identifier === post.integration?.providerIdentifier
|
||||
)?.name
|
||||
}: ${post.content.slice(0, 100)}`}
|
||||
>
|
||||
<img
|
||||
className="w-[20px] h-[20px] rounded-full"
|
||||
src={
|
||||
integrations.find(
|
||||
(p) => p.identifier === post.integration?.providerIdentifier
|
||||
)?.picture!
|
||||
}
|
||||
/>
|
||||
<img
|
||||
className="w-[12px] h-[12px] rounded-full absolute z-10 bottom-[0] right-0 border border-fifth"
|
||||
src={`/icons/platforms/${post.integration?.providerIdentifier}.png`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CalendarColumn: FC<{ day: number; hour: string }> = (props) => {
|
||||
const { day, hour } = props;
|
||||
const week = useCalendar();
|
||||
const { currentWeek, integrations, posts, changeDate } = useCalendar();
|
||||
const modal = useModals();
|
||||
const fetch = useFetch();
|
||||
|
||||
const getDate = useMemo(() => {
|
||||
const date =
|
||||
dayjs().isoWeek(week.currentWeek).isoWeekday(day).format('YYYY-MM-DD') +
|
||||
dayjs().isoWeek(currentWeek).isoWeekday(day).format('YYYY-MM-DD') +
|
||||
'T' +
|
||||
hour +
|
||||
':00';
|
||||
return dayjs(date);
|
||||
}, [week.currentWeek]);
|
||||
}, [currentWeek]);
|
||||
|
||||
const postList = useMemo(() => {
|
||||
return posts.filter((post) => {
|
||||
return dayjs(post.publishDate).local().isSame(getDate);
|
||||
});
|
||||
}, [posts]);
|
||||
|
||||
const isBeforeNow = useMemo(() => {
|
||||
return getDate.isBefore(dayjs());
|
||||
}, [getDate]);
|
||||
|
||||
const [{ canDrop }, drop] = useDrop(() => ({
|
||||
accept: 'post',
|
||||
drop: (item: any) => {
|
||||
if (isBeforeNow) return;
|
||||
fetch(`/posts/${item.id}/date`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ date: getDate.utc().format('YYYY-MM-DDTHH:mm:ss') }),
|
||||
});
|
||||
changeDate(item.id, getDate);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
canDrop: isBeforeNow ? false : !!monitor.canDrop() && !!monitor.isOver(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const editPost = useCallback(
|
||||
(id: string) => async () => {
|
||||
const data = await (await fetch(`/posts/${id}`)).json();
|
||||
|
||||
modal.openModal({
|
||||
closeOnClickOutside: false,
|
||||
closeOnEscape: false,
|
||||
withCloseButton: false,
|
||||
children: (
|
||||
<ExistingDataContextProvider value={data}>
|
||||
<AddEditModal
|
||||
integrations={integrations.filter(
|
||||
(f) => f.id === data.integration
|
||||
)}
|
||||
date={getDate}
|
||||
/>
|
||||
</ExistingDataContextProvider>
|
||||
),
|
||||
size: '80%',
|
||||
title: `Edit post for ${getDate.format('DD/MM/YYYY HH:mm')}`,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const addModal = useCallback(() => {
|
||||
modal.openModal({
|
||||
closeOnClickOutside: false,
|
||||
closeOnEscape: false,
|
||||
withCloseButton: false,
|
||||
children: (
|
||||
<AddEditModal integrations={week.integrations} date={getDate} />
|
||||
),
|
||||
children: <AddEditModal integrations={integrations} date={getDate} />,
|
||||
size: '80%',
|
||||
title: `Adding posts for ${getDate.format('DD/MM/YYYY HH:mm')}`,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isBeforeNow = useMemo(() => {
|
||||
return getDate.isBefore(dayjs());
|
||||
}, [getDate]);
|
||||
|
||||
return (
|
||||
<div className={clsx("h-[calc(216px/6)] text-[12px] hover:bg-white/20 pointer flex justify-center items-center", isBeforeNow && 'bg-white/10 pointer-events-none')}>
|
||||
<div
|
||||
onClick={addModal}
|
||||
className="flex-1 h-full flex justify-center items-center"
|
||||
>
|
||||
{isBeforeNow ? '' : '+ Add'}
|
||||
<div className="relative w-full h-full">
|
||||
<div className="absolute left-0 top-0 w-full h-full">
|
||||
<div
|
||||
ref={drop}
|
||||
className={clsx(
|
||||
'h-[calc(216px/6)] text-[12px] pointer w-full overflow-hidden justify-center overflow-x-auto flex scrollbar scrollbar-thumb-tableBorder scrollbar-track-secondary',
|
||||
isBeforeNow && 'bg-secondary',
|
||||
canDrop && 'bg-white/80'
|
||||
)}
|
||||
>
|
||||
{postList.map((post) => (
|
||||
<div
|
||||
key={post.id}
|
||||
className={clsx(
|
||||
postList.length > 1 && 'w-[33px] basis-[28px]',
|
||||
'h-full text-white relative flex justify-center items-center flex-grow-0 flex-shrink-0'
|
||||
)}
|
||||
>
|
||||
<div className="relative flex gap-[5px] items-center">
|
||||
<CalendarItem
|
||||
date={getDate}
|
||||
editPost={editPost(post.id)}
|
||||
post={post}
|
||||
integrations={integrations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!isBeforeNow && (
|
||||
<div
|
||||
className={clsx(
|
||||
!postList.length ? 'justify-center flex-1' : 'ml-[2px]',
|
||||
'flex items-center cursor-pointer'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-tooltip-id="tooltip"
|
||||
data-tooltip-content={
|
||||
'Schedule for ' + getDate.format('DD/MM/YYYY HH:mm')
|
||||
}
|
||||
onClick={addModal}
|
||||
className={clsx(
|
||||
'w-[20px] h-[20px] bg-forth rounded-full flex justify-center items-center hover:bg-seventh'
|
||||
)}
|
||||
>
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -91,51 +232,53 @@ const CalendarColumn: FC<{ day: number; hour: string }> = (props) => {
|
|||
|
||||
export const Calendar = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-8 text-center border-tableBorder border-r">
|
||||
{days.map((day) => (
|
||||
<div
|
||||
className="border-tableBorder border-l border-b h-[36px] border-t flex items-center justify-center bg-input text-[14px] sticky top-0"
|
||||
key={day}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{hours.map((hour) =>
|
||||
days.map((day, index) => (
|
||||
<>
|
||||
{index === 0 ? (
|
||||
<div
|
||||
className="border-tableBorder border-l border-b h-[216px]"
|
||||
key={day + hour}
|
||||
>
|
||||
{['00', '10', '20', '30', '40', '50'].map((num) => (
|
||||
<div
|
||||
key={day + hour + num}
|
||||
className="h-[calc(216px/6)] text-[12px] flex justify-center items-center"
|
||||
>
|
||||
{hour.split(':')[0] + ':' + num}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="border-tableBorder border-l border-b h-[216px] flex flex-col"
|
||||
key={day + hour}
|
||||
>
|
||||
{['00', '10', '20', '30', '40', '50'].map((num) => (
|
||||
<CalendarColumn
|
||||
key={day + hour + num}
|
||||
day={index}
|
||||
hour={hour.split(':')[0] + ':' + num}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))
|
||||
)}
|
||||
<DNDProvider>
|
||||
<div>
|
||||
<div className="grid grid-cols-8 text-center border-tableBorder border-r">
|
||||
{days.map((day) => (
|
||||
<div
|
||||
className="border-tableBorder border-l border-b h-[36px] border-t flex items-center justify-center bg-input text-[14px] sticky top-0 z-[100]"
|
||||
key={day}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{hours.map((hour) =>
|
||||
days.map((day, index) => (
|
||||
<>
|
||||
{index === 0 ? (
|
||||
<div
|
||||
className="border-tableBorder border-l border-b h-[216px]"
|
||||
key={day + hour}
|
||||
>
|
||||
{['00', '10', '20', '30', '40', '50'].map((num) => (
|
||||
<div
|
||||
key={day + hour + num}
|
||||
className="h-[calc(216px/6)] text-[12px] flex justify-center items-center"
|
||||
>
|
||||
{hour.split(':')[0] + ':' + num}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="border-tableBorder border-l border-b h-[216px] flex flex-col"
|
||||
key={day + hour}
|
||||
>
|
||||
{['00', '10', '20', '30', '40', '50'].map((num) => (
|
||||
<CalendarColumn
|
||||
key={day + hour + num}
|
||||
day={index}
|
||||
hour={hour.split(':')[0] + ':' + num}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DNDProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import {FC, ReactNode} from "react";
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
import { DndProvider } from 'react-dnd'
|
||||
|
||||
export const DNDProvider: FC<{children: ReactNode}> = ({children}) => {
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
{children}
|
||||
</DndProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
|
||||
import { useCallback } from 'react';
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
|
||||
export const useCustomProviderFunction = () => {
|
||||
const { integration } = useIntegration();
|
||||
const fetch = useFetch();
|
||||
const get = useCallback(
|
||||
async (funcName: string, customData?: string) => {
|
||||
return (
|
||||
await fetch('/integrations/function', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: funcName,
|
||||
id: integration?.id!,
|
||||
data: customData,
|
||||
}),
|
||||
})
|
||||
).json();
|
||||
},
|
||||
[integration]
|
||||
);
|
||||
|
||||
return { get };
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import {createContext, FC, ReactNode, useContext} from "react";
|
||||
import {Post} from "@prisma/client";
|
||||
|
||||
const ExistingDataContext = createContext({
|
||||
integration: '',
|
||||
posts: [] as Post[],
|
||||
settings: {} as any
|
||||
});
|
||||
|
||||
|
||||
export const ExistingDataContextProvider: FC<{children: ReactNode, value: any}> = ({children, value}) => {
|
||||
return (
|
||||
<ExistingDataContext.Provider value={value}>
|
||||
{children}
|
||||
</ExistingDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export const useExistingData = () => useContext(ExistingDataContext);
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
import removeMd from "remove-markdown";
|
||||
import {useMemo} from "react";
|
||||
|
||||
export const useFormatting = (text: string[], params: {
|
||||
export const useFormatting = (text: Array<{content: string, id?: string}>, params: {
|
||||
removeMarkdown?: boolean,
|
||||
saveBreaklines?: boolean,
|
||||
specialFunc?: (text: string) => string,
|
||||
}) => {
|
||||
return useMemo(() => {
|
||||
return text.map((value) => {
|
||||
let newText = value;
|
||||
let newText = value.content;
|
||||
if (params.saveBreaklines) {
|
||||
newText = newText.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢');
|
||||
}
|
||||
if (params.removeMarkdown) {
|
||||
newText = removeMd(value);
|
||||
newText = removeMd(value.content);
|
||||
}
|
||||
if (params.saveBreaklines) {
|
||||
newText = newText.replace('𝔫𝔢𝔴𝔩𝔦𝔫𝔢', '\n');
|
||||
|
|
@ -22,6 +22,7 @@ export const useFormatting = (text: string[], params: {
|
|||
newText = params.specialFunc(newText);
|
||||
}
|
||||
return {
|
||||
id: value.id,
|
||||
text: newText,
|
||||
count: params.removeMarkdown && params.saveBreaklines ? newText.replace(/\n/g, ' ').length : newText.length,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,6 @@
|
|||
import {createContext, useContext} from "react";
|
||||
import {Integrations} from "@gitroom/frontend/components/launches/calendar.context";
|
||||
|
||||
export const IntegrationContext = createContext<{integration: Integrations|undefined, value: string[]}>({integration: undefined, value: []});
|
||||
export const IntegrationContext = createContext<{integration: Integrations|undefined, value: Array<{content: string, id?: string}>}>({integration: undefined, value: []});
|
||||
|
||||
export const useIntegration = () => useContext(IntegrationContext);
|
||||
|
|
@ -1,25 +1,24 @@
|
|||
import {useEffect, useMemo} from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { UseFormProps } from 'react-hook-form/dist/types';
|
||||
import {allProvidersSettings} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/all.providers.settings";
|
||||
import {useForm, useFormContext} from 'react-hook-form';
|
||||
import {classValidatorResolver} from "@hookform/resolvers/class-validator";
|
||||
|
||||
const finalInformation = {} as {
|
||||
[key: string]: { posts: string[]; settings: () => object; isValid: boolean };
|
||||
[key: string]: { posts: Array<{id?: string, content: string, media?: Array<string>}>; settings: () => object; isValid: boolean };
|
||||
};
|
||||
export const useValues = (identifier: string, integration: string, value: string[]) => {
|
||||
export const useValues = (initialValues: object, integration: string, identifier: string, value: Array<{id?: string, content: string, media?: Array<string>}>, dto: any) => {
|
||||
const resolver = useMemo(() => {
|
||||
const findValidator = allProvidersSettings.find((provider) => provider.identifier === identifier)!;
|
||||
return classValidatorResolver(findValidator?.validator);
|
||||
return classValidatorResolver(dto);
|
||||
}, [integration]);
|
||||
|
||||
const form = useForm({
|
||||
resolver
|
||||
resolver,
|
||||
values: initialValues,
|
||||
mode: 'onChange'
|
||||
});
|
||||
|
||||
const getValues = useMemo(() => {
|
||||
return form.getValues;
|
||||
}, [form]);
|
||||
return () => ({...form.getValues(), __type: identifier});
|
||||
}, [form, integration]);
|
||||
|
||||
finalInformation[integration]= finalInformation[integration] || {};
|
||||
finalInformation[integration].posts = value;
|
||||
|
|
@ -35,20 +34,7 @@ export const useValues = (identifier: string, integration: string, value: string
|
|||
return form;
|
||||
};
|
||||
|
||||
export const useSettings = (formProps?: Omit<UseFormProps, 'mode'>) => {
|
||||
// const { integration } = useIntegration();
|
||||
// const form = useForm({
|
||||
// ...formProps,
|
||||
// mode: 'onChange',
|
||||
// });
|
||||
//
|
||||
// finalInformation[integration?.identifier!].settings = {
|
||||
// __type: integration?.identifier!,
|
||||
// ...form.getValues(),
|
||||
// };
|
||||
// return form;
|
||||
};
|
||||
|
||||
export const useSettings = () => useFormContext();
|
||||
export const getValues = () => finalInformation;
|
||||
export const resetValues = () => {
|
||||
Object.keys(finalInformation).forEach((key) => {
|
||||
|
|
|
|||
|
|
@ -10,16 +10,18 @@ export const LaunchesComponent: FC<{
|
|||
integrations: Integrations[]
|
||||
}> = (props) => {
|
||||
const { integrations } = props;
|
||||
|
||||
const sortedIntegrations = useMemo(() => {
|
||||
return orderBy(integrations, ['type', 'identifier'], ['desc', 'asc']);
|
||||
}, [integrations]);
|
||||
|
||||
return (
|
||||
<CalendarWeekProvider integrations={sortedIntegrations}>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Filters />
|
||||
<div className="flex flex-1 relative">
|
||||
<div className="absolute w-full h-full flex flex-1 gap-[30px] overflow-hidden overflow-y-scroll scrollbar scrollbar-thumb-tableBorder scrollbar-track-secondary">
|
||||
<div className="w-[330px] bg-third p-[16px] flex flex-col gap-[24px] sticky top-0">
|
||||
<div className="w-[220px] bg-third p-[16px] flex flex-col gap-[24px] sticky top-0">
|
||||
<h2 className="text-[20px]">Channels</h2>
|
||||
<div className="gap-[16px] flex flex-col">
|
||||
{sortedIntegrations.map((integration) => (
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
import {FC} from "react";
|
||||
import {withProvider} from "@gitroom/frontend/components/launches/providers/high.order.provider";
|
||||
|
||||
const DevtoPreview: FC = () => {
|
||||
return <div>asd</div>
|
||||
};
|
||||
|
||||
const DevtoSettings: FC = () => {
|
||||
return <div>asdfasd</div>
|
||||
};
|
||||
|
||||
export default withProvider(DevtoSettings, DevtoPreview);
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import {FC} from "react";
|
||||
import {withProvider} from "@gitroom/frontend/components/launches/providers/high.order.provider";
|
||||
import {DevToSettingsDto} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto";
|
||||
import {useSettings} from "@gitroom/frontend/components/launches/helpers/use.values";
|
||||
import {Input} from "@gitroom/react/form/input";
|
||||
import {MediaComponent} from "@gitroom/frontend/components/media/media.component";
|
||||
import {SelectOrganization} from "@gitroom/frontend/components/launches/providers/devto/select.organization";
|
||||
import {DevtoTags} from "@gitroom/frontend/components/launches/providers/devto/devto.tags";
|
||||
|
||||
const DevtoPreview: FC = () => {
|
||||
return <div>asd</div>
|
||||
};
|
||||
|
||||
const DevtoSettings: FC = () => {
|
||||
const form = useSettings();
|
||||
return (
|
||||
<>
|
||||
<Input label="Title" {...form.register('title')} />
|
||||
<Input label="Canonical Link" {...form.register('canonical')} />
|
||||
<MediaComponent label="Cover picture" description="Add a cover picture" {...form.register('main_image')} />
|
||||
<div className="mt-[20px]">
|
||||
<SelectOrganization {...form.register('organization') } />
|
||||
</div>
|
||||
<div>
|
||||
<DevtoTags label="Tags (Maximum 4)" {...form.register('tags')} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
export default withProvider(DevtoSettings, DevtoPreview, DevToSettingsDto);
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
|
||||
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
|
||||
import { ReactTags } from 'react-tag-autocomplete';
|
||||
|
||||
export const DevtoTags: FC<{
|
||||
name: string;
|
||||
label: string;
|
||||
onChange: (event: { target: { value: any[]; name: string } }) => void;
|
||||
}> = (props) => {
|
||||
const { onChange, name, label } = props;
|
||||
const customFunc = useCustomProviderFunction();
|
||||
const [tags, setTags] = useState<any[]>([]);
|
||||
const { getValues } = useSettings();
|
||||
const [tagValue, setTagValue] = useState<any[]>([]);
|
||||
|
||||
const onDelete = useCallback(
|
||||
(tagIndex: number) => {
|
||||
const modify = tagValue.filter((_, i) => i !== tagIndex);
|
||||
setTagValue(modify);
|
||||
onChange({ target: { value: modify, name } });
|
||||
},
|
||||
[tagValue]
|
||||
);
|
||||
|
||||
const onAddition = useCallback(
|
||||
(newTag: any) => {
|
||||
if (tagValue.length >= 4) {
|
||||
return;
|
||||
}
|
||||
const modify = [...tagValue, newTag];
|
||||
setTagValue(modify);
|
||||
onChange({ target: { value: modify, name } });
|
||||
},
|
||||
[tagValue]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
customFunc.get('tags').then((data) => setTags(data));
|
||||
const settings = getValues()[props.name];
|
||||
if (settings) {
|
||||
setTagValue(settings);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!tags.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="font-['Inter'] text-[14px] mb-[6px]">{label}</div>
|
||||
<ReactTags
|
||||
suggestions={tags}
|
||||
selected={tagValue}
|
||||
onAdd={onAddition}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
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 SelectOrganization: FC<{
|
||||
name: string;
|
||||
onChange: (event: { target: { value: string; name: string } }) => void;
|
||||
}> = (props) => {
|
||||
const { onChange, name } = props;
|
||||
const customFunc = useCustomProviderFunction();
|
||||
const [orgs, setOrgs] = useState([]);
|
||||
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('organizations').then((data) => setOrgs(data));
|
||||
const settings = getValues()[props.name];
|
||||
if (settings) {
|
||||
setCurrentMedia(settings);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
if (!orgs.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select name={name} label="Select organization" onChange={onChangeInner} value={currentMedia}>
|
||||
<option value="">--Select--</option>
|
||||
{orgs.map((org: any) => (
|
||||
<option key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,6 +8,11 @@ import { useHideTopEditor } from '@gitroom/frontend/components/launches/helpers/
|
|||
import { useValues } from '@gitroom/frontend/components/launches/helpers/use.values';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
import { useMoveToIntegrationListener } from '@gitroom/frontend/components/launches/helpers/use.move.to.integration';
|
||||
import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
|
||||
import {
|
||||
IntegrationContext,
|
||||
useIntegration,
|
||||
} from '@gitroom/frontend/components/launches/helpers/use.integration';
|
||||
|
||||
// This is a simple function that if we edit in place, we hide the editor on top
|
||||
export const EditorWrapper: FC = (props) => {
|
||||
|
|
@ -22,16 +27,28 @@ export const EditorWrapper: FC = (props) => {
|
|||
return null;
|
||||
};
|
||||
|
||||
export const withProvider = (SettingsComponent: FC, PreviewComponent: FC) => {
|
||||
export const withProvider = (
|
||||
SettingsComponent: FC,
|
||||
PreviewComponent: FC,
|
||||
dto?: any
|
||||
) => {
|
||||
return (props: {
|
||||
identifier: string;
|
||||
id: string;
|
||||
value: string[];
|
||||
value: Array<{ content: string; id?: string }>;
|
||||
show: boolean;
|
||||
}) => {
|
||||
const [editInPlace, setEditInPlace] = useState(false);
|
||||
const [InPlaceValue, setInPlaceValue] = useState(['']);
|
||||
const [showTab, setShowTab] = useState(0);
|
||||
const existingData = useExistingData();
|
||||
const { integration } = useIntegration();
|
||||
const [editInPlace, setEditInPlace] = useState(!!existingData.integration);
|
||||
const [InPlaceValue, setInPlaceValue] = useState<
|
||||
Array<{ id?: string; content: string }>
|
||||
>(
|
||||
existingData.integration
|
||||
? existingData.posts.map((p) => ({ id: p.id, content: p.content }))
|
||||
: [{ content: '' }]
|
||||
);
|
||||
const [showTab, setShowTab] = useState(existingData.integration ? 1 : 0);
|
||||
|
||||
// in case there is an error on submit, we change to the settings tab for the specific provider
|
||||
useMoveToIntegrationListener(true, (identifier) => {
|
||||
|
|
@ -42,16 +59,18 @@ export const withProvider = (SettingsComponent: FC, PreviewComponent: FC) => {
|
|||
|
||||
// this is a smart function, it updates the global value without updating the states (too heavy) and set the settings validation
|
||||
const form = useValues(
|
||||
props.identifier,
|
||||
existingData.settings,
|
||||
props.id,
|
||||
editInPlace ? InPlaceValue : props.value
|
||||
props.identifier,
|
||||
editInPlace ? InPlaceValue : props.value,
|
||||
dto
|
||||
);
|
||||
|
||||
// change editor value
|
||||
const changeValue = useCallback(
|
||||
(index: number) => (newValue: string) => {
|
||||
return setInPlaceValue((prev) => {
|
||||
prev[index] = newValue;
|
||||
prev[index].content = newValue;
|
||||
return [...prev];
|
||||
});
|
||||
},
|
||||
|
|
@ -62,7 +81,7 @@ export const withProvider = (SettingsComponent: FC, PreviewComponent: FC) => {
|
|||
const addValue = useCallback(
|
||||
(index: number) => () => {
|
||||
setInPlaceValue((prev) => {
|
||||
prev.splice(index + 1, 0, '');
|
||||
prev.splice(index + 1, 0, { content: '' });
|
||||
return [...prev];
|
||||
});
|
||||
},
|
||||
|
|
@ -85,12 +104,15 @@ export const withProvider = (SettingsComponent: FC, PreviewComponent: FC) => {
|
|||
setShowTab(editor ? 1 : 0);
|
||||
if (editor && !editInPlace) {
|
||||
setEditInPlace(true);
|
||||
setInPlaceValue(props.value);
|
||||
setInPlaceValue(
|
||||
props.value.map((p) => ({ id: p.id, content: p.content }))
|
||||
);
|
||||
}
|
||||
},
|
||||
[props.value, editInPlace]
|
||||
);
|
||||
|
||||
// this is a trick to prevent the data from being deleted, yet we don't render the elements
|
||||
if (!props.show) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -123,7 +145,7 @@ export const withProvider = (SettingsComponent: FC, PreviewComponent: FC) => {
|
|||
<MDEditor
|
||||
key={`edit_inner_${index}`}
|
||||
height={InPlaceValue.length > 1 ? 200 : 500}
|
||||
value={val}
|
||||
value={val.content}
|
||||
preview="edit"
|
||||
// @ts-ignore
|
||||
onChange={changeValue(index)}
|
||||
|
|
@ -135,8 +157,21 @@ export const withProvider = (SettingsComponent: FC, PreviewComponent: FC) => {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{showTab === 2 && <SettingsComponent />}
|
||||
{showTab === 0 && <PreviewComponent />}
|
||||
{showTab === 2 && (
|
||||
<div className="mt-[20px]">
|
||||
<SettingsComponent />
|
||||
</div>
|
||||
)}
|
||||
{showTab === 0 && (
|
||||
<IntegrationContext.Provider
|
||||
value={{
|
||||
value: editInPlace ? InPlaceValue : props.value,
|
||||
integration,
|
||||
}}
|
||||
>
|
||||
<PreviewComponent />
|
||||
</IntegrationContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
</FormProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,42 +1,21 @@
|
|||
import { FC } from 'react';
|
||||
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
|
||||
import localFont from 'next/font/local';
|
||||
import clsx from 'clsx';
|
||||
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
|
||||
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
|
||||
|
||||
const chirp = localFont({
|
||||
src: [
|
||||
{
|
||||
path: './fonts/x/Chirp-Regular.woff2',
|
||||
weight: '400',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './fonts/x/Chirp-Bold.woff2',
|
||||
weight: '700',
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const LinkedinPreview: FC = (props) => {
|
||||
const { value: topValue, integration } = useIntegration();
|
||||
const newValues = useFormatting(topValue, {
|
||||
removeMarkdown: true,
|
||||
saveBreaklines: true,
|
||||
specialFunc: (text: string) => {
|
||||
return text.slice(0, 280);
|
||||
}
|
||||
return text.slice(0, 280);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'max-w-[598px] px-[16px] border border-[#2E3336]',
|
||||
chirp.className
|
||||
)}
|
||||
>
|
||||
<div className={clsx('max-w-[598px] px-[16px] border border-[#2E3336]')}>
|
||||
<div className="w-full h-full relative flex flex-col pt-[12px]">
|
||||
{newValues.map((value, index) => (
|
||||
<div
|
||||
|
|
@ -77,7 +56,7 @@ const LinkedinPreview: FC = (props) => {
|
|||
@username
|
||||
</div>
|
||||
</div>
|
||||
<pre className={chirp.className}>{value.text}</pre>
|
||||
<pre>{value.text}</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -1,25 +1,9 @@
|
|||
import { FC } from 'react';
|
||||
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
|
||||
import localFont from 'next/font/local';
|
||||
import clsx from 'clsx';
|
||||
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
|
||||
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
|
||||
|
||||
const chirp = localFont({
|
||||
src: [
|
||||
{
|
||||
path: './fonts/x/Chirp-Regular.woff2',
|
||||
weight: '400',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './fonts/x/Chirp-Bold.woff2',
|
||||
weight: '700',
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const RedditPreview: FC = (props) => {
|
||||
const { value: topValue, integration } = useIntegration();
|
||||
const newValues = useFormatting(topValue, {
|
||||
|
|
@ -34,7 +18,6 @@ const RedditPreview: FC = (props) => {
|
|||
<div
|
||||
className={clsx(
|
||||
'max-w-[598px] px-[16px] border border-[#2E3336]',
|
||||
chirp.className
|
||||
)}
|
||||
>
|
||||
<div className="w-full h-full relative flex flex-col pt-[12px]">
|
||||
|
|
@ -77,7 +60,7 @@ const RedditPreview: FC = (props) => {
|
|||
@username
|
||||
</div>
|
||||
</div>
|
||||
<pre className={chirp.className}>{value.text}</pre>
|
||||
<pre>{value.text}</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import {FC} from "react";
|
||||
import {Integrations} from "@gitroom/frontend/components/launches/calendar.context";
|
||||
import DevtoProvider from "@gitroom/frontend/components/launches/providers/devto.provider";
|
||||
import XProvider from "@gitroom/frontend/components/launches/providers/x.provider";
|
||||
import LinkedinProvider from "@gitroom/frontend/components/launches/providers/linkedin.provider";
|
||||
import RedditProvider from "@gitroom/frontend/components/launches/providers/reddit.provider";
|
||||
import DevtoProvider from "@gitroom/frontend/components/launches/providers/devto/devto.provider";
|
||||
import XProvider from "@gitroom/frontend/components/launches/providers/x/x.provider";
|
||||
import LinkedinProvider from "@gitroom/frontend/components/launches/providers/linkedin/linkedin.provider";
|
||||
import RedditProvider from "@gitroom/frontend/components/launches/providers/reddit/reddit.provider";
|
||||
|
||||
const Providers = [
|
||||
{identifier: 'devto', component: DevtoProvider},
|
||||
|
|
@ -12,7 +12,7 @@ const Providers = [
|
|||
{identifier: 'reddit', component: RedditProvider},
|
||||
];
|
||||
|
||||
export const ShowAllProviders: FC<{integrations: Integrations[], value: string[], selectedProvider?: Integrations}> = (props) => {
|
||||
export const ShowAllProviders: FC<{integrations: Integrations[], value: Array<{content: string, id?: string}>, selectedProvider?: Integrations}> = (props) => {
|
||||
const {integrations, value, selectedProvider} = props;
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ function LayoutContextInner(params: {children: ReactNode}) {
|
|||
baseUrl={process.env.NEXT_PUBLIC_BACKEND_URL!}
|
||||
afterRequest={afterRequest}
|
||||
>
|
||||
|
||||
{params?.children || <></>}
|
||||
</FetchWrapperComponent>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import {ContextWrapper} from "@gitroom/frontend/components/layout/user.context";
|
|||
import {NotificationComponent} from "@gitroom/frontend/components/notifications/notification.component";
|
||||
import {TopMenu} from "@gitroom/frontend/components/layout/top.menu";
|
||||
import {MantineWrapper} from "@gitroom/react/helpers/mantine.wrapper";
|
||||
import {ToolTip} from "@gitroom/frontend/components/layout/top.tip";
|
||||
|
||||
export const LayoutSettings = ({children}: {children: ReactNode}) => {
|
||||
const user = JSON.parse(headers().get('user')!);
|
||||
return (
|
||||
<ContextWrapper user={user}>
|
||||
<MantineWrapper>
|
||||
<ToolTip />
|
||||
<div className="min-h-[100vh] w-full max-w-[1440px] mx-auto bg-primary px-[12px] text-white flex flex-col">
|
||||
<div className="px-[23px] flex h-[80px] items-center justify-between z-[200] sticky top-0 bg-primary">
|
||||
<div className="text-2xl">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
|
||||
export const ToolTip = () => {
|
||||
return <Tooltip className="z-[200]" id="tooltip" />;
|
||||
};
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
'use client';
|
||||
|
||||
import { ChangeEvent, FC, useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import useSWR from 'swr';
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
import { Media } from '@prisma/client';
|
||||
import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory';
|
||||
import {useFormState} from "react-hook-form";
|
||||
import {useSettings} from "@gitroom/frontend/components/launches/helpers/use.values";
|
||||
|
||||
export const MediaBox: FC<{
|
||||
setMedia: (params: { id: string; path: string }) => void;
|
||||
closeModal: () => void;
|
||||
}> = (props) => {
|
||||
const { setMedia, closeModal } = props;
|
||||
const [pages, setPages] = useState(0);
|
||||
const [mediaList, setListMedia] = useState<Media[]>([]);
|
||||
const fetch = useFetch();
|
||||
const mediaDirectory = useMediaDirectory();
|
||||
|
||||
const loadMedia = useCallback(async () => {
|
||||
return (await fetch('/media')).json();
|
||||
}, []);
|
||||
|
||||
const uploadMedia = useCallback(
|
||||
async (file: ChangeEvent<HTMLInputElement>) => {
|
||||
const maxFileSize = 10 * 1024 * 1024;
|
||||
if (
|
||||
!file?.target?.files?.length ||
|
||||
file?.target?.files?.[0]?.size > maxFileSize
|
||||
)
|
||||
return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file?.target?.files?.[0]);
|
||||
const data = await (
|
||||
await fetch('/media', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
).json();
|
||||
|
||||
console.log(data);
|
||||
setListMedia([...mediaList, data]);
|
||||
},
|
||||
[mediaList]
|
||||
);
|
||||
|
||||
const setNewMedia = useCallback(
|
||||
(media: Media) => () => {
|
||||
setMedia(media);
|
||||
closeModal();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const { data } = useSWR('get-media', loadMedia);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.pages) {
|
||||
setPages(data.pages);
|
||||
}
|
||||
if (data?.results && data?.results?.length) {
|
||||
setListMedia([...data.results]);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 top-0 bg-black/80 z-[300] w-full h-full p-[60px] animate-fade">
|
||||
<div className="w-full h-full bg-black border-tableBorder border-2 rounded-xl p-[20px] relative">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-black hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path
|
||||
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="flex absolute right-[40px] top-[7px] pointer hover:bg-third rounded-lg transition-all group px-2.5 py-2.5 text-sm font-semibold bg-transparent text-gray-800 hover:bg-gray-100 focus:text-primary-500"
|
||||
type="button"
|
||||
>
|
||||
<div className="relative flex gap-2 items-center justify-center">
|
||||
<input
|
||||
type="file"
|
||||
className="absolute left-0 top-0 w-full h-full opacity-0"
|
||||
accept="image/*"
|
||||
onChange={uploadMedia}
|
||||
/>
|
||||
<span className="sc-dhKdcB fhJPPc w-4 h-4">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.5276 1.00176C7.3957 0.979897 8.25623 1.16248 9.04309 1.53435C9.82982 1.90617 10.5209 2.45677 11.065 3.14199C11.3604 3.51404 11.6084 3.92054 11.8045 4.3516C12.2831 4.21796 12.7853 4.17281 13.2872 4.22273C14.2108 4.3146 15.0731 4.72233 15.7374 5.3744C16.4012 6.02599 16.8292 6.88362 16.9586 7.808C17.088 8.73224 16.9124 9.67586 16.457 10.4887C16.1871 10.9706 15.5777 11.1424 15.0958 10.8724C14.614 10.6025 14.4422 9.99308 14.7122 9.51126C14.9525 9.08224 15.0471 8.57971 14.9779 8.08532C14.9087 7.59107 14.6807 7.13971 14.3364 6.8017C13.9925 6.46418 13.5528 6.25903 13.0892 6.21291C12.6258 6.16682 12.1584 6.28157 11.7613 6.5429C11.4874 6.7232 11.1424 6.7577 10.8382 6.63524C10.534 6.51278 10.3091 6.24893 10.2365 5.92912C10.1075 5.36148 9.8545 4.83374 9.49872 4.38568C9.14303 3.93773 8.69439 3.58166 8.18851 3.34258C7.68275 3.10355 7.13199 2.98717 6.57794 3.00112C6.02388 3.01507 5.47902 3.15905 4.98477 3.4235C4.49039 3.68801 4.05875 4.06664 3.72443 4.53247C3.39004 4.9984 3.16233 5.5387 3.06049 6.11239C2.95864 6.68613 2.98571 7.27626 3.1394 7.83712C3.29306 8.39792 3.56876 8.91296 3.94345 9.34361C4.30596 9.76027 4.26207 10.3919 3.84542 10.7544C3.42876 11.1169 2.79712 11.073 2.4346 10.6564C1.8607 9.99678 1.44268 9.213 1.2105 8.36566C0.978333 7.51837 0.937639 6.62828 1.09128 5.76282C1.24492 4.89732 1.58919 4.07751 2.09958 3.36634C2.61005 2.65507 3.27363 2.07075 4.04125 1.66005C4.80899 1.24927 5.65951 1.02361 6.5276 1.00176Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
<path
|
||||
d="M8 12.4142L8 17C8 17.5523 8.44771 18 9 18C9.55228 18 10 17.5523 10 17V12.4142L11.2929 13.7071C11.6834 14.0976 12.3166 14.0976 12.7071 13.7071C13.0976 13.3166 13.0976 12.6834 12.7071 12.2929L9.70711 9.29289C9.61123 9.19702 9.50073 9.12468 9.38278 9.07588C9.26488 9.02699 9.13559 9 9 9C8.86441 9 8.73512 9.02699 8.61722 9.07588C8.50195 9.12357 8.3938 9.19374 8.29945 9.2864C8.29705 9.28875 8.29467 9.29111 8.2923 9.29349L5.29289 12.2929C4.90237 12.6834 4.90237 13.3166 5.29289 13.7071C5.68342 14.0976 6.31658 14.0976 6.70711 13.7071L8 12.4142Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span>Upload assets</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-wrap gap-[10px] mt-[35px] pt-[20px] border-tableBorder border-t-2">
|
||||
{mediaList.map((media) => (
|
||||
<div
|
||||
key={media.id}
|
||||
className="w-[200px] h-[200px] border-tableBorder border-2 cursor-pointer"
|
||||
onClick={setNewMedia(media)}
|
||||
>
|
||||
<img
|
||||
className="w-full h-full object-cover"
|
||||
src={mediaDirectory.set(media.path)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const MediaComponent: FC<{
|
||||
label: string;
|
||||
description: string;
|
||||
value?: { path: string; id: string };
|
||||
name: string;
|
||||
onChange: (event: {
|
||||
target: { name: string; value?: { id: string; path: string } };
|
||||
}) => void;
|
||||
}> = (props) => {
|
||||
const { name, label, description, onChange, value } = props;
|
||||
const {getValues} = useSettings();
|
||||
useEffect(() => {
|
||||
const settings = getValues()[props.name];
|
||||
if (settings) {
|
||||
setCurrentMedia(settings);
|
||||
}
|
||||
}, []);
|
||||
const [modal, setShowModal] = useState(false);
|
||||
const [currentMedia, setCurrentMedia] = useState(value);
|
||||
const mediaDirectory = useMediaDirectory();
|
||||
|
||||
const changeMedia = useCallback((m: { path: string; id: string }) => {
|
||||
setCurrentMedia(m);
|
||||
onChange({ target: { name, value: m } });
|
||||
}, []);
|
||||
|
||||
const showModal = useCallback(() => {
|
||||
setShowModal(!modal);
|
||||
}, [modal]);
|
||||
|
||||
const clearMedia = useCallback(() => {
|
||||
setCurrentMedia(undefined);
|
||||
onChange({ target: { name, value: undefined } });
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[8px]">
|
||||
{modal && <MediaBox setMedia={changeMedia} closeModal={showModal} />}
|
||||
<div className="text-[14px]">{label}</div>
|
||||
<div className="text-[12px]">{description}</div>
|
||||
{!!currentMedia && (
|
||||
<div className="my-[20px] cursor-pointer w-[200px] h-[200px] border-2 border-tableBorder">
|
||||
<img
|
||||
className="w-full h-full object-cover"
|
||||
src={mediaDirectory.set(currentMedia.path)}
|
||||
onClick={() => window.open(mediaDirectory.set(currentMedia.path))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex">
|
||||
<Button onClick={showModal}>Select</Button>
|
||||
<Button secondary={true} onClick={clearMedia}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -34,11 +34,21 @@ export async function middleware(request: NextRequest) {
|
|||
}
|
||||
|
||||
try {
|
||||
const user = await (await fetchBackend('/user/self', {
|
||||
const userResponse = await fetchBackend('/user/self', {
|
||||
headers: {
|
||||
auth: authCookie?.value!
|
||||
}
|
||||
})).json();
|
||||
});
|
||||
|
||||
if (userResponse.status === 401) {
|
||||
return NextResponse.redirect(new URL('/auth/logout', nextUrl.href));
|
||||
}
|
||||
|
||||
if ([200, 201].indexOf(userResponse.status) === -1) {
|
||||
return NextResponse.redirect(new URL('/err', nextUrl.href));
|
||||
}
|
||||
|
||||
const user = await userResponse.json();
|
||||
|
||||
const next = NextResponse.next();
|
||||
next.headers.set('user', JSON.stringify(user));
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ module.exports = {
|
|||
forth: '#612AD5',
|
||||
fifth: '#28344F',
|
||||
sixth: '#0B101B',
|
||||
seventh: '#7236f1',
|
||||
gray: '#8C8C8C',
|
||||
input: '#131B2C',
|
||||
inputText: '#64748B',
|
||||
|
|
@ -30,7 +31,18 @@ module.exports = {
|
|||
},
|
||||
gridTemplateColumns: {
|
||||
'13': 'repeat(13, minmax(0, 1fr));'
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
fade: 'fadeOut 0.5s ease-in-out',
|
||||
},
|
||||
|
||||
// that is actual animation
|
||||
keyframes: theme => ({
|
||||
fadeOut: {
|
||||
'0%': { opacity: 0, transform: 'translateY(30px)' },
|
||||
'100%': { opacity: 1, transform: 'translateY(0)' },
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
|
|
|||
|
|
@ -1,28 +1,32 @@
|
|||
export interface Params {
|
||||
baseUrl: string,
|
||||
beforeRequest?: (url: string, options: RequestInit) => Promise<RequestInit>,
|
||||
afterRequest?: (url: string, options: RequestInit, response: Response) => Promise<void>
|
||||
baseUrl: string;
|
||||
beforeRequest?: (url: string, options: RequestInit) => Promise<RequestInit>;
|
||||
afterRequest?: (
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
response: Response
|
||||
) => Promise<void>;
|
||||
}
|
||||
export const customFetch = (params: Params, auth?: string) => {
|
||||
return async function newFetch (url: string, options: RequestInit = {}) {
|
||||
const newRequestObject = await params?.beforeRequest?.(url, options);
|
||||
const fetchRequest = await fetch(params.baseUrl + url, {
|
||||
credentials: 'include',
|
||||
...(
|
||||
newRequestObject || options
|
||||
),
|
||||
headers: {
|
||||
...options?.headers,
|
||||
...auth ? {auth} : {},
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
await params?.afterRequest?.(url, options, fetchRequest);
|
||||
return fetchRequest;
|
||||
}
|
||||
}
|
||||
return async function newFetch(url: string, options: RequestInit = {}) {
|
||||
const newRequestObject = await params?.beforeRequest?.(url, options);
|
||||
const fetchRequest = await fetch(params.baseUrl + url, {
|
||||
credentials: 'include',
|
||||
...(newRequestObject || options),
|
||||
headers: {
|
||||
...(auth ? { auth } : {}),
|
||||
...(options.body instanceof FormData
|
||||
? {}
|
||||
: { 'Content-Type': 'application/json' }),
|
||||
Accept: 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
await params?.afterRequest?.(url, options, fetchRequest);
|
||||
return fetchRequest;
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchBackend = customFetch({
|
||||
baseUrl: process.env.NEXT_PUBLIC_BACKEND_URL!
|
||||
baseUrl: process.env.NEXT_PUBLIC_BACKEND_URL!,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export class BullMqClient extends ClientProxy {
|
|||
return () => void 0;
|
||||
}
|
||||
|
||||
async delay(pattern: string, jobId: string, delay: number) {
|
||||
delay(pattern: string, jobId: string, delay: number) {
|
||||
const queue = this.getQueue(pattern);
|
||||
return queue.getJob(jobId).then((job) => job?.changeDelay(delay));
|
||||
}
|
||||
|
|
@ -84,6 +84,11 @@ export class BullMqClient extends ClientProxy {
|
|||
return queue.getJob(jobId).then((job) => job?.remove());
|
||||
}
|
||||
|
||||
job(pattern: string, jobId: string) {
|
||||
const queue = this.getQueue(pattern);
|
||||
return queue.getJob(jobId);
|
||||
}
|
||||
|
||||
protected async dispatchEvent(
|
||||
packet: ReadPacket<IBullMqEvent<any>>,
|
||||
): Promise<any> {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {IntegrationRepository} from "@gitroom/nestjs-libraries/database/prisma/i
|
|||
import {PostsService} from "@gitroom/nestjs-libraries/database/prisma/posts/posts.service";
|
||||
import {PostsRepository} from "@gitroom/nestjs-libraries/database/prisma/posts/posts.repository";
|
||||
import {IntegrationManager} from "@gitroom/nestjs-libraries/integrations/integration.manager";
|
||||
import {MediaService} from "@gitroom/nestjs-libraries/database/prisma/media/media.service";
|
||||
import {MediaRepository} from "@gitroom/nestjs-libraries/database/prisma/media/media.repository";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
|
|
@ -35,6 +37,8 @@ import {IntegrationManager} from "@gitroom/nestjs-libraries/integrations/integra
|
|||
IntegrationRepository,
|
||||
PostsService,
|
||||
PostsRepository,
|
||||
MediaService,
|
||||
MediaRepository,
|
||||
IntegrationManager
|
||||
],
|
||||
get exports() {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,15 @@ export class IntegrationRepository {
|
|||
})
|
||||
}
|
||||
|
||||
getIntegrationById(org: string, id: string) {
|
||||
return this._integration.model.integration.findFirst({
|
||||
where: {
|
||||
organizationId: org,
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getIntegrationsList(org: string) {
|
||||
return this._integration.model.integration.findMany({
|
||||
where: {
|
||||
|
|
|
|||
|
|
@ -14,4 +14,8 @@ export class IntegrationService {
|
|||
getIntegrationsList(org: string) {
|
||||
return this._integrationRepository.getIntegrationsList(org);
|
||||
}
|
||||
|
||||
getIntegrationById(org: string, id: string) {
|
||||
return this._integrationRepository.getIntegrationById(org, id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class MediaRepository {
|
||||
constructor(private _media: PrismaRepository<'media'>) {}
|
||||
|
||||
saveFile(org: string, fileName: string, filePath: string) {
|
||||
return this._media.model.media.create({
|
||||
data: {
|
||||
organization: {
|
||||
connect: {
|
||||
id: org,
|
||||
},
|
||||
},
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getMedia(org: string, page: number) {
|
||||
const pageNum = (page || 1) - 1;
|
||||
const query = {
|
||||
where: {
|
||||
organization: {
|
||||
id: org,
|
||||
},
|
||||
},
|
||||
};
|
||||
const pages =
|
||||
pageNum === 0
|
||||
? Math.ceil((await this._media.model.media.count(query)) / 10)
|
||||
: 0;
|
||||
const results = await this._media.model.media.findMany({
|
||||
where: {
|
||||
organization: {
|
||||
id: org,
|
||||
},
|
||||
},
|
||||
skip: pageNum * 10,
|
||||
take: 10,
|
||||
});
|
||||
|
||||
return {
|
||||
pages,
|
||||
results,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import {Injectable} from "@nestjs/common";
|
||||
import {MediaRepository} from "@gitroom/nestjs-libraries/database/prisma/media/media.repository";
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
constructor(
|
||||
private _mediaRepository: MediaRepository
|
||||
){}
|
||||
|
||||
saveFile(org: string, fileName: string, filePath: string) {
|
||||
return this._mediaRepository.saveFile(org, fileName, filePath);
|
||||
}
|
||||
|
||||
getMedia(org: string, page: number) {
|
||||
return this._mediaRepository.getMedia(org, page);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,56 @@
|
|||
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Post as PostBody } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto';
|
||||
import dayjs from 'dayjs';
|
||||
import { Integration, Post } from '@prisma/client';
|
||||
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
|
||||
import dayjs from 'dayjs';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {instanceToInstance, instanceToPlain} from "class-transformer";
|
||||
import {validate} from "class-validator";
|
||||
|
||||
dayjs.extend(isoWeek);
|
||||
|
||||
@Injectable()
|
||||
export class PostsRepository {
|
||||
constructor(private _post: PrismaRepository<'post'>) {}
|
||||
|
||||
getPost(id: string, includeIntegration = false) {
|
||||
getPosts(orgId: string, query: GetPostsDto) {
|
||||
const date = dayjs().year(query.year).isoWeek(query.week);
|
||||
|
||||
const startDate = date.startOf('isoWeek').toDate();
|
||||
const endDate = date.endOf('isoWeek').toDate();
|
||||
|
||||
return this._post.model.post.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
publishDate: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
parentPostId: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
publishDate: true,
|
||||
releaseURL: true,
|
||||
state: true,
|
||||
integration: {
|
||||
select: {
|
||||
id: true,
|
||||
providerIdentifier: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getPost(id: string, includeIntegration = false, orgId?: string) {
|
||||
return this._post.model.post.findUnique({
|
||||
where: {
|
||||
id,
|
||||
...(orgId ? { organizationId: orgId } : {}),
|
||||
},
|
||||
include: {
|
||||
...(includeIntegration ? { integration: true } : {}),
|
||||
|
|
@ -33,35 +72,56 @@ export class PostsRepository {
|
|||
});
|
||||
}
|
||||
|
||||
async createPost(orgId: string, date: string, body: PostBody) {
|
||||
async changeDate(orgId: string, id: string, date: string) {
|
||||
return this._post.model.post.update({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
publishDate: dayjs(date).toDate(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createOrUpdatePost(orgId: string, date: string, body: PostBody) {
|
||||
const posts: Post[] = [];
|
||||
|
||||
for (const value of body.value) {
|
||||
posts.push(
|
||||
await this._post.model.post.create({
|
||||
data: {
|
||||
publishDate: dayjs(date).toDate(),
|
||||
integration: {
|
||||
connect: {
|
||||
id: body.integration.id,
|
||||
organizationId: orgId,
|
||||
},
|
||||
},
|
||||
...(posts.length
|
||||
? {
|
||||
parentPost: {
|
||||
connect: {
|
||||
id: posts[posts.length - 1]?.id,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
content: value,
|
||||
organization: {
|
||||
connect: {
|
||||
id: orgId,
|
||||
},
|
||||
},
|
||||
const updateData = {
|
||||
publishDate: dayjs(date).toDate(),
|
||||
integration: {
|
||||
connect: {
|
||||
id: body.integration.id,
|
||||
organizationId: orgId,
|
||||
},
|
||||
},
|
||||
...(posts.length
|
||||
? {
|
||||
parentPost: {
|
||||
connect: {
|
||||
id: posts[posts.length - 1]?.id,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
content: value.content,
|
||||
settings: JSON.stringify(body.settings),
|
||||
organization: {
|
||||
connect: {
|
||||
id: orgId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
posts.push(
|
||||
await this._post.model.post.upsert({
|
||||
where: {
|
||||
id: value.id || uuidv4()
|
||||
},
|
||||
create: updateData,
|
||||
update: updateData,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport/client
|
|||
import dayjs from 'dayjs';
|
||||
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
|
||||
import { Integration, Post } from '@prisma/client';
|
||||
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
|
||||
|
||||
type PostWithConditionals = Post & {
|
||||
integration?: Integration;
|
||||
|
|
@ -21,17 +22,35 @@ export class PostsService {
|
|||
|
||||
async getPostsRecursively(
|
||||
id: string,
|
||||
includeIntegration = false
|
||||
includeIntegration = false,
|
||||
orgId?: string
|
||||
): Promise<PostWithConditionals[]> {
|
||||
const post = await this._postRepository.getPost(id, includeIntegration);
|
||||
const post = await this._postRepository.getPost(
|
||||
id,
|
||||
includeIntegration,
|
||||
orgId
|
||||
);
|
||||
return [
|
||||
post!,
|
||||
...(post?.childrenPost?.length
|
||||
? await this.getPostsRecursively(post.childrenPost[0].id)
|
||||
? await this.getPostsRecursively(post.childrenPost[0].id, false, orgId)
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
getPosts(orgId: string, query: GetPostsDto) {
|
||||
return this._postRepository.getPosts(orgId, query);
|
||||
}
|
||||
|
||||
async getPost(orgId: string, id: string) {
|
||||
const posts = await this.getPostsRecursively(id, false, orgId);
|
||||
return {
|
||||
posts,
|
||||
integration: posts[0].integrationId,
|
||||
settings: JSON.parse(posts[0].settings || '{}'),
|
||||
};
|
||||
}
|
||||
|
||||
async post(id: string) {
|
||||
const [firstPost, ...morePosts] = await this.getPostsRecursively(id, true);
|
||||
if (!firstPost) {
|
||||
|
|
@ -39,49 +58,71 @@ export class PostsService {
|
|||
}
|
||||
|
||||
if (firstPost.integration?.type === 'article') {
|
||||
return this.postArticle(firstPost.integration!, [firstPost, ...morePosts]);
|
||||
return this.postArticle(firstPost.integration!, [
|
||||
firstPost,
|
||||
...morePosts,
|
||||
]);
|
||||
}
|
||||
|
||||
return this.postSocial(firstPost.integration!, [firstPost, ...morePosts]);
|
||||
}
|
||||
|
||||
private async postSocial(integration: Integration, posts: Post[]) {
|
||||
const getIntegration = this._integrationManager.getSocialIntegration(integration.providerIdentifier);
|
||||
if (!getIntegration) {
|
||||
return;
|
||||
}
|
||||
const getIntegration = this._integrationManager.getSocialIntegration(
|
||||
integration.providerIdentifier
|
||||
);
|
||||
if (!getIntegration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const publishedPosts = await getIntegration.post(integration.internalId, integration.token, posts.map(p => ({
|
||||
const publishedPosts = await getIntegration.post(
|
||||
integration.internalId,
|
||||
integration.token,
|
||||
posts.map((p) => ({
|
||||
id: p.id,
|
||||
message: p.content,
|
||||
settings: JSON.parse(p.settings || '{}'),
|
||||
})));
|
||||
}))
|
||||
);
|
||||
|
||||
for (const post of publishedPosts) {
|
||||
await this._postRepository.updatePost(post.id, post.postId, post.releaseURL);
|
||||
}
|
||||
for (const post of publishedPosts) {
|
||||
await this._postRepository.updatePost(
|
||||
post.id,
|
||||
post.postId,
|
||||
post.releaseURL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async postArticle(integration: Integration, posts: Post[]) {
|
||||
const getIntegration = this._integrationManager.getArticlesIntegration(integration.providerIdentifier);
|
||||
if (!getIntegration) {
|
||||
return;
|
||||
}
|
||||
const {postId, releaseURL} = await getIntegration.post(integration.token, posts.map(p => p.content).join('\n\n'), JSON.parse(posts[0].settings || '{}'));
|
||||
await this._postRepository.updatePost(posts[0].id, postId, releaseURL);
|
||||
const getIntegration = this._integrationManager.getArticlesIntegration(
|
||||
integration.providerIdentifier
|
||||
);
|
||||
if (!getIntegration) {
|
||||
return;
|
||||
}
|
||||
const { postId, releaseURL } = await getIntegration.post(
|
||||
integration.token,
|
||||
posts.map((p) => p.content).join('\n\n'),
|
||||
JSON.parse(posts[0].settings || '{}')
|
||||
);
|
||||
await this._postRepository.updatePost(posts[0].id, postId, releaseURL);
|
||||
}
|
||||
|
||||
async createPost(orgId: string, body: CreatePostDto) {
|
||||
for (const post of body.posts) {
|
||||
const posts = await this._postRepository.createPost(
|
||||
const posts = await this._postRepository.createOrUpdatePost(
|
||||
orgId,
|
||||
body.date,
|
||||
post
|
||||
);
|
||||
|
||||
await this._workerServiceProducer.delete('post', posts[0].id);
|
||||
|
||||
this._workerServiceProducer.emit('post', {
|
||||
id: posts[0].id,
|
||||
options: {
|
||||
delay: 0 // dayjs(posts[0].publishDate).diff(dayjs(), 'millisecond'),
|
||||
delay: dayjs(posts[0].publishDate).diff(dayjs(), 'millisecond'),
|
||||
},
|
||||
payload: {
|
||||
id: posts[0].id,
|
||||
|
|
@ -89,4 +130,18 @@ export class PostsService {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
async changeDate(orgId: string, id: string, date: string) {
|
||||
await this._workerServiceProducer.delete('post', id);
|
||||
this._workerServiceProducer.emit('post', {
|
||||
id: id,
|
||||
options: {
|
||||
delay: dayjs(date).diff(dayjs(), 'millisecond'),
|
||||
},
|
||||
payload: {
|
||||
id: id,
|
||||
},
|
||||
});
|
||||
return this._postRepository.changeDate(orgId, id, date);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ model Star {
|
|||
model Media {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
url String
|
||||
path String
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
organizationId String
|
||||
posts PostMedia[]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import {IsDefined, IsString} from "class-validator";
|
||||
|
||||
export class IntegrationFunctionDto {
|
||||
@IsString()
|
||||
@IsDefined()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsDefined()
|
||||
id: string;
|
||||
|
||||
data: any;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import {IsDefined, IsString} from "class-validator";
|
||||
|
||||
export class MediaDto {
|
||||
@IsString()
|
||||
@IsDefined()
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@IsDefined()
|
||||
path: string;
|
||||
}
|
||||
|
|
@ -1,12 +1,24 @@
|
|||
import {ArrayMinSize, IsArray, IsDateString, IsDefined, IsString, ValidateNested} from "class-validator";
|
||||
import {Type} from "class-transformer";
|
||||
import {DevToSettingsDto} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto";
|
||||
import {
|
||||
ArrayMinSize, IsArray, IsDateString, IsDefined, IsOptional, IsString, ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto';
|
||||
|
||||
export class EmptySettings {}
|
||||
export class Integration {
|
||||
@IsDefined()
|
||||
@IsString()
|
||||
id: string
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class PostContent {
|
||||
@IsDefined()
|
||||
@IsString()
|
||||
content: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class Post {
|
||||
|
|
@ -18,19 +30,18 @@ export class Post {
|
|||
@IsDefined()
|
||||
@ArrayMinSize(1)
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
value: string[];
|
||||
@Type(() => PostContent)
|
||||
@ValidateNested({ each: true })
|
||||
value: PostContent[];
|
||||
|
||||
@Type(() => EmptySettings, {
|
||||
keepDiscriminatorProperty: true,
|
||||
keepDiscriminatorProperty: false,
|
||||
discriminator: {
|
||||
property: '__type',
|
||||
subTypes: [
|
||||
{ value: DevToSettingsDto, name: 'devto' },
|
||||
],
|
||||
subTypes: [{ value: DevToSettingsDto, name: 'devto' }],
|
||||
},
|
||||
})
|
||||
settings: DevToSettingsDto
|
||||
settings: DevToSettingsDto;
|
||||
}
|
||||
|
||||
export class CreatePostDto {
|
||||
|
|
@ -41,7 +52,7 @@ export class CreatePostDto {
|
|||
@IsDefined()
|
||||
@Type(() => Post)
|
||||
@IsArray()
|
||||
@ValidateNested({each: true})
|
||||
@ValidateNested({ each: true })
|
||||
@ArrayMinSize(1)
|
||||
posts: Post[]
|
||||
posts: Post[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import {Type} from "class-transformer";
|
||||
import {IsNumber, Max, Min} from "class-validator";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export class GetPostsDto {
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Max(52)
|
||||
@Min(1)
|
||||
week: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Max(dayjs().add(10, 'year').year())
|
||||
@Min(2022)
|
||||
year: number;
|
||||
}
|
||||
|
|
@ -1,7 +1,2 @@
|
|||
import {DevToSettingsDto} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto";
|
||||
export const allProvidersSettings = [{
|
||||
identifier: 'devto',
|
||||
validator: DevToSettingsDto
|
||||
}];
|
||||
|
||||
export type AllProvidersSettings = DevToSettingsDto;
|
||||
|
|
@ -1,24 +1,32 @@
|
|||
import {IsArray, IsDefined, IsOptional, IsString} from "class-validator";
|
||||
import {ArrayMaxSize, IsArray, IsDefined, IsOptional, IsString, Matches, MinLength, ValidateNested} from "class-validator";
|
||||
import {MediaDto} from "@gitroom/nestjs-libraries/dtos/media/media.dto";
|
||||
import {Type} from "class-transformer";
|
||||
import {DevToTagsSettings} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.tags.settings";
|
||||
|
||||
export class DevToSettingsDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@IsDefined()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
main_image?: number;
|
||||
@ValidateNested()
|
||||
@Type(() => MediaDto)
|
||||
main_image?: MediaDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
canonical: string;
|
||||
|
||||
@IsString({
|
||||
each: true
|
||||
@Matches(/^(|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/, {
|
||||
message: 'Invalid URL'
|
||||
})
|
||||
@IsArray()
|
||||
tags: string[];
|
||||
canonical?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
organization?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMaxSize(4)
|
||||
@IsOptional()
|
||||
tags: DevToTagsSettings[];
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import {IsNumber, IsString} from "class-validator";
|
||||
|
||||
export class DevToTagsSettings {
|
||||
@IsNumber()
|
||||
value: number;
|
||||
|
||||
@IsString()
|
||||
label: string;
|
||||
}
|
||||
|
|
@ -1,27 +1,99 @@
|
|||
import {ArticleProvider} from "@gitroom/nestjs-libraries/integrations/article/article.integrations.interface";
|
||||
import { ArticleProvider } from '@gitroom/nestjs-libraries/integrations/article/article.integrations.interface';
|
||||
import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto';
|
||||
|
||||
export class DevToProvider implements ArticleProvider {
|
||||
identifier = 'devto';
|
||||
name = 'Dev.to';
|
||||
async authenticate(token: string) {
|
||||
const {name, id, profile_image} = await (await fetch('https://dev.to/api/users/me', {
|
||||
identifier = 'devto';
|
||||
name = 'Dev.to';
|
||||
async authenticate(token: string) {
|
||||
const { name, id, profile_image } = await (
|
||||
await fetch('https://dev.to/api/users/me', {
|
||||
headers: {
|
||||
'api-key': token,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
token,
|
||||
picture: profile_image,
|
||||
};
|
||||
}
|
||||
|
||||
async tags(token: string) {
|
||||
const tags = await (
|
||||
await fetch('https://dev.to/api/tags?per_page=1000&page=1', {
|
||||
headers: {
|
||||
'api-key': token,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
return tags.map((p: any) => ({ value: p.id, label: p.name }));
|
||||
}
|
||||
|
||||
async organizations(token: string) {
|
||||
const orgs = await (
|
||||
await fetch('https://dev.to/api/articles/me/all?per_page=1000', {
|
||||
headers: {
|
||||
'api-key': token,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
const allOrgs: string[] = [
|
||||
...new Set(
|
||||
orgs
|
||||
.flatMap((org: any) => org?.organization?.username)
|
||||
.filter((f: string) => f)
|
||||
),
|
||||
] as string[];
|
||||
const fullDetails = await Promise.all(
|
||||
allOrgs.map(async (org: string) => {
|
||||
return (
|
||||
await fetch(`https://dev.to/api/organizations/${org}`, {
|
||||
headers: {
|
||||
'api-key': token
|
||||
}
|
||||
})).json();
|
||||
'api-key': token,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
token,
|
||||
picture: profile_image
|
||||
}
|
||||
}
|
||||
return fullDetails.map((org: any) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
username: org.username,
|
||||
}));
|
||||
}
|
||||
|
||||
async post(token: string, content: string, settings: object) {
|
||||
return {
|
||||
postId: '123',
|
||||
releaseURL: 'https://dev.to'
|
||||
}
|
||||
}
|
||||
}
|
||||
async post(token: string, content: string, settings: DevToSettingsDto) {
|
||||
const { id, url } = await (
|
||||
await fetch(`https://dev.to/api/articles`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
article: {
|
||||
title: settings.title,
|
||||
body_markdown: content,
|
||||
published: false,
|
||||
main_image: settings?.main_image?.path
|
||||
? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY}${settings?.main_image?.path}`
|
||||
: undefined,
|
||||
tags: settings?.tags?.map((t) => t.label),
|
||||
organization_id: settings.organization,
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': token,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
return {
|
||||
postId: String(id),
|
||||
releaseURL: url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export class LinkedinProvider implements SocialProvider {
|
|||
}&redirect_uri=${encodeURIComponent(
|
||||
`${process.env.FRONTEND_URL}/integrations/social/linkedin`
|
||||
)}&state=${state}&scope=${encodeURIComponent(
|
||||
'openid profile w_member_social r_liteprofile'
|
||||
'openid profile w_member_social'
|
||||
)}`;
|
||||
return {
|
||||
url,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export class XProvider implements SocialProvider {
|
|||
accessToken: string,
|
||||
postDetails: PostDetails[],
|
||||
): Promise<PostResponse[]> {
|
||||
console.log('hello');
|
||||
const client = new TwitterApi(accessToken);
|
||||
const {data: {username}} = await client.v2.me({
|
||||
"user.fields": "username"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import {MulterModule} from "@nestjs/platform-express";
|
||||
import {diskStorage} from "multer";
|
||||
import {mkdirSync} from 'fs';
|
||||
import {extname} from 'path';
|
||||
|
||||
const storage = diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0'); // Month is zero-based, hence +1
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
|
||||
const dir = `${process.env.UPLOAD_DIRECTORY}/${year}/${month}/${day}`;
|
||||
|
||||
// Create the directory if it doesn't exist
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Generate a unique filename here if needed
|
||||
const randomName = Array(32).fill(null).map(() => (Math.round(Math.random() * 16)).toString(16)).join('');
|
||||
cb(null, `${randomName}${extname(file.originalname)}`);
|
||||
},
|
||||
});
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
MulterModule.register({
|
||||
storage
|
||||
}),
|
||||
],
|
||||
get exports() {
|
||||
return this.imports;
|
||||
},
|
||||
})
|
||||
export class UploadModule {}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import {DetailedHTMLProps, FC, SelectHTMLAttributes, useMemo} from "react";
|
||||
import {clsx} from "clsx";
|
||||
import {useFormContext} from "react-hook-form";
|
||||
|
||||
export const Select: FC<DetailedHTMLProps<SelectHTMLAttributes<HTMLSelectElement>, HTMLSelectElement> & {label: string, name: string}> = (props) => {
|
||||
const {label, className, ...rest} = props;
|
||||
const form = useFormContext();
|
||||
const err = useMemo(() => {
|
||||
if (!form || !form.formState.errors[props?.name!]) return;
|
||||
return form?.formState?.errors?.[props?.name!]?.message! as string;
|
||||
}, [form?.formState?.errors?.[props?.name!]?.message]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
<div className="font-['Inter'] text-[14px]">{label}</div>
|
||||
<select {...form.register(props.name)} className={clsx("bg-input h-[44px] px-[16px] outline-none border-fifth border rounded-[4px] text-inputText placeholder-inputText", className)} {...rest} />
|
||||
<div className="text-red-400 text-[12px]">{err || <> </>}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import {useCallback} from "react";
|
||||
|
||||
export const useMediaDirectory = () => {
|
||||
const set = useCallback((path: string) => {
|
||||
return `${process.env.NEXT_PUBLIC_BACKEND_URL}/${process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY}${path}`;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
set,
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
"@nestjs/microservices": "^10.3.1",
|
||||
"@nestjs/platform-express": "^10.0.2",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/serve-static": "^4.0.1",
|
||||
"@novu/node": "^0.23.0",
|
||||
"@novu/notification-center": "^0.23.0",
|
||||
"@prisma/client": "^5.8.1",
|
||||
|
|
@ -46,14 +47,19 @@
|
|||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nestjs-command": "^3.1.4",
|
||||
"next": "13.4.4",
|
||||
"prisma-paginate": "^5.2.1",
|
||||
"react": "18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.50.1",
|
||||
"react-query": "^3.39.3",
|
||||
"react-router-dom": "6.11.2",
|
||||
"react-tag-autocomplete": "^7.2.0",
|
||||
"react-tooltip": "^5.26.2",
|
||||
"redis": "^4.6.12",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"remove-markdown": "^0.5.0",
|
||||
|
|
@ -61,6 +67,7 @@
|
|||
"simple-statistics": "^7.8.3",
|
||||
"stripe": "^14.14.0",
|
||||
"sweetalert2": "^11.10.5",
|
||||
"swr": "^2.2.5",
|
||||
"tslib": "^2.3.0",
|
||||
"twitter-api-v2": "^1.16.0",
|
||||
"yargs": "^17.7.2"
|
||||
|
|
@ -86,6 +93,7 @@
|
|||
"@testing-library/react": "14.0.0",
|
||||
"@types/cookie-parser": "^1.4.6",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "18.16.9",
|
||||
"@types/react": "18.2.33",
|
||||
"@types/react-dom": "18.2.14",
|
||||
|
|
@ -4048,6 +4056,23 @@
|
|||
"@nestjs/core": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express/node_modules/multer": {
|
||||
"version": "1.4.4-lts.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz",
|
||||
"integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.0.0",
|
||||
"concat-stream": "^1.5.2",
|
||||
"mkdirp": "^0.5.4",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.4",
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.0.0.tgz",
|
||||
|
|
@ -4090,6 +4115,37 @@
|
|||
"typescript": ">=4.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/serve-static": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-4.0.1.tgz",
|
||||
"integrity": "sha512-AoOrVdAe+WmsceuCcA8nWmKUYmaOsg9pqBCbIj7PS4W3XdikJQMtfxgSIoOlyUksZdhTBFjHqKh0Yhpj6pulwQ==",
|
||||
"dependencies": {
|
||||
"path-to-regexp": "0.2.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fastify/static": "^6.5.0",
|
||||
"@nestjs/common": "^9.0.0 || ^10.0.0",
|
||||
"@nestjs/core": "^9.0.0 || ^10.0.0",
|
||||
"express": "^4.18.1",
|
||||
"fastify": "^4.7.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@fastify/static": {
|
||||
"optional": true
|
||||
},
|
||||
"express": {
|
||||
"optional": true
|
||||
},
|
||||
"fastify": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/serve-static/node_modules/path-to-regexp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.2.5.tgz",
|
||||
"integrity": "sha512-l6qtdDPIkmAmzEO6egquYDfqQGPMRNGjYtrU13HAXb3YSRrt7HSb1sJY0pKp6o2bAa86tSB6iwaW2JbthPKr7Q=="
|
||||
},
|
||||
"node_modules/@nestjs/testing": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.1.tgz",
|
||||
|
|
@ -5428,6 +5484,21 @@
|
|||
"react": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-dnd/asap": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
|
||||
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
|
||||
},
|
||||
"node_modules/@react-dnd/invariant": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
|
||||
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
|
||||
},
|
||||
"node_modules/@react-dnd/shallowequal": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
|
||||
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
|
||||
},
|
||||
"node_modules/@redis/bloom": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
||||
|
|
@ -6869,6 +6940,15 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz",
|
||||
"integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g=="
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "1.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz",
|
||||
"integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "18.16.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.9.tgz",
|
||||
|
|
@ -9556,6 +9636,11 @@
|
|||
"validator": "^13.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
||||
|
|
@ -11010,6 +11095,16 @@
|
|||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dnd-core": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
|
||||
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
|
||||
"dependencies": {
|
||||
"@react-dnd/asap": "^5.0.1",
|
||||
"@react-dnd/invariant": "^4.0.1",
|
||||
"redux": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dns-packet": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
|
||||
|
|
@ -12537,8 +12632,7 @@
|
|||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.2.7",
|
||||
|
|
@ -17618,9 +17712,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "1.4.4-lts.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz",
|
||||
"integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==",
|
||||
"version": "1.4.5-lts.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz",
|
||||
"integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.0.0",
|
||||
|
|
@ -19741,6 +19835,43 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
|
||||
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
|
||||
"dependencies": {
|
||||
"@react-dnd/invariant": "^4.0.1",
|
||||
"@react-dnd/shallowequal": "^4.0.1",
|
||||
"dnd-core": "^16.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"hoist-non-react-statics": "^3.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/hoist-non-react-statics": ">= 3.3.1",
|
||||
"@types/node": ">= 12",
|
||||
"@types/react": ">= 16",
|
||||
"react": ">= 16.14"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/hoist-non-react-statics": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd-html5-backend": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
|
||||
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
|
||||
"dependencies": {
|
||||
"dnd-core": "^16.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||
|
|
@ -19874,6 +20005,17 @@
|
|||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-tag-autocomplete": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-7.2.0.tgz",
|
||||
"integrity": "sha512-ahTJGOZJfwdQ0cWw7U27/KUqY40BEadAClp62vuHxuNMSCV3bGXcWas5PoTGISuBJyN3qRypTDrZSNJ5GY0Iug==",
|
||||
"engines": {
|
||||
"node": ">= 16.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-textarea-autosize": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz",
|
||||
|
|
@ -19890,6 +20032,19 @@
|
|||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-tooltip": {
|
||||
"version": "5.26.2",
|
||||
"resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.26.2.tgz",
|
||||
"integrity": "sha512-C1qHiqWYn6l5c98kL/NKFyJSw5G11vUVJkgOPcKgn306c5iL5317LxMNn5Qg1GSSM7Qvtsd6KA5MvwfgxFF7Dg==",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.1",
|
||||
"classnames": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.14.0",
|
||||
"react-dom": ">=16.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
|
@ -19972,6 +20127,14 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.1.14",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz",
|
||||
|
|
@ -21908,6 +22071,18 @@
|
|||
"url": "https://github.com/sponsors/limonte"
|
||||
}
|
||||
},
|
||||
"node_modules/swr": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz",
|
||||
"integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==",
|
||||
"dependencies": {
|
||||
"client-only": "^0.0.1",
|
||||
"use-sync-external-store": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"@nestjs/microservices": "^10.3.1",
|
||||
"@nestjs/platform-express": "^10.0.2",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/serve-static": "^4.0.1",
|
||||
"@novu/node": "^0.23.0",
|
||||
"@novu/notification-center": "^0.23.0",
|
||||
"@prisma/client": "^5.8.1",
|
||||
|
|
@ -46,14 +47,19 @@
|
|||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nestjs-command": "^3.1.4",
|
||||
"next": "13.4.4",
|
||||
"prisma-paginate": "^5.2.1",
|
||||
"react": "18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.50.1",
|
||||
"react-query": "^3.39.3",
|
||||
"react-router-dom": "6.11.2",
|
||||
"react-tag-autocomplete": "^7.2.0",
|
||||
"react-tooltip": "^5.26.2",
|
||||
"redis": "^4.6.12",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"remove-markdown": "^0.5.0",
|
||||
|
|
@ -61,6 +67,7 @@
|
|||
"simple-statistics": "^7.8.3",
|
||||
"stripe": "^14.14.0",
|
||||
"sweetalert2": "^11.10.5",
|
||||
"swr": "^2.2.5",
|
||||
"tslib": "^2.3.0",
|
||||
"twitter-api-v2": "^1.16.0",
|
||||
"yargs": "^17.7.2"
|
||||
|
|
@ -86,6 +93,7 @@
|
|||
"@testing-library/react": "14.0.0",
|
||||
"@types/cookie-parser": "^1.4.6",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "18.16.9",
|
||||
"@types/react": "18.2.33",
|
||||
"@types/react-dom": "18.2.14",
|
||||
|
|
|
|||
Loading…
Reference in New Issue