diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index b613fec1..e01b7e05 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -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); + } } diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index 9e645b88..d7232688 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -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 + ); + } } diff --git a/apps/backend/src/api/routes/media.controller.ts b/apps/backend/src/api/routes/media.controller.ts new file mode 100644 index 00000000..641bc56c --- /dev/null +++ b/apps/backend/src/api/routes/media.controller.ts @@ -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); + } +} diff --git a/apps/backend/src/api/routes/posts.controller.ts b/apps/backend/src/api/routes/posts.controller.ts index 3e6950ec..aa386946 100644 --- a/apps/backend/src/api/routes/posts.controller.ts +++ b/apps/backend/src/api/routes/posts.controller.ts @@ -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); + } } diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index f39d7596..58f4eace 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -11,7 +11,7 @@ async function bootstrap() { cors: { credentials: true, exposedHeaders: ['reload'], - origin: [process.env.FRONTEND_URL] + origin: [process.env.FRONTEND_URL], } }); diff --git a/apps/backend/tsconfig.app.json b/apps/backend/tsconfig.app.json index a2ce7652..693a8392 100644 --- a/apps/backend/tsconfig.app.json +++ b/apps/backend/tsconfig.app.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "module": "commonjs", - "types": ["node"], + "types": ["node", "Multer"], "emitDecoratorMetadata": true, "target": "es2021" }, diff --git a/apps/frontend/src/app/(site)/err/page.tsx b/apps/frontend/src/app/(site)/err/page.tsx new file mode 100644 index 00000000..c89fe5bb --- /dev/null +++ b/apps/frontend/src/app/(site)/err/page.tsx @@ -0,0 +1,5 @@ +export default async function Page() { + return ( +
We are experiencing some difficulty, try to refresh the page
+ ) +} \ No newline at end of file diff --git a/apps/frontend/src/app/global.css b/apps/frontend/src/app/global.css index 3ef18c18..4c853287 100644 --- a/apps/frontend/src/app/global.css +++ b/apps/frontend/src/app/global.css @@ -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; -} \ No newline at end of file + 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; +} diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index 7177d176..fe90cca4 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -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"; diff --git a/apps/frontend/src/components/launches/add.edit.model.tsx b/apps/frontend/src/components/launches/add.edit.model.tsx index 3b513eef..20253626 100644 --- a/apps/frontend/src/components/launches/add.edit.model.tsx +++ b/apps/frontend/src/components/launches/add.edit.model.tsx @@ -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(['']); + const [value, setValue] = useState>([ + { 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<{
- - {!showHide.hideTopEditor ? ( + {!existingData.integration && ( + + )} + {!existingData.integration && !showHide.hideTopEditor ? ( <> {value.map((p, index) => ( <> 1 ? 150 : 500} - value={p} + value={p.content} preview="edit" // @ts-ignore onChange={changeValue(index)} @@ -332,9 +349,11 @@ export const AddEditModal: FC<{ ))} ) : ( -
- Global Editor Hidden -
+ !existingData.integration && ( +
+ Global Editor Hidden +
+ ) )} {!!selectedIntegrations.length && ( {}, + posts: [] as Array, + 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 ( - + {children} ); diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 35e34cdc..90ea4bdc 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -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 ( +
p.identifier === post.integration?.providerIdentifier + )?.name + }: ${post.content.slice(0, 100)}`} + > + p.identifier === post.integration?.providerIdentifier + )?.picture! + } + /> + +
+ ); +}; + 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: ( + + f.id === data.integration + )} + date={getDate} + /> + + ), + 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: ( - - ), + children: , size: '80%', title: `Adding posts for ${getDate.format('DD/MM/YYYY HH:mm')}`, }); }, []); - const isBeforeNow = useMemo(() => { - return getDate.isBefore(dayjs()); - }, [getDate]); - return ( -
-
- {isBeforeNow ? '' : '+ Add'} +
+
+
+ {postList.map((post) => ( +
1 && 'w-[33px] basis-[28px]', + 'h-full text-white relative flex justify-center items-center flex-grow-0 flex-shrink-0' + )} + > +
+ +
+
+ ))} + {!isBeforeNow && ( +
+
+ + +
+
+ )} +
); @@ -91,51 +232,53 @@ const CalendarColumn: FC<{ day: number; hour: string }> = (props) => { export const Calendar = () => { return ( -
-
- {days.map((day) => ( -
- {day} -
- ))} - {hours.map((hour) => - days.map((day, index) => ( - <> - {index === 0 ? ( -
- {['00', '10', '20', '30', '40', '50'].map((num) => ( -
- {hour.split(':')[0] + ':' + num} -
- ))} -
- ) : ( -
- {['00', '10', '20', '30', '40', '50'].map((num) => ( - - ))} -
- )} - - )) - )} + +
+
+ {days.map((day) => ( +
+ {day} +
+ ))} + {hours.map((hour) => + days.map((day, index) => ( + <> + {index === 0 ? ( +
+ {['00', '10', '20', '30', '40', '50'].map((num) => ( +
+ {hour.split(':')[0] + ':' + num} +
+ ))} +
+ ) : ( +
+ {['00', '10', '20', '30', '40', '50'].map((num) => ( + + ))} +
+ )} + + )) + )} +
-
+ ); }; diff --git a/apps/frontend/src/components/launches/helpers/dnd.provider.tsx b/apps/frontend/src/components/launches/helpers/dnd.provider.tsx new file mode 100644 index 00000000..907eb2d8 --- /dev/null +++ b/apps/frontend/src/components/launches/helpers/dnd.provider.tsx @@ -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 ( + + {children} + + ) +} diff --git a/apps/frontend/src/components/launches/helpers/use.custom.provider.function.ts b/apps/frontend/src/components/launches/helpers/use.custom.provider.function.ts new file mode 100644 index 00000000..c71c4c40 --- /dev/null +++ b/apps/frontend/src/components/launches/helpers/use.custom.provider.function.ts @@ -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 }; +}; diff --git a/apps/frontend/src/components/launches/helpers/use.existing.data.tsx b/apps/frontend/src/components/launches/helpers/use.existing.data.tsx new file mode 100644 index 00000000..4c7d7ad0 --- /dev/null +++ b/apps/frontend/src/components/launches/helpers/use.existing.data.tsx @@ -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 ( + + {children} + + ); +} + + +export const useExistingData = () => useContext(ExistingDataContext); \ No newline at end of file diff --git a/apps/frontend/src/components/launches/helpers/use.formatting.ts b/apps/frontend/src/components/launches/helpers/use.formatting.ts index ad2362d8..61e94d9a 100644 --- a/apps/frontend/src/components/launches/helpers/use.formatting.ts +++ b/apps/frontend/src/components/launches/helpers/use.formatting.ts @@ -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, } diff --git a/apps/frontend/src/components/launches/helpers/use.integration.ts b/apps/frontend/src/components/launches/helpers/use.integration.ts index 515f146b..36e31022 100644 --- a/apps/frontend/src/components/launches/helpers/use.integration.ts +++ b/apps/frontend/src/components/launches/helpers/use.integration.ts @@ -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); \ No newline at end of file diff --git a/apps/frontend/src/components/launches/helpers/use.values.ts b/apps/frontend/src/components/launches/helpers/use.values.ts index 8732fe5c..baa2fdeb 100644 --- a/apps/frontend/src/components/launches/helpers/use.values.ts +++ b/apps/frontend/src/components/launches/helpers/use.values.ts @@ -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}>; 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}>, 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) => { - // 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) => { diff --git a/apps/frontend/src/components/launches/launches.component.tsx b/apps/frontend/src/components/launches/launches.component.tsx index f30e42be..c7fedd3b 100644 --- a/apps/frontend/src/components/launches/launches.component.tsx +++ b/apps/frontend/src/components/launches/launches.component.tsx @@ -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 (
-
+

Channels

{sortedIntegrations.map((integration) => ( diff --git a/apps/frontend/src/components/launches/providers/devto.provider.tsx b/apps/frontend/src/components/launches/providers/devto.provider.tsx deleted file mode 100644 index 8c0c338e..00000000 --- a/apps/frontend/src/components/launches/providers/devto.provider.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {FC} from "react"; -import {withProvider} from "@gitroom/frontend/components/launches/providers/high.order.provider"; - -const DevtoPreview: FC = () => { - return
asd
-}; - -const DevtoSettings: FC = () => { - return
asdfasd
-}; - -export default withProvider(DevtoSettings, DevtoPreview); \ No newline at end of file diff --git a/apps/frontend/src/components/launches/providers/devto/devto.provider.tsx b/apps/frontend/src/components/launches/providers/devto/devto.provider.tsx new file mode 100644 index 00000000..6ad8e930 --- /dev/null +++ b/apps/frontend/src/components/launches/providers/devto/devto.provider.tsx @@ -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
asd
+}; + +const DevtoSettings: FC = () => { + const form = useSettings(); + return ( + <> + + + +
+ +
+
+ +
+ + ) +}; + +export default withProvider(DevtoSettings, DevtoPreview, DevToSettingsDto); \ No newline at end of file diff --git a/apps/frontend/src/components/launches/providers/devto/devto.tags.tsx b/apps/frontend/src/components/launches/providers/devto/devto.tags.tsx new file mode 100644 index 00000000..b03669fd --- /dev/null +++ b/apps/frontend/src/components/launches/providers/devto/devto.tags.tsx @@ -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([]); + const { getValues } = useSettings(); + const [tagValue, setTagValue] = useState([]); + + 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 ( +
+
{label}
+ +
+ ); +}; diff --git a/apps/frontend/src/components/launches/providers/devto/select.organization.tsx b/apps/frontend/src/components/launches/providers/devto/select.organization.tsx new file mode 100644 index 00000000..09e0f5ea --- /dev/null +++ b/apps/frontend/src/components/launches/providers/devto/select.organization.tsx @@ -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(); + + 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 ( + + ); +}; diff --git a/apps/frontend/src/components/launches/providers/high.order.provider.tsx b/apps/frontend/src/components/launches/providers/high.order.provider.tsx index f823d2e8..128e51d0 100644 --- a/apps/frontend/src/components/launches/providers/high.order.provider.tsx +++ b/apps/frontend/src/components/launches/providers/high.order.provider.tsx @@ -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) => { 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) => { ))}
)} - {showTab === 2 && } - {showTab === 0 && } + {showTab === 2 && ( +
+ +
+ )} + {showTab === 0 && ( + + + + )}
); diff --git a/apps/frontend/src/components/launches/providers/linkedin.provider.tsx b/apps/frontend/src/components/launches/providers/linkedin/linkedin.provider.tsx similarity index 86% rename from apps/frontend/src/components/launches/providers/linkedin.provider.tsx rename to apps/frontend/src/components/launches/providers/linkedin/linkedin.provider.tsx index 122a7e2a..c8d84716 100644 --- a/apps/frontend/src/components/launches/providers/linkedin.provider.tsx +++ b/apps/frontend/src/components/launches/providers/linkedin/linkedin.provider.tsx @@ -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 ( -
+
{newValues.map((value, index) => (
{ @username
-
{value.text}
+
{value.text}
))} diff --git a/apps/frontend/src/components/launches/providers/reddit.provider.tsx b/apps/frontend/src/components/launches/providers/reddit/reddit.provider.tsx similarity index 90% rename from apps/frontend/src/components/launches/providers/reddit.provider.tsx rename to apps/frontend/src/components/launches/providers/reddit/reddit.provider.tsx index 1093b14d..08500d52 100644 --- a/apps/frontend/src/components/launches/providers/reddit.provider.tsx +++ b/apps/frontend/src/components/launches/providers/reddit/reddit.provider.tsx @@ -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) => {
@@ -77,7 +60,7 @@ const RedditPreview: FC = (props) => { @username
-
{value.text}
+
{value.text}
))} diff --git a/apps/frontend/src/components/launches/providers/show.all.providers.tsx b/apps/frontend/src/components/launches/providers/show.all.providers.tsx index 39e72fb8..b8d5bc5c 100644 --- a/apps/frontend/src/components/launches/providers/show.all.providers.tsx +++ b/apps/frontend/src/components/launches/providers/show.all.providers.tsx @@ -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 ( <> diff --git a/apps/frontend/src/components/launches/providers/fonts/x/Chirp-Bold.woff2 b/apps/frontend/src/components/launches/providers/x/fonts/x/Chirp-Bold.woff2 similarity index 100% rename from apps/frontend/src/components/launches/providers/fonts/x/Chirp-Bold.woff2 rename to apps/frontend/src/components/launches/providers/x/fonts/x/Chirp-Bold.woff2 diff --git a/apps/frontend/src/components/launches/providers/fonts/x/Chirp-Regular.woff2 b/apps/frontend/src/components/launches/providers/x/fonts/x/Chirp-Regular.woff2 similarity index 100% rename from apps/frontend/src/components/launches/providers/fonts/x/Chirp-Regular.woff2 rename to apps/frontend/src/components/launches/providers/x/fonts/x/Chirp-Regular.woff2 diff --git a/apps/frontend/src/components/launches/providers/x.provider.tsx b/apps/frontend/src/components/launches/providers/x/x.provider.tsx similarity index 100% rename from apps/frontend/src/components/launches/providers/x.provider.tsx rename to apps/frontend/src/components/launches/providers/x/x.provider.tsx diff --git a/apps/frontend/src/components/layout/layout.context.tsx b/apps/frontend/src/components/layout/layout.context.tsx index 4310ae73..b2c27d19 100644 --- a/apps/frontend/src/components/layout/layout.context.tsx +++ b/apps/frontend/src/components/layout/layout.context.tsx @@ -23,6 +23,7 @@ function LayoutContextInner(params: {children: ReactNode}) { baseUrl={process.env.NEXT_PUBLIC_BACKEND_URL!} afterRequest={afterRequest} > + {params?.children || <>} ) diff --git a/apps/frontend/src/components/layout/layout.settings.tsx b/apps/frontend/src/components/layout/layout.settings.tsx index 4fda7403..95ce3af7 100644 --- a/apps/frontend/src/components/layout/layout.settings.tsx +++ b/apps/frontend/src/components/layout/layout.settings.tsx @@ -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 ( +
diff --git a/apps/frontend/src/components/layout/top.tip.tsx b/apps/frontend/src/components/layout/top.tip.tsx new file mode 100644 index 00000000..47638d9e --- /dev/null +++ b/apps/frontend/src/components/layout/top.tip.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { Tooltip } from 'react-tooltip'; + +export const ToolTip = () => { + return ; +}; diff --git a/apps/frontend/src/components/media/media.component.tsx b/apps/frontend/src/components/media/media.component.tsx new file mode 100644 index 00000000..d95b989c --- /dev/null +++ b/apps/frontend/src/components/media/media.component.tsx @@ -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([]); + const fetch = useFetch(); + const mediaDirectory = useMediaDirectory(); + + const loadMedia = useCallback(async () => { + return (await fetch('/media')).json(); + }, []); + + const uploadMedia = useCallback( + async (file: ChangeEvent) => { + 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 ( +
+
+ + + + +
+ {mediaList.map((media) => ( +
+ +
+ ))} +
+
+
+ ); +}; +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 ( +
+ {modal && } +
{label}
+
{description}
+ {!!currentMedia && ( +
+ window.open(mediaDirectory.set(currentMedia.path))} + /> +
+ )} +
+ + +
+
+ ); +}; diff --git a/apps/frontend/src/middleware.ts b/apps/frontend/src/middleware.ts index 1e02d8fe..e5a8c741 100644 --- a/apps/frontend/src/middleware.ts +++ b/apps/frontend/src/middleware.ts @@ -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)); diff --git a/apps/frontend/tailwind.config.js b/apps/frontend/tailwind.config.js index 84babd43..b836639e 100644 --- a/apps/frontend/tailwind.config.js +++ b/apps/frontend/tailwind.config.js @@ -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: [ diff --git a/libraries/helpers/src/utils/custom.fetch.func.ts b/libraries/helpers/src/utils/custom.fetch.func.ts index 1e7ba077..a6e84fe3 100644 --- a/libraries/helpers/src/utils/custom.fetch.func.ts +++ b/libraries/helpers/src/utils/custom.fetch.func.ts @@ -1,28 +1,32 @@ export interface Params { - baseUrl: string, - beforeRequest?: (url: string, options: RequestInit) => Promise, - afterRequest?: (url: string, options: RequestInit, response: Response) => Promise + baseUrl: string; + beforeRequest?: (url: string, options: RequestInit) => Promise; + afterRequest?: ( + url: string, + options: RequestInit, + response: Response + ) => Promise; } 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!, }); diff --git a/libraries/nestjs-libraries/src/bull-mq-transport/client/bull-mq.client.ts b/libraries/nestjs-libraries/src/bull-mq-transport/client/bull-mq.client.ts index e488ce49..2ea68379 100644 --- a/libraries/nestjs-libraries/src/bull-mq-transport/client/bull-mq.client.ts +++ b/libraries/nestjs-libraries/src/bull-mq-transport/client/bull-mq.client.ts @@ -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>, ): Promise { diff --git a/libraries/nestjs-libraries/src/database/prisma/database.module.ts b/libraries/nestjs-libraries/src/database/prisma/database.module.ts index 549764f1..eb0025e6 100644 --- a/libraries/nestjs-libraries/src/database/prisma/database.module.ts +++ b/libraries/nestjs-libraries/src/database/prisma/database.module.ts @@ -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() { diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts index 50a58631..dd77663f 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts @@ -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: { diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts index 692fdfa4..8286a5e6 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -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); + } } diff --git a/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts b/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts new file mode 100644 index 00000000..b0658035 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts @@ -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, + }; + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts b/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts new file mode 100644 index 00000000..78227061 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts @@ -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); + } +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts index d12e152b..ecc9cca0 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -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, }) ); } diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 094f9aba..f0f1aea1 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -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 { - 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); + } } diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index 49b38347..18c026b8 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -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[] diff --git a/libraries/nestjs-libraries/src/dtos/integrations/integration.function.dto.ts b/libraries/nestjs-libraries/src/dtos/integrations/integration.function.dto.ts new file mode 100644 index 00000000..5f2faa4e --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/integrations/integration.function.dto.ts @@ -0,0 +1,13 @@ +import {IsDefined, IsString} from "class-validator"; + +export class IntegrationFunctionDto { + @IsString() + @IsDefined() + name: string; + + @IsString() + @IsDefined() + id: string; + + data: any; +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/dtos/media/media.dto.ts b/libraries/nestjs-libraries/src/dtos/media/media.dto.ts new file mode 100644 index 00000000..568fb14a --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/media/media.dto.ts @@ -0,0 +1,11 @@ +import {IsDefined, IsString} from "class-validator"; + +export class MediaDto { + @IsString() + @IsDefined() + id: string; + + @IsString() + @IsDefined() + path: string; +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts index b47b48c0..4a063f25 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts @@ -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[]; } diff --git a/libraries/nestjs-libraries/src/dtos/posts/get.posts.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/get.posts.dto.ts new file mode 100644 index 00000000..525c504d --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/get.posts.dto.ts @@ -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; +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts index d1e91366..9caa68af 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts @@ -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; \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dev.to.settings.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dev.to.settings.dto.ts index 433a24d5..0a835efb 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dev.to.settings.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dev.to.settings.dto.ts @@ -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[]; } \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dev.to.tags.settings.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dev.to.tags.settings.ts new file mode 100644 index 00000000..f71b241e --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dev.to.tags.settings.ts @@ -0,0 +1,9 @@ +import {IsNumber, IsString} from "class-validator"; + +export class DevToTagsSettings { + @IsNumber() + value: number; + + @IsString() + label: string; +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/integrations/article/dev.to.provider.ts b/libraries/nestjs-libraries/src/integrations/article/dev.to.provider.ts index f13ff12e..0ba8f2d7 100644 --- a/libraries/nestjs-libraries/src/integrations/article/dev.to.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/article/dev.to.provider.ts @@ -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' - } - } -} \ No newline at end of file + 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, + }; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts index b745221f..ac5c258a 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -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, diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts index 43da34d3..d49c6893 100644 --- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts @@ -82,6 +82,7 @@ export class XProvider implements SocialProvider { accessToken: string, postDetails: PostDetails[], ): Promise { + console.log('hello'); const client = new TwitterApi(accessToken); const {data: {username}} = await client.v2.me({ "user.fields": "username" diff --git a/libraries/nestjs-libraries/src/upload/upload.module.ts b/libraries/nestjs-libraries/src/upload/upload.module.ts new file mode 100644 index 00000000..4f8e8cb1 --- /dev/null +++ b/libraries/nestjs-libraries/src/upload/upload.module.ts @@ -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 {} diff --git a/libraries/react-shared-libraries/src/form/select.tsx b/libraries/react-shared-libraries/src/form/select.tsx new file mode 100644 index 00000000..6f61a9f3 --- /dev/null +++ b/libraries/react-shared-libraries/src/form/select.tsx @@ -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, 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 ( +
+
{label}
+