Merge branch 'main' into sentry-user

This commit is contained in:
Enno Gelhaus 2025-11-24 18:52:09 +01:00 committed by GitHub
commit a21087ea25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
276 changed files with 24827 additions and 17440 deletions

View file

@ -50,6 +50,10 @@ GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
BEEHIIVE_API_KEY=""
BEEHIIVE_PUBLICATION_ID=""
LISTMONK_DOMAIN=""
LISTMONK_USER=""
LISTMONK_API_KEY=""
LISTMONK_LIST_ID=""
THREADS_APP_ID=""
THREADS_APP_SECRET=""
FACEBOOK_APP_ID=""

View file

@ -3,11 +3,11 @@ name: Build
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
@ -16,6 +16,9 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
@ -47,3 +50,20 @@ jobs:
- name: Build
run: pnpm run build
- name: Get Commit SHA (short)
id: get_version
run: |
# Get the short 8-character commit SHA
VERSION=$(git rev-parse --short=8 HEAD)
echo "Commit SHA is $VERSION"
echo "tag=$VERSION" >> $GITHUB_OUTPUT
- name: SonarQube Analysis (Branch)
uses: SonarSource/sonarqube-scan-action@v6
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
with:
args: >
-Dsonar.projectVersion=${{ steps.get_version.outputs.tag }}

71
.github/workflows/build-pr vendored Normal file
View file

@ -0,0 +1,71 @@
---
name: Build
on:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ['20.17.0']
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
environment:
name: build-pr
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: |
${{ env.STORE_PATH }}
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}-
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm run build
- name: Get Commit SHA (short)
id: get_version
run: |
# Get the short 8-character commit SHA
VERSION=$(git rev-parse --short=8 HEAD)
echo "Commit SHA is $VERSION"
echo "tag=$VERSION" >> $GITHUB_OUTPUT
- name: SonarQube Analysis (Pull Request)
uses: SonarSource/sonarqube-scan-action@v6
with:
args: >
-Dsonar.projectVersion=${{ steps.get_version.outputs.tag }}
-Dsonar.pullrequest.key=${{ github.event.pull_request.number }}
-Dsonar.pullrequest.branch=${{ github.event.pull_request.head.ref }}
-Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }}

View file

@ -9,10 +9,16 @@ permissions: write-all
jobs:
build-and-publish:
runs-on: ubuntu-latest
environment:
name: build-pr
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3

View file

@ -1,4 +1,4 @@
FROM node:20-alpine3.19
FROM node:22.20-alpine
ARG NEXT_PUBLIC_VERSION
ENV NEXT_PUBLIC_VERSION=$NEXT_PUBLIC_VERSION
RUN apk add --no-cache g++ make py3-pip bash nginx

96
Jenkins/Build.Jenkinsfile Normal file
View file

@ -0,0 +1,96 @@
// Declarative Pipeline for building Node.js application and running SonarQube analysis triggered by a push event.
pipeline {
// Defines the execution environment. Using 'agent any' to ensure an agent is available.
agent any
// Global environment block removed to prevent Groovy scoping issues with manual path calculation.
stages {
// Stage 1: Checkout the code (Relies on the initial SCM checkout done by Jenkins)
stage('Source Checkout') {
steps {
echo "Workspace already populated by the initial SCM checkout. Proceeding."
}
}
// Stage 2: Setup Node.js v20 and install pnpm
stage('Setup Environment and Tools') {
steps {
sh '''
echo "Ensuring required utilities and Node.js are installed..."
sudo apt-get update
sudo apt-get install -y curl unzip nodejs
# 1. Install Node.js v20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
echo "Node.js version: \$(node -v)"
# 2. Install pnpm globally (version 8)
npm install -g pnpm@8
echo "pnpm version: \$(pnpm -v)"
'''
}
}
// Stage 3: Install dependencies and build the application
stage('Install and Build') {
steps {
sh 'pnpm install'
sh 'pnpm run build'
}
}
// Stage 4: Run SonarQube analysis: Install scanner, get version, and execute.
stage('SonarQube Analysis') {
steps {
script {
// 1. Get the short 8-character commit SHA for project versioning
def commitShaShort = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
echo "Commit SHA (short) is: ${commitShaShort}"
// --- 2. MANUALLY INSTALL THE SONAR SCANNER CLI LOCALLY IN THIS STAGE ---
sh """
echo "Manually downloading and installing Sonar Scanner CLI..."
# Download the stable scanner CLI package
curl -sS -o sonar-scanner.zip \
"https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-4.7.0.2747.zip"
# Added -o flag to force overwrite and prevent interactive prompt failure
unzip -o -q sonar-scanner.zip -d .
"""
// 3. Find the extracted directory name and capture the full absolute bin path in Groovy
// This is defined locally and used directly, avoiding environment variable issues.
def scannerBinPath = sh(
returnStdout: true,
script: '''
SCANNER_DIR=$(find . -maxdepth 1 -type d -name "sonar-scanner*" | head -n 1)
# Get the full absolute path to the executable file
echo \$(pwd)/\${SCANNER_DIR}/bin/sonar-scanner
'''
).trim()
echo "Scanner executable path captured: ${scannerBinPath}"
// 4. Use withSonarQubeEnv to set up the secure variables (HOST and TOKEN)
withSonarQubeEnv(installationName: 'SonarQube-Server') {
// 5. Execute the scanner using the Groovy variable directly.
sh """
echo "Starting SonarQube Analysis for project version: ${commitShaShort}"
# Execute the full, absolute path captured in the Groovy variable.
'${scannerBinPath}' \\
-Dsonar.projectVersion=${commitShaShort} \\
-Dsonar.sources=.
# SONAR_HOST_URL and SONAR_TOKEN are automatically passed as environment variables
# by the withSonarQubeEnv block.
"""
}
}
}
}
}
}

100
Jenkins/BuildPR.Jenkinsfile Normal file
View file

@ -0,0 +1,100 @@
// Declarative Pipeline for building Node.js application and running SonarQube analysis for a Pull Request.
pipeline {
// Defines the execution environment. Using 'agent any' to ensure an agent is available.
agent any
// Environment variables that hold PR details, provided by Jenkins Multibranch setup.
environment {
// FIX: Environment variables must be quoted or wrapped in a function call.
// We quote the 'env.CHANGE_ID' reference to fix the compilation error.
PR_KEY = "${env.CHANGE_ID}"
PR_BRANCH = "${env.CHANGE_BRANCH}"
PR_BASE = "${env.CHANGE_TARGET}"
}
stages {
// Stage 1: Checkout the code (Relies on the initial SCM checkout done by Jenkins)
stage('Source Checkout') {
steps {
echo "Workspace already populated by the initial SCM checkout. Proceeding."
}
}
// Stage 2: Setup Node.js v20, install pnpm, and install required tools (curl, unzip)
stage('Setup Environment and Tools') {
steps {
sh '''
echo "Ensuring required utilities and Node.js are installed..."
sudo apt-get update
sudo apt-get install -y curl unzip nodejs
# 1. Install Node.js v20 (closest matching the specified version '20.17.0')
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
echo "Node.js version: \$(node -v)"
# 2. Install pnpm globally (version 8)
npm install -g pnpm@8
echo "pnpm version: \$(pnpm -v)"
'''
}
}
// Stage 3: Install dependencies and build the application
stage('Install and Build') {
steps {
sh 'pnpm install'
sh 'pnpm run build'
}
}
// Stage 4: Run SonarQube PR analysis: Install scanner locally, get version, and execute.
stage('SonarQube Pull Request Analysis') {
steps {
script {
// 1. Get the short 8-character commit SHA for project versioning
def commitShaShort = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
echo "Commit SHA (short) is: ${commitShaShort}"
// --- 2. MANUALLY INSTALL THE SONAR SCANNER CLI LOCALLY ---
sh """
echo "Manually downloading and installing Sonar Scanner CLI..."
curl -sS -o sonar-scanner.zip \
"https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-4.7.0.2747.zip"
unzip -o -q sonar-scanner.zip -d .
"""
// 3. Find the extracted directory name and capture the full absolute executable path.
def scannerBinPath = sh(
returnStdout: true,
script: '''
SCANNER_DIR=$(find . -maxdepth 1 -type d -name "sonar-scanner*" | head -n 1)
# Get the full absolute path to the executable file
echo \$(pwd)/\${SCANNER_DIR}/bin/sonar-scanner
'''
).trim()
echo "Scanner executable path captured: ${scannerBinPath}"
// 4. Use withSonarQubeEnv to set up the secure variables (HOST and TOKEN)
withSonarQubeEnv(installationName: 'SonarQube-Server') {
// 5. Execute the scanner using the Groovy variable directly with PR parameters.
sh """
echo "Starting SonarQube Pull Request Analysis for PR #${PR_KEY}"
'${scannerBinPath}' \\
-Dsonar.projectVersion=${commitShaShort} \\
-Dsonar.sources=. \\
-Dsonar.pullrequest.key=${PR_KEY} \\
-Dsonar.pullrequest.branch=${PR_BRANCH} \\
-Dsonar.pullrequest.base=${PR_BASE}
# SONAR_HOST_URL and SONAR_TOKEN are automatically passed as environment variables
# by the withSonarQubeEnv block.
"""
}
}
}
}
}
}

70
Jenkinsfile vendored
View file

@ -1,70 +0,0 @@
pipeline {
agent any
environment {
NODE_VERSION = '20.17.0'
PR_NUMBER = "${env.CHANGE_ID}" // PR number comes from webhook payload
IMAGE_TAG="ghcr.io/gitroomhq/postiz-app-pr:${env.CHANGE_ID}"
}
stages {
stage('Checkout Repository') {
steps {
checkout scm
}
}
stage('Check Node.js and npm') {
steps {
script {
sh "node -v"
sh "npm -v"
}
}
}
stage('Install Dependencies') {
steps {
sh 'npm ci'
}
}
stage('Build Project') {
steps {
sh 'npm run build'
}
}
stage('Build and Push Docker Image') {
when {
expression { return env.CHANGE_ID != null } // Only run if it's a PR
}
steps {
withCredentials([string(credentialsId: 'gh-pat', variable: 'GITHUB_PASS')]) {
// Docker login step
sh '''
echo "$GITHUB_PASS" | docker login ghcr.io -u "egelhaus" --password-stdin
'''
// Build Docker image
sh '''
docker build -f Dockerfile.dev -t $IMAGE_TAG .
'''
// Push Docker image to GitHub Container Registry
sh '''
docker push $IMAGE_TAG
'''
}
}
}
}
post {
success {
echo 'Build completed successfully!'
}
failure {
echo 'Build failed!'
}
}
}

View file

@ -1,6 +1,6 @@
<p align="center">
<a href="https://affiliate.postiz.com">
<img src="https://github.com/user-attachments/assets/af9f47b3-e20c-402b-bd11-02f39248d738" />
<a href="https://github.com/growchief/growchief">
<img alt="automate" src="https://github.com/user-attachments/assets/d760188d-8d56-4b05-a6c1-c57e67ef25cd" />
</a>
</p>

View file

@ -5,7 +5,7 @@
"scripts": {
"dev": "dotenv -e ../../.env -- nest start --watch --entryFile=./apps/backend/src/main",
"build": "cross-env NODE_ENV=production nest build",
"start": "dotenv -e ../../.env -- node ./dist/apps/backend/src/main.js",
"start": "dotenv -e ../../.env -- node --experimental-require-module ./dist/apps/backend/src/main.js",
"pm2": "pm2 start pnpm --name backend -- start"
},
"keywords": [],

View file

@ -31,8 +31,6 @@ import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments';
import { WebhookController } from '@gitroom/backend/api/routes/webhooks.controller';
import { SignatureController } from '@gitroom/backend/api/routes/signature.controller';
import { AutopostController } from '@gitroom/backend/api/routes/autopost.controller';
import { McpService } from '@gitroom/nestjs-libraries/mcp/mcp.service';
import { McpController } from '@gitroom/backend/api/routes/mcp.controller';
import { SetsController } from '@gitroom/backend/api/routes/sets.controller';
import { ThirdPartyController } from '@gitroom/backend/api/routes/third-party.controller';
import { MonitorController } from '@gitroom/backend/api/routes/monitor.controller';
@ -63,7 +61,6 @@ const authenticatedController = [
StripeController,
AuthController,
PublicController,
McpController,
MonitorController,
...authenticatedController,
],
@ -80,7 +77,6 @@ const authenticatedController = [
TrackService,
ShortLinkService,
Nowpayments,
McpService,
],
get exports() {
return [...this.imports, ...this.providers];

View file

@ -10,7 +10,7 @@ import { CreateAgencyDto } from '@gitroom/nestjs-libraries/dtos/agencies/create.
export class AgenciesController {
constructor(private _agenciesService: AgenciesService) {}
@Get('/')
async getAgencyByUser(@GetUserFromRequest() user: User) {
async getAgencyByUsers(@GetUserFromRequest() user: User) {
return (await this._agenciesService.getAgencyByUser(user)) || {};
}

View file

@ -21,6 +21,7 @@ import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
import { RealIP } from 'nestjs-real-ip';
import { UserAgent } from '@gitroom/nestjs-libraries/user/user.agent';
import { Provider } from '@prisma/client';
import * as Sentry from '@sentry/nestjs';
@ApiTags('Auth')
@Controller('/auth')
@ -41,7 +42,7 @@ export class AuthController {
async register(
@Req() req: Request,
@Body() body: CreateOrgUserDto,
@Res({ passthrough: true }) response: Response,
@Res({ passthrough: false }) response: Response,
@RealIP() ip: string,
@UserAgent() userAgent: string
) {
@ -101,6 +102,7 @@ export class AuthController {
}
}
Sentry.metrics.count("new_user", 1);
response.header('onboarding', 'true');
response.status(200).json({
register: true,
@ -114,7 +116,7 @@ export class AuthController {
async login(
@Req() req: Request,
@Body() body: LoginUserDto,
@Res({ passthrough: true }) response: Response,
@Res({ passthrough: false }) response: Response,
@RealIP() ip: string,
@UserAgent() userAgent: string
) {
@ -204,11 +206,11 @@ export class AuthController {
@Post('/activate')
async activate(
@Body('code') code: string,
@Res({ passthrough: true }) response: Response
@Res({ passthrough: false }) response: Response
) {
const activate = await this._authService.activate(code);
if (!activate) {
return response.status(200).send({ can: false });
return response.status(200).json({ can: false });
}
response.cookie('auth', activate, {
@ -228,16 +230,18 @@ export class AuthController {
}
response.header('onboarding', 'true');
return response.status(200).send({ can: true });
return response.status(200).json({ can: true });
}
@Post('/oauth/:provider/exists')
async oauthExists(
@Body('code') code: string,
@Param('provider') provider: string,
@Res({ passthrough: true }) response: Response
@Res({ passthrough: false }) response: Response
) {
const { jwt, token } = await this._authService.checkExists(provider, code);
if (token) {
return response.json({ token });
}

View file

@ -1,18 +1,43 @@
import { Logger, Controller, Get, Post, Req, Res, Query } from '@nestjs/common';
import {
Logger,
Controller,
Get,
Post,
Req,
Res,
Query,
Param,
} from '@nestjs/common';
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNestEndpoint,
copilotRuntimeNodeHttpEndpoint,
copilotRuntimeNextJSAppRouterEndpoint,
} from '@copilotkit/runtime';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization } from '@prisma/client';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { MastraAgent } from '@ag-ui/mastra';
import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service';
import { Request, Response } from 'express';
import { RuntimeContext } from '@mastra/core/di';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import { AuthorizationActions, Sections } from '@gitroom/backend/services/auth/permissions/permission.exception.class';
export type ChannelsContext = {
integrations: string;
organization: string;
ui: string;
};
@Controller('/copilot')
export class CopilotController {
constructor(private _subscriptionService: SubscriptionService) {}
constructor(
private _subscriptionService: SubscriptionService,
private _mastraService: MastraService
) {}
@Post('/chat')
chat(@Req() req: Request, @Res() res: Response) {
chatAgent(@Req() req: Request, @Res() res: Response) {
if (
process.env.OPENAI_API_KEY === undefined ||
process.env.OPENAI_API_KEY === ''
@ -21,28 +46,112 @@ export class CopilotController {
return;
}
const copilotRuntimeHandler = copilotRuntimeNestEndpoint({
const copilotRuntimeHandler = copilotRuntimeNodeHttpEndpoint({
endpoint: '/copilot/chat',
runtime: new CopilotRuntime(),
serviceAdapter: new OpenAIAdapter({
model:
// @ts-ignore
req?.body?.variables?.data?.metadata?.requestType ===
'TextareaCompletion'
? 'gpt-4o-mini'
: 'gpt-4.1',
model: 'gpt-4.1',
}),
});
// @ts-ignore
return copilotRuntimeHandler(req, res);
}
@Post('/agent')
@CheckPolicies([AuthorizationActions.Create, Sections.AI])
async agent(
@Req() req: Request,
@Res() res: Response,
@GetOrgFromRequest() organization: Organization
) {
if (
process.env.OPENAI_API_KEY === undefined ||
process.env.OPENAI_API_KEY === ''
) {
Logger.warn('OpenAI API key not set, chat functionality will not work');
return;
}
const mastra = await this._mastraService.mastra();
const runtimeContext = new RuntimeContext<ChannelsContext>();
runtimeContext.set(
'integrations',
req?.body?.variables?.properties?.integrations || []
);
runtimeContext.set('organization', JSON.stringify(organization));
runtimeContext.set('ui', 'true');
const agents = MastraAgent.getLocalAgents({
resourceId: organization.id,
mastra,
// @ts-ignore
runtimeContext,
});
const runtime = new CopilotRuntime({
agents,
});
const copilotRuntimeHandler = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: '/copilot/agent',
runtime,
// properties: req.body.variables.properties,
serviceAdapter: new OpenAIAdapter({
model: 'gpt-4.1',
}),
});
return copilotRuntimeHandler.handleRequest(req, res);
}
@Get('/credits')
calculateCredits(
@GetOrgFromRequest() organization: Organization,
@Query('type') type: 'ai_images' | 'ai_videos',
@Query('type') type: 'ai_images' | 'ai_videos'
) {
return this._subscriptionService.checkCredits(organization, type || 'ai_images');
return this._subscriptionService.checkCredits(
organization,
type || 'ai_images'
);
}
@Get('/:thread/list')
@CheckPolicies([AuthorizationActions.Create, Sections.AI])
async getMessagesList(
@GetOrgFromRequest() organization: Organization,
@Param('thread') threadId: string
): Promise<any> {
const mastra = await this._mastraService.mastra();
const memory = await mastra.getAgent('postiz').getMemory();
try {
return await memory.query({
resourceId: organization.id,
threadId,
});
} catch (err) {
return { messages: [] };
}
}
@Get('/list')
@CheckPolicies([AuthorizationActions.Create, Sections.AI])
async getList(@GetOrgFromRequest() organization: Organization) {
const mastra = await this._mastraService.mastra();
// @ts-ignore
const memory = await mastra.getAgent('postiz').getMemory();
const list = await memory.getThreadsByResourceIdPaginated({
resourceId: organization.id,
perPage: 100000,
page: 0,
orderBy: 'createdAt',
sortDirection: 'DESC',
});
return {
threads: list.threads.map((p) => ({
id: p.id,
title: p.title,
})),
};
}
}

View file

@ -3,6 +3,7 @@ import {
Controller,
Delete,
Get,
HttpException,
Param,
Post,
Put,
@ -15,7 +16,6 @@ import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integ
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization, User } 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';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
@ -37,6 +37,7 @@ import {
AuthorizationActions,
Sections,
} from '@gitroom/backend/services/auth/permissions/permission.exception.class';
import { uniqBy } from 'lodash';
@ApiTags('Integrations')
@Controller('/integrations')
@ -246,11 +247,68 @@ export class IntegrationsController {
) {
return this._integrationService.setTimes(org.id, id, body);
}
@Post('/mentions')
async mentions(
@GetOrgFromRequest() org: Organization,
@Body() body: IntegrationFunctionDto
) {
const getIntegration = await this._integrationService.getIntegrationById(
org.id,
body.id
);
if (!getIntegration) {
throw new Error('Invalid integration');
}
let newList: any[] | { none: true } = [];
try {
newList = (await this.functionIntegration(org, body)) || [];
} catch (err) {
console.log(err);
}
if (!Array.isArray(newList) && newList?.none) {
return newList;
}
const list = await this._integrationService.getMentions(
getIntegration.providerIdentifier,
body?.data?.query
);
if (Array.isArray(newList) && newList.length) {
await this._integrationService.insertMentions(
getIntegration.providerIdentifier,
newList
.map((p: any) => ({
name: p.label || '',
username: p.id || '',
image: p.image || '',
doNotCache: p.doNotCache || false,
}))
.filter((f: any) => f.name && !f.doNotCache)
);
}
return uniqBy(
[
...list.map((p) => ({
id: p.username,
image: p.image,
label: p.name,
})),
...(newList as any[]),
],
(p) => p.id
).filter((f) => f.label && f.id);
}
@Post('/function')
async functionIntegration(
@GetOrgFromRequest() org: Organization,
@Body() body: IntegrationFunctionDto
) {
): Promise<any> {
const getIntegration = await this._integrationService.getIntegrationById(
org.id,
body.id
@ -266,8 +324,10 @@ export class IntegrationsController {
throw new Error('Invalid provider');
}
// @ts-ignore
if (integrationProvider[body.name]) {
try {
// @ts-ignore
const load = await integrationProvider[body.name](
getIntegration.token,
body.data,
@ -427,6 +487,18 @@ export class IntegrationsController {
validName = `Channel_${String(id).slice(0, 8)}`;
}
}
if (
process.env.STRIPE_PUBLISHABLE_KEY &&
org.isTrailing &&
(await this._integrationService.checkPreviousConnections(
org.id,
String(id)
))
) {
throw new HttpException('', 412);
}
return this._integrationService.createOrUpdateIntegration(
additionalSettings,
!!integrationProvider.oneTimeToken,

View file

@ -1,40 +0,0 @@
import {
Body,
Controller,
HttpException,
Param,
Post,
Sse,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { McpService } from '@gitroom/nestjs-libraries/mcp/mcp.service';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
@ApiTags('Mcp')
@Controller('/mcp')
export class McpController {
constructor(
private _mcpService: McpService,
private _organizationService: OrganizationService
) {}
@Sse('/:api/sse')
async sse(@Param('api') api: string) {
const apiModel = await this._organizationService.getOrgByApiKey(api);
if (!apiModel) {
throw new HttpException('Invalid url', 400);
}
return await this._mcpService.runServer(api, apiModel.id);
}
@Post('/:api/messages')
async post(@Param('api') api: string, @Body() body: any) {
const apiModel = await this._organizationService.getOrgByApiKey(api);
if (!apiModel) {
throw new HttpException('Invalid url', 400);
}
return this._mcpService.processPostBody(apiModel.id, body);
}
}

View file

@ -1,4 +1,14 @@
import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
Post,
Query,
Req,
Res,
StreamableFile,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AgenciesService } from '@gitroom/nestjs-libraries/database/prisma/agencies/agencies.service';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
@ -11,6 +21,10 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
import { AgentGraphInsertService } from '@gitroom/nestjs-libraries/agent/agent.graph.insert.service';
import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments';
import { Readable, pipeline } from 'stream';
import { promisify } from 'util';
const pump = promisify(pipeline);
@ApiTags('Public')
@Controller('/public')
@ -136,4 +150,46 @@ export class PublicController {
console.log('cryptoPost', body, path);
return this._nowpayments.processPayment(path, body);
}
@Get('/stream')
async streamFile(
@Query('url') url: string,
@Res() res: Response,
@Req() req: Request
) {
if (!url.endsWith('mp4')) {
return res.status(400).send('Invalid video URL');
}
const ac = new AbortController();
const onClose = () => ac.abort();
req.on('aborted', onClose);
res.on('close', onClose);
const r = await fetch(url, { signal: ac.signal });
if (!r.ok && r.status !== 206) {
res.status(r.status);
throw new Error(`Upstream error: ${r.statusText}`);
}
const type = r.headers.get('content-type') ?? 'application/octet-stream';
res.setHeader('Content-Type', type);
const contentRange = r.headers.get('content-range');
if (contentRange) res.setHeader('Content-Range', contentRange);
const len = r.headers.get('content-length');
if (len) res.setHeader('Content-Length', len);
const acceptRanges = r.headers.get('accept-ranges') ?? 'bytes';
res.setHeader('Accept-Ranges', acceptRanges);
if (r.status === 206) res.status(206); // Partial Content for range responses
try {
await pump(Readable.fromWeb(r.body as any), res);
} catch (err) {
}
}
}

View file

@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
@ -28,6 +28,14 @@ export class SignatureController {
return this._signatureService.createOrUpdateSignature(org.id, body);
}
@Delete('/:id')
async deleteSignature(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return this._signatureService.deleteSignature(org.id, id);
}
@Put('/:id')
async updateSignature(
@Param('id') id: string,

View file

@ -203,6 +203,7 @@ export class UsersController {
// Clear Sentry user context on logout
clearSentryUserContext();
response.header('logout', 'true');
response.cookie('auth', '', {
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
...(!process.env.NOT_SECURED

View file

@ -8,11 +8,11 @@ import { PublicApiModule } from '@gitroom/backend/public-api/public.api.module';
import { ThrottlerBehindProxyGuard } from '@gitroom/nestjs-libraries/throttler/throttler.provider';
import { ThrottlerModule } from '@nestjs/throttler';
import { AgentModule } from '@gitroom/nestjs-libraries/agent/agent.module';
import { McpModule } from '@gitroom/backend/mcp/mcp.module';
import { ThirdPartyModule } from '@gitroom/nestjs-libraries/3rdparties/thirdparty.module';
import { VideoModule } from '@gitroom/nestjs-libraries/videos/video.module';
import { SentryModule } from "@sentry/nestjs/setup";
import { SentryModule } from '@sentry/nestjs/setup';
import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
import { ChatModule } from '@gitroom/nestjs-libraries/chat/chat.module';
@Global()
@Module({
@ -23,9 +23,9 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
ApiModule,
PublicApiModule,
AgentModule,
McpModule,
ThirdPartyModule,
VideoModule,
ChatModule,
ThrottlerModule.forRoot([
{
ttl: 3600000,
@ -43,7 +43,7 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
{
provide: APP_GUARD,
useClass: PoliciesGuard,
}
},
],
exports: [
BullMqModule,
@ -51,8 +51,8 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
ApiModule,
PublicApiModule,
AgentModule,
McpModule,
ThrottlerModule,
ChatModule,
],
})
export class AppModule {}

View file

@ -1,4 +1,8 @@
import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry';
initializeSentry('backend', true);
import { loadSwagger } from '@gitroom/helpers/swagger/load.swagger';
import { json } from 'express';
process.env.TZ = 'UTC';
@ -7,37 +11,44 @@ import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry';
initializeSentry('backend', true);
import { SubscriptionExceptionFilter } from '@gitroom/backend/services/auth/permissions/subscription.exception';
import { HttpExceptionFilter } from '@gitroom/nestjs-libraries/services/exception.filter';
import { ConfigurationChecker } from '@gitroom/helpers/configuration/configuration.checker';
import { startMcp } from '@gitroom/nestjs-libraries/chat/start.mcp';
async function bootstrap() {
async function start() {
const app = await NestFactory.create(AppModule, {
rawBody: true,
cors: {
...(!process.env.NOT_SECURED ? { credentials: true } : {}),
allowedHeaders: ['Content-Type', 'Authorization', 'x-copilotkit-runtime-client-gql-version'],
exposedHeaders: [
'reload',
'onboarding',
'activate',
'x-copilotkit-runtime-client-gql-version',
...(process.env.NOT_SECURED ? ['auth', 'showorg', 'impersonate'] : []),
],
origin: [
process.env.FRONTEND_URL,
'http://localhost:6274',
...(process.env.MAIN_URL ? [process.env.MAIN_URL] : []),
],
},
});
await startMcp(app);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
})
);
app.use('/copilot/*', (req: any, res: any, next: any) => {
json({ limit: '50mb' })(req, res, next);
});
app.use(cookieParser());
app.useGlobalFilters(new SubscriptionExceptionFilter());
app.useGlobalFilters(new HttpExceptionFilter());
@ -69,8 +80,8 @@ function checkConfiguration() {
Logger.warn('Configuration issues found: ' + checker.getIssuesCount());
} else {
Logger.log('Configuration check completed without any issues.');
Logger.log('Configuration check completed without any issues');
}
}
bootstrap();
start();

View file

@ -1,113 +0,0 @@
import { Injectable } from '@nestjs/common';
import { McpTool } from '@gitroom/nestjs-libraries/mcp/mcp.tool';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { string, array, enum as eenum, object, boolean } from 'zod';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import dayjs from 'dayjs';
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
@Injectable()
export class MainMcp {
constructor(
private _integrationService: IntegrationService,
private _postsService: PostsService,
private _openAiService: OpenaiService
) {}
@McpTool({ toolName: 'POSTIZ_GET_CONFIG_ID' })
async preRun() {
return [
{
type: 'text',
text: `id: ${makeId(10)} Today date is ${dayjs.utc().format()}`,
},
];
}
@McpTool({ toolName: 'POSTIZ_PROVIDERS_LIST' })
async listOfProviders(organization: string) {
const list = (
await this._integrationService.getIntegrationsList(organization)
).map((org) => ({
id: org.id,
name: org.name,
identifier: org.providerIdentifier,
picture: org.picture,
disabled: org.disabled,
profile: org.profile,
customer: org.customer
? {
id: org.customer.id,
name: org.customer.name,
}
: undefined,
}));
return [{ type: 'text', text: JSON.stringify(list) }];
}
@McpTool({
toolName: 'POSTIZ_SCHEDULE_POST',
zod: {
type: eenum(['draft', 'scheduled']),
configId: string(),
generatePictures: boolean(),
date: string().describe('UTC TIME'),
providerId: string().describe('Use POSTIZ_PROVIDERS_LIST to get the id'),
posts: array(object({ text: string(), images: array(string()) })),
},
})
async schedulePost(
organization: string,
obj: {
type: 'draft' | 'schedule';
generatePictures: boolean;
date: string;
providerId: string;
posts: { text: string }[];
}
) {
const create = await this._postsService.createPost(organization, {
date: obj.date,
type: obj.type,
tags: [],
shortLink: false,
posts: [
{
group: makeId(10),
value: await Promise.all(
obj.posts.map(async (post) => ({
content: post.text,
id: makeId(10),
image: !obj.generatePictures
? []
: [
{
id: makeId(10),
path: await this._openAiService.generateImage(
post.text,
true
),
},
],
}))
),
settings: {
__type: 'any' as any,
},
integration: {
id: obj.providerId,
},
},
],
});
return [
{
type: 'text',
text: `Post created successfully, check it here: ${process.env.FRONTEND_URL}/p/${create[0].postId}`,
},
];
}
}

View file

@ -1,13 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { MainMcp } from '@gitroom/backend/mcp/main.mcp';
@Global()
@Module({
imports: [],
controllers: [],
providers: [MainMcp],
get exports() {
return [...this.providers];
},
})
export class McpModule {}

View file

@ -34,3 +34,4 @@ export class PublicApiModule implements NestModule {
consumer.apply(PublicAuthMiddleware).forRoutes(...authenticatedController);
}
}

View file

@ -26,12 +26,17 @@ import {
} from '@gitroom/backend/services/auth/permissions/permission.exception.class';
import { VideoDto } from '@gitroom/nestjs-libraries/dtos/videos/video.dto';
import { VideoFunctionDto } from '@gitroom/nestjs-libraries/dtos/videos/video.function.dto';
import { UploadDto } from '@gitroom/nestjs-libraries/dtos/media/upload.dto';
import axios from 'axios';
import { Readable } from 'stream';
import { lookup } from 'mime-types';
import * as Sentry from '@sentry/nestjs';
@ApiTags('Public API')
@Controller('/public/v1')
export class PublicIntegrationsController {
private storage = UploadFactory.createStorage();
constructor(
private _integrationService: IntegrationService,
private _postsService: PostsService,
@ -44,6 +49,7 @@ export class PublicIntegrationsController {
@GetOrgFromRequest() org: Organization,
@UploadedFile('file') file: Express.Multer.File
) {
Sentry.metrics.count("public_api-request", 1);
if (!file) {
throw new HttpException({ msg: 'No file provided' }, 400);
}
@ -56,11 +62,53 @@ export class PublicIntegrationsController {
);
}
@Post('/upload-from-url')
async uploadsFromUrl(
@GetOrgFromRequest() org: Organization,
@Body() body: UploadDto
) {
Sentry.metrics.count("public_api-request", 1);
const response = await axios.get(body.url, {
responseType: 'arraybuffer',
});
const buffer = Buffer.from(response.data);
const getFile = await this.storage.uploadFile({
buffer,
mimetype: lookup(body?.url?.split?.('?')?.[0]) || 'image/jpeg',
size: buffer.length,
path: '',
fieldname: '',
destination: '',
stream: new Readable(),
filename: '',
originalname: '',
encoding: '',
});
return this._mediaService.saveFile(
org.id,
getFile.originalname,
getFile.path
);
}
@Get('/find-slot/:id')
async findSlotIntegration(
@GetOrgFromRequest() org: Organization,
@Param('id') id?: string
) {
Sentry.metrics.count("public_api-request", 1);
return { date: await this._postsService.findFreeDateTime(org.id, id) };
}
@Get('/posts')
async getPosts(
@GetOrgFromRequest() org: Organization,
@Query() query: GetPostsDto
) {
Sentry.metrics.count("public_api-request", 1);
const posts = await this._postsService.getPosts(org.id, query);
return {
posts,
@ -74,6 +122,7 @@ export class PublicIntegrationsController {
@GetOrgFromRequest() org: Organization,
@Body() rawBody: any
) {
Sentry.metrics.count("public_api-request", 1);
const body = await this._postsService.mapTypeToPost(
rawBody,
org.id,
@ -90,17 +139,20 @@ export class PublicIntegrationsController {
@GetOrgFromRequest() org: Organization,
@Param() body: { id: string }
) {
Sentry.metrics.count("public_api-request", 1);
const getPostById = await this._postsService.getPost(org.id, body.id);
return this._postsService.deletePost(org.id, getPostById.group);
}
@Get('/is-connected')
async getActiveIntegrations(@GetOrgFromRequest() org: Organization) {
Sentry.metrics.count("public_api-request", 1);
return { connected: true };
}
@Get('/integrations')
async listIntegration(@GetOrgFromRequest() org: Organization) {
Sentry.metrics.count("public_api-request", 1);
return (await this._integrationService.getIntegrationsList(org.id)).map(
(org) => ({
id: org.id,
@ -124,13 +176,17 @@ export class PublicIntegrationsController {
@GetOrgFromRequest() org: Organization,
@Body() body: VideoDto
) {
Sentry.metrics.count("public_api-request", 1);
return this._mediaService.generateVideo(org, body);
}
@Post('/video/function')
videoFunction(
@Body() body: VideoFunctionDto
) {
return this._mediaService.videoFunction(body.identifier, body.functionName, body.params);
videoFunction(@Body() body: VideoFunctionDto) {
Sentry.metrics.count("public_api-request", 1);
return this._mediaService.videoFunction(
body.identifier,
body.functionName,
body.params
);
}
}

View file

@ -7,6 +7,7 @@ import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/us
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter';
import { setSentryUserContext } from '@gitroom/nestjs-libraries/sentry/sentry.user.context';
import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service';
export const removeAuth = (res: Response) => {
res.cookie('auth', '', {
@ -21,7 +22,6 @@ export const removeAuth = (res: Response) => {
expires: new Date(0),
maxAge: -1,
});
res.header('logout', 'true');
};

View file

@ -7,10 +7,10 @@ import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/o
import { AuthService as AuthChecker } from '@gitroom/helpers/auth/auth.service';
import { ProvidersFactory } from '@gitroom/backend/services/auth/providers/providers.factory';
import dayjs from 'dayjs';
import { NewsletterService } from '@gitroom/nestjs-libraries/services/newsletter.service';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto';
import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
import { NewsletterService } from '@gitroom/nestjs-libraries/newsletter/newsletter.service';
@Injectable()
export class AuthService {
@ -36,10 +36,13 @@ export class AuthService {
addToOrg?: boolean | { orgId: string; role: 'USER' | 'ADMIN'; id: string }
) {
if (provider === Provider.LOCAL) {
if (process.env.DISALLOW_PLUS && body.email.includes('+')) {
throw new Error('Email with plus sign is not allowed');
}
const user = await this._userService.getUserByEmail(body.email);
if (body instanceof CreateOrgUserDto) {
if (user) {
throw new Error('User already exists');
throw new Error('Email already exists');
}
if (!(await this.canRegister(provider))) {

View file

@ -56,7 +56,7 @@ export class OauthProvider implements ProvidersInterface {
redirect_uri: `${this.frontendUrl}/settings`,
});
return `${this.authUrl}/?${params.toString()}`;
return `${this.authUrl}?${params.toString()}`;
}
async getToken(code: string): Promise<string> {

View file

@ -5,7 +5,7 @@
"scripts": {
"dev": "dotenv -e ../../.env -- nest start --watch --entryFile=./apps/cron/src/main",
"build": "cross-env NODE_ENV=production nest build",
"start": "dotenv -e ../../.env -- node ./dist/apps/cron/src/main.js",
"start": "dotenv -e ../../.env -- node --experimental-require-module ./dist/apps/cron/src/main.js",
"pm2": "pm2 start pnpm --name cron -- start"
},
"keywords": [],

View file

@ -4,6 +4,8 @@ import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/databa
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { SentryModule } from '@sentry/nestjs/setup';
import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
import { CheckMissingQueues } from '@gitroom/cron/tasks/check.missing.queues';
import { PostNowPendingQueues } from '@gitroom/cron/tasks/post.now.pending.queues';
@Module({
imports: [
@ -13,8 +15,6 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
BullMqModule,
],
controllers: [],
providers: [
FILTER
],
providers: [FILTER, CheckMissingQueues, PostNowPendingQueues],
})
export class CronModule {}

View file

@ -1,12 +1,12 @@
import { NestFactory } from '@nestjs/core';
import { CronModule } from './cron.module';
import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry';
initializeSentry('cron');
async function bootstrap() {
import { NestFactory } from '@nestjs/core';
import { CronModule } from './cron.module';
async function start() {
// some comment again
await NestFactory.createApplicationContext(CronModule);
}
bootstrap();
start();

View file

@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
import dayjs from 'dayjs';
@Injectable()
export class CheckMissingQueues {
constructor(
private _postService: PostsService,
private _workerServiceProducer: BullMqClient
) {}
@Cron('0 * * * *')
async handleCron() {
const list = await this._postService.searchForMissingThreeHoursPosts();
const notExists = (
await Promise.all(
list.map(async (p) => ({
id: p.id,
publishDate: p.publishDate,
isJob:
['delayed', 'waiting'].indexOf(
await this._workerServiceProducer
.getQueue('post')
.getJobState(p.id)
) > -1,
}))
)
).filter((p) => !p.isJob);
for (const job of notExists) {
this._workerServiceProducer.emit('post', {
id: job.id,
options: {
delay: dayjs(job.publishDate).diff(dayjs(), 'millisecond'),
},
payload: {
id: job.id,
delay: dayjs(job.publishDate).diff(dayjs(), 'millisecond'),
},
});
}
}
}

View file

@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
@Injectable()
export class PostNowPendingQueues {
constructor(
private _postService: PostsService,
private _workerServiceProducer: BullMqClient
) {}
@Cron('*/16 * * * *')
async handleCron() {
const list = await this._postService.checkPending15minutesBack();
const notExists = (
await Promise.all(
list.map(async (p) => ({
id: p.id,
publishDate: p.publishDate,
isJob:
['delayed', 'waiting'].indexOf(
await this._workerServiceProducer
.getQueue('post')
.getJobState(p.id)
) > -1,
}))
)
).filter((p) => !p.isJob);
for (const job of notExists) {
this._workerServiceProducer.emit('post', {
id: job.id,
options: {
delay: 0,
},
payload: {
id: job.id,
delay: 0,
},
});
}
}
}

View file

@ -6,8 +6,31 @@ const nextConfig = {
experimental: {
proxyTimeout: 90_000,
},
// Document-Policy header for browser profiling
async headers() {
return [{
source: "/:path*",
headers: [{
key: "Document-Policy",
value: "js-profiling",
}, ],
}, ];
},
reactStrictMode: false,
transpilePackages: ['crypto-hash'],
// Enable production sourcemaps for Sentry
productionBrowserSourceMaps: true,
// Custom webpack config to ensure sourcemaps are generated properly
webpack: (config, { buildId, dev, isServer, defaultLoaders }) => {
// Enable sourcemaps for both client and server in production
if (!dev) {
config.devtool = isServer ? 'source-map' : 'hidden-source-map';
}
return config;
},
images: {
remotePatterns: [
{
@ -42,9 +65,54 @@ const nextConfig = {
];
},
};
export default withSentryConfig(nextConfig, {
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Sourcemap configuration optimized for monorepo
sourcemaps: {
disable: false,
// More comprehensive asset patterns for monorepo
assets: [
".next/static/**/*.js",
".next/static/**/*.js.map",
".next/server/**/*.js",
".next/server/**/*.js.map",
],
ignore: [
"**/node_modules/**",
"**/*hot-update*",
"**/_buildManifest.js",
"**/_ssgManifest.js",
"**/*.test.js",
"**/*.spec.js",
],
deleteSourcemapsAfterUpload: true,
},
// Release configuration
release: {
create: true,
finalize: true,
// Use git commit hash for releases in monorepo
name: process.env.VERCEL_GIT_COMMIT_SHA || process.env.GITHUB_SHA || undefined,
},
// NextJS specific optimizations for monorepo
widenClientFileUpload: true,
// Additional configuration
telemetry: false,
silent: process.env.NODE_ENV === 'production',
debug: process.env.NODE_ENV === 'development',
// Error handling for CI/CD
errorHandler: (error) => {
console.warn("Sentry build error occurred:", error.message);
console.warn("This might be due to missing Sentry environment variables or network issues");
// Don't fail the build if Sentry upload fails in monorepo context
return;
},
});

View file

@ -6,6 +6,7 @@
"scripts": {
"dev": "dotenv -e ../../.env -- next dev -p 4200",
"build": "next build",
"build:sentry": "dotenv -e ../../.env -- next build",
"start": "dotenv -e ../../.env -- next start -p 4200",
"pm2": "pm2 start pnpm --name frontend -- start"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -10,6 +10,16 @@ import utc from 'dayjs/plugin/utc';
import { VideoOrImage } from '@gitroom/react/helpers/video.or.image';
import { CopyClient } from '@gitroom/frontend/components/preview/copy.client';
import { getT } from '@gitroom/react/translation/get.translation.service.backend';
import dynamicLoad from 'next/dynamic';
const RenderPreviewDate = dynamicLoad(
() =>
import('@gitroom/frontend/components/preview/render.preview.date').then(
(mod) => mod.RenderPreviewDate
),
{ ssr: false }
);
dayjs.extend(utc);
export const metadata: Metadata = {
title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Preview`,
@ -91,11 +101,8 @@ export default async function Auth({
</div>
)}
<div className="flex-1">
{t('publication_date', 'Publication Date:')}
{dayjs
.utc(post[0].publishDate)
.local()
.format('MMMM D, YYYY h:mm A')}
{t('publication_date', 'Publication Date:')}{' '}
<RenderPreviewDate date={post[0].publishDate} />
</div>
</div>
</div>

View file

@ -0,0 +1,12 @@
import { Metadata } from 'next';
import { Agent } from '@gitroom/frontend/components/agents/agent';
import { AgentChat } from '@gitroom/frontend/components/agents/agent.chat';
export const metadata: Metadata = {
title: 'Postiz - Agent',
description: '',
};
export default async function Page() {
return (
<AgentChat />
);
}

View file

@ -0,0 +1,13 @@
import { Metadata } from 'next';
import { Agent } from '@gitroom/frontend/components/agents/agent';
export const metadata: Metadata = {
title: 'Postiz - Agent',
description: '',
};
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
return <Agent>{children}</Agent>;
}

View file

@ -0,0 +1,11 @@
import { Metadata } from 'next';
import { redirect } from 'next/navigation';
export const metadata: Metadata = {
title: 'Postiz - Agent',
description: '',
};
export default async function Page() {
return redirect('/agents/new');
}

View file

@ -18,6 +18,13 @@ import { FacebookComponent } from '@gitroom/frontend/components/layout/facebook.
import { headers } from 'next/headers';
import { headerName } from '@gitroom/react/translation/i18n.config';
import { HtmlComponent } from '@gitroom/frontend/components/layout/html.component';
// import dynamicLoad from 'next/dynamic';
// const SetTimezone = dynamicLoad(
// () => import('@gitroom/frontend/components/layout/set.timezone'),
// {
// ssr: false,
// }
// );
const jakartaSans = Plus_Jakarta_Sans({
weight: ['600', '500'],
@ -72,6 +79,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
}
>
<SentryComponent>
{/*<SetTimezone />*/}
<HtmlComponent />
<ToltScript />
<FacebookComponent />

View file

@ -22,7 +22,8 @@
--new-col-color: #2c2b2b;
--new-menu-dots: #696868;
--new-menu-hover: #fff;
--menu-shadow: 0 8px 30px 0 rgba(0, 0, 0, 0.50);
--menu-shadow: 0 8px 30px 0 rgba(0, 0, 0, 0.5);
--popup-color: rgba(65, 64, 66, 0.3);
}
.light {
--new-bgColor: #f0f2f4;
@ -33,8 +34,8 @@
--new-boxFocused: #ebe8ff;
--new-textColor: 14 14 14;
--new-blockSeparator: #f2f2f4;
--new-btn-simple: #ECEEF1;
--new-btn-text: #0E0E0E;
--new-btn-simple: #eceef1;
--new-btn-text: #0e0e0e;
--new-btn-primary: #612bd3;
--new-ai-btn: #d82d7e;
--new-box-hover: #f4f6f8;
@ -43,15 +44,19 @@
--new-table-text: #777b7f;
--new-table-text-focused: #fc69ff;
--new-small-strips: #191818;
--new-big-strips: #F5F7F9;
--new-col-color: #EFF1F3;
--new-big-strips: #f5f7f9;
--new-col-color: #eff1f3;
--new-menu-dots: #696868;
--new-menu-hover: #000;
--menu-shadow: -22px 83px 24px 0 rgba(55, 52, 75, 0.00), -14px 53px 22px 0 rgba(55, 52, 75, 0.01), -8px 30px 19px 0 rgba(55, 52, 75, 0.05), -3px 13px 14px 0 rgba(55, 52, 75, 0.09), -1px 3px 8px 0 rgba(55, 52, 75, 0.10);
--menu-shadow: -22px 83px 24px 0 rgba(55, 52, 75, 0),
-14px 53px 22px 0 rgba(55, 52, 75, 0.01),
-8px 30px 19px 0 rgba(55, 52, 75, 0.05),
-3px 13px 14px 0 rgba(55, 52, 75, 0.09),
-1px 3px 8px 0 rgba(55, 52, 75, 0.1);
--popup-color: rgba(55, 37, 97, 0.2);
}
}
:root {
.dark {
--color-primary: #0e0e0e;
@ -193,4 +198,4 @@
--color-custom55: #d5d7e1;
--color-modalCustom: transparent;
}
}
}

View file

@ -357,6 +357,10 @@ body * {
// /*right: -5rem !important;*/
//}
.trz {
transform: translateZ(0);
}
.uppy-FileInput-container {
@apply cursor-pointer font-[500] flex justify-center items-center gap-[4px] text-[12px] rounded-[4px] w-[107px] h-[25px] text-textColor border-[2px];
@apply bg-customColor3 border-customColor21;
@ -626,3 +630,77 @@ html[dir='rtl'] [dir='ltr'] {
.mantine-Overlay-root {
background: rgba(65, 64, 66, 0.3) !important;
}
.dropdown-menu {
@apply shadow-menu;
background: var(--new-bgColorInner);
border: 1px solid var(--new-bgLineColor);
border-radius: 18px;
display: flex;
flex-direction: column;
overflow: auto;
position: relative;
button {
align-items: center;
background-color: transparent;
display: flex;
text-align: left;
width: 100%;
padding: 10px;
&:hover,
&:hover.is-selected {
background-color: var(--new-bgLineColor);
}
}
}
.tiptap {
a {
@apply underline;
}
}
.tiptap {
:first-child {
margin-top: 0;
}
.mention {
background-color: var(--purple-light);
border-radius: 0.4rem;
box-decoration-break: clone;
color: #ae8afc;
&::after {
content: '\200B';
}
}
}
.blur-xs {
filter: blur(4px);
}
.agent {
.copilotKitInputContainer {
padding: 0 24px !important;
}
.copilotKitInput {
width: 100% !important;
}
}
.rm-bg .b2 {
padding-top: 0 !important;
}
.rm-bg .b1 {
background: transparent !important;
gap: 0 !important;
}
.copilotKitMessage img {
width: 200px;
}
.copilotKitMessage a {
color: var(--new-btn-text) !important;
}

View file

@ -0,0 +1,359 @@
'use client';
import React, {
FC,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { CopilotChat, CopilotKitCSSProperties } from '@copilotkit/react-ui';
import {
InputProps,
UserMessageProps,
} from '@copilotkit/react-ui/dist/components/chat/props';
import { Input } from '@gitroom/frontend/components/agents/agent.input';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import {
CopilotKit,
useCopilotAction,
useCopilotMessagesContext,
} from '@copilotkit/react-core';
import {
MediaPortal,
PropertiesContext,
} from '@gitroom/frontend/components/agents/agent';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { useParams } from 'next/navigation';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { TextMessage } from '@copilotkit/runtime-client-gql';
import { AddEditModal } from '@gitroom/frontend/components/new-launch/add.edit.modal';
import dayjs from 'dayjs';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { ExistingDataContextProvider } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
export const AgentChat: FC = () => {
const { backendUrl } = useVariables();
const params = useParams<{ id: string }>();
const { properties } = useContext(PropertiesContext);
return (
<CopilotKit
{...(params.id === 'new' ? {} : { threadId: params.id })}
credentials="include"
runtimeUrl={backendUrl + '/copilot/agent'}
showDevConsole={false}
agent="postiz"
properties={{
integrations: properties,
}}
>
<Hooks />
<LoadMessages id={params.id} />
<div
style={
{
'--copilot-kit-primary-color': 'var(--new-btn-text)',
'--copilot-kit-background-color': 'var(--new-bg-color)',
} as CopilotKitCSSProperties
}
className="trz agent bg-newBgColorInner flex flex-col gap-[15px] transition-all flex-1 items-center relative"
>
<div className="absolute left-0 w-full h-full pb-[20px]">
<CopilotChat
className="w-full h-full"
labels={{
title: 'Your Assistant',
initial: `Hello, I am your Postiz agent 🙌🏻.
I can schedule a post or multiple posts to multiple channels and generate pictures and videos.
You can select the channels you want to use from the left menu.
You can see your previous conversations from the right menu.
You can also use me as an MCP Server, check Settings >> Public API
`,
}}
UserMessage={Message}
Input={NewInput}
/>
</div>
</div>
</CopilotKit>
);
};
const LoadMessages: FC<{ id: string }> = ({ id }) => {
const { setMessages } = useCopilotMessagesContext();
const fetch = useFetch();
const loadMessages = useCallback(async (idToSet: string) => {
const data = await (await fetch(`/copilot/${idToSet}/list`)).json();
setMessages(
data.uiMessages.map((p: any) => {
return new TextMessage({
content: p.content,
role: p.role,
});
})
);
}, []);
useEffect(() => {
if (id === 'new') {
setMessages([]);
return;
}
loadMessages(id);
}, [id]);
return null;
};
const Message: FC<UserMessageProps> = (props) => {
const convertContentToImagesAndVideo = useMemo(() => {
return (props.message?.content || '')
.replace(/Video: (http.*mp4\n)/g, (match, p1) => {
return `<video controls class="h-[150px] w-[150px] rounded-[8px] mb-[10px]"><source src="${p1.trim()}" type="video/mp4">Your browser does not support the video tag.</video>`;
})
.replace(/Image: (http.*\n)/g, (match, p1) => {
return `<img src="${p1.trim()}" class="h-[150px] w-[150px] max-w-full border border-newBgColorInner" />`;
})
.replace(/\[\-\-Media\-\-\](.*)\[\-\-Media\-\-\]/g, (match, p1) => {
return `<div class="flex justify-center mt-[20px]">${p1}</div>`;
})
.replace(
/(\[--integrations--\][\s\S]*?\[--integrations--\])/g,
(match, p1) => {
return ``;
}
);
}, [props.message?.content]);
return (
<div
className="copilotKitMessage copilotKitUserMessage min-w-[300px]"
dangerouslySetInnerHTML={{ __html: convertContentToImagesAndVideo }}
/>
);
};
const NewInput: FC<InputProps> = (props) => {
const [media, setMedia] = useState([] as { path: string; id: string }[]);
const [value, setValue] = useState('');
const { properties } = useContext(PropertiesContext);
return (
<>
<MediaPortal
value={value}
media={media}
setMedia={(e) => setMedia(e.target.value)}
/>
<Input
{...props}
onChange={setValue}
onSend={(text) => {
const send = props.onSend(
text +
(media.length > 0
? '\n[--Media--]' +
media
.map((m) =>
m.path.indexOf('mp4') > -1
? `Video: ${m.path}`
: `Image: ${m.path}`
)
.join('\n') +
'\n[--Media--]'
: '') +
`
${
properties.length
? `[--integrations--]
Use the following social media platforms: ${JSON.stringify(
properties.map((p) => ({
id: p.id,
platform: p.identifier,
profilePicture: p.picture,
additionalSettings: p.additionalSettings,
}))
)}
[--integrations--]`
: ``
}`
);
setValue('');
setMedia([]);
return send;
}}
/>
</>
);
};
export const Hooks: FC = () => {
const modals = useModals();
useCopilotAction({
name: 'manualPosting',
description:
'This tool should be triggered when the user wants to manually add the generated post',
parameters: [
{
name: 'list',
type: 'object[]',
description:
'list of posts to schedule to different social media (integration ids)',
attributes: [
{
name: 'integrationId',
type: 'string',
description: 'The integration id',
},
{
name: 'date',
type: 'string',
description: 'UTC date of the scheduled post',
},
{
name: 'settings',
type: 'object',
description: 'Settings for the integration [input:settings]',
},
{
name: 'posts',
type: 'object[]',
description: 'list of posts / comments (one under another)',
attributes: [
{
name: 'content',
type: 'string',
description: 'the content of the post',
},
{
name: 'attachments',
type: 'object[]',
description: 'list of attachments',
attributes: [
{
name: 'id',
type: 'string',
description: 'id of the attachment',
},
{
name: 'path',
type: 'string',
description: 'url of the attachment',
},
],
},
],
},
],
},
],
renderAndWaitForResponse: ({ args, status, respond }) => {
if (status === 'executing') {
return <OpenModal args={args} respond={respond} />;
}
return null;
},
});
return null;
};
const OpenModal: FC<{
respond: (value: any) => void;
args: {
list: {
integrationId: string;
date: string;
settings?: Record<string, any>;
posts: { content: string; attachments: { id: string; path: string }[] }[];
}[];
};
}> = ({ args, respond }) => {
const modals = useModals();
const { properties } = useContext(PropertiesContext);
const startModal = useCallback(async () => {
for (const integration of args.list) {
await new Promise((res) => {
const group = makeId(10);
modals.openModal({
id: 'add-edit-modal',
closeOnClickOutside: false,
removeLayout: true,
closeOnEscape: false,
withCloseButton: false,
askClose: true,
size: '80%',
title: ``,
classNames: {
modal: 'w-[100%] max-w-[1400px] text-textColor',
},
children: (
<ExistingDataContextProvider
value={{
group,
integration: integration.integrationId,
integrationPicture:
properties.find((p) => p.id === integration.integrationId)
.picture || '',
settings: integration.settings || {},
posts: integration.posts.map((p) => ({
approvedSubmitForOrder: 'NO',
content: p.content,
createdAt: new Date().toISOString(),
state: 'DRAFT',
id: makeId(10),
settings: JSON.stringify(integration.settings || {}),
group,
integrationId: integration.integrationId,
integration: properties.find(
(p) => p.id === integration.integrationId
),
publishDate: dayjs.utc(integration.date).toISOString(),
image: p.attachments.map((a) => ({
id: a.id,
path: a.path,
})),
})),
}}
>
<AddEditModal
date={dayjs.utc(integration.date)}
allIntegrations={properties}
integrations={properties.filter(
(p) => p.id === integration.integrationId
)}
onlyValues={integration.posts.map((p) => ({
content: p.content,
id: makeId(10),
settings: integration.settings || {},
image: p.attachments.map((a) => ({
id: a.id,
path: a.path,
})),
}))}
reopenModal={() => {}}
mutate={() => res(true)}
/>
</ExistingDataContextProvider>
),
});
});
}
respond('User scheduled all the posts');
}, [args, respond, properties]);
useEffect(() => {
startModal();
}, []);
return (
<div onClick={() => respond('continue')}>
Opening manually ${JSON.stringify(args)}
</div>
);
};

View file

@ -0,0 +1,120 @@
import React, { useMemo, useRef, useState } from 'react';
import { useCopilotContext, useCopilotReadable } from '@copilotkit/react-core';
import AutoResizingTextarea from '@gitroom/frontend/components/agents/agent.textarea';
import { useChatContext } from '@copilotkit/react-ui';
import { InputProps } from '@copilotkit/react-ui/dist/components/chat/props';
const MAX_NEWLINES = 6;
export const Input = ({
inProgress,
onSend,
isVisible = false,
onStop,
onUpload,
hideStopButton = false,
onChange,
}: InputProps & { onChange: (value: string) => void }) => {
const context = useChatContext();
const copilotContext = useCopilotContext();
const showPoweredBy = !copilotContext.copilotApiConfig?.publicApiKey;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isComposing, setIsComposing] = useState(false);
const handleDivClick = (event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
// If the user clicked a button or inside a button, don't focus the textarea
if (target.closest('button')) return;
// If the user clicked the textarea, do nothing (it's already focused)
if (target.tagName === 'TEXTAREA') return;
// Otherwise, focus the textarea
textareaRef.current?.focus();
};
const [text, setText] = useState('');
const send = () => {
if (inProgress) return;
onSend(text);
setText('');
textareaRef.current?.focus();
};
const isInProgress = inProgress;
const buttonIcon =
isInProgress && !hideStopButton
? context.icons.stopIcon
: context.icons.sendIcon;
const canSend = useMemo(() => {
const interruptEvent = copilotContext.langGraphInterruptAction?.event;
const interruptInProgress =
interruptEvent?.name === 'LangGraphInterruptEvent' &&
!interruptEvent?.response;
return !isInProgress && text.trim().length > 0 && !interruptInProgress;
}, [copilotContext.langGraphInterruptAction?.event, isInProgress, text]);
const canStop = useMemo(() => {
return isInProgress && !hideStopButton;
}, [isInProgress, hideStopButton]);
const sendDisabled = !canSend && !canStop;
return (
<div
className={`copilotKitInputContainer ${
showPoweredBy ? 'poweredByContainer' : ''
}`}
>
<div className="copilotKitInput" onClick={handleDivClick}>
<AutoResizingTextarea
ref={textareaRef}
placeholder={context.labels.placeholder}
autoFocus={false}
maxRows={MAX_NEWLINES}
value={text}
onChange={(event) => {
onChange(event.target.value);
setText(event.target.value);
}}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !isComposing) {
event.preventDefault();
if (canSend) {
send();
}
}
}}
/>
<div className="copilotKitInputControls">
{onUpload && (
<button onClick={onUpload} className="copilotKitInputControlButton">
{context.icons.uploadIcon}
</button>
)}
<div style={{ flexGrow: 1 }} />
<button
disabled={sendDisabled}
onClick={isInProgress && !hideStopButton ? onStop : send}
data-copilotkit-in-progress={inProgress}
data-test-id={
inProgress
? 'copilot-chat-request-in-progress'
: 'copilot-chat-ready'
}
className="copilotKitInputControlButton"
>
{buttonIcon}
</button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,77 @@
import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react";
interface AutoResizingTextareaProps {
maxRows?: number;
placeholder?: string;
value: string;
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCompositionStart?: () => void;
onCompositionEnd?: () => void;
autoFocus?: boolean;
}
const AutoResizingTextarea = forwardRef<HTMLTextAreaElement, AutoResizingTextareaProps>(
(
{
maxRows = 1,
placeholder,
value,
onChange,
onKeyDown,
onCompositionStart,
onCompositionEnd,
autoFocus,
},
ref,
) => {
const internalTextareaRef = useRef<HTMLTextAreaElement>(null);
const [maxHeight, setMaxHeight] = useState<number>(0);
useImperativeHandle(ref, () => internalTextareaRef.current as HTMLTextAreaElement);
useEffect(() => {
const calculateMaxHeight = () => {
const textarea = internalTextareaRef.current;
if (textarea) {
textarea.style.height = "auto";
const singleRowHeight = textarea.scrollHeight;
setMaxHeight(singleRowHeight * maxRows);
if (autoFocus) {
textarea.focus();
}
}
};
calculateMaxHeight();
}, [maxRows]);
useEffect(() => {
const textarea = internalTextareaRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
}
}, [value, maxHeight]);
return (
<textarea
ref={internalTextareaRef}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
placeholder={placeholder}
style={{
overflow: "auto",
resize: "none",
maxHeight: `${maxHeight}px`,
}}
rows={1}
/>
);
},
);
export default AutoResizingTextarea;

View file

@ -0,0 +1,271 @@
'use client';
import React, {
createContext,
FC,
useCallback,
useMemo,
useState,
ReactNode,
} from 'react';
import clsx from 'clsx';
import useCookie from 'react-use-cookie';
import useSWR from 'swr';
import { orderBy } from 'lodash';
import { SVGLine } from '@gitroom/frontend/components/launches/launches.component';
import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback';
import Image from 'next/image';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useWaitForClass } from '@gitroom/helpers/utils/use.wait.for.class';
import { MultiMediaComponent } from '@gitroom/frontend/components/media/media.component';
import { Integration } from '@prisma/client';
import Link from 'next/link';
import { useParams, usePathname, useRouter } from 'next/navigation';
export const MediaPortal: FC<{
media: { path: string; id: string }[];
value: string;
setMedia: (event: {
target: {
name: string;
value?: {
id: string;
path: string;
alt?: string;
thumbnail?: string;
thumbnailTimestamp?: number;
}[];
};
}) => void;
}> = ({ media, setMedia, value }) => {
const waitForClass = useWaitForClass('copilotKitMessages');
if (!waitForClass) return null;
return (
<div className="pl-[14px] pr-[24px] whitespace-nowrap editor rm-bg">
<MultiMediaComponent
allData={[{ content: value }]}
text={value}
label="Attachments"
description=""
value={media}
dummy={false}
name="image"
onChange={setMedia}
onOpen={() => {}}
onClose={() => {}}
/>
</div>
);
};
export const AgentList: FC<{ onChange: (arr: any[]) => void }> = ({
onChange,
}) => {
const fetch = useFetch();
const [selected, setSelected] = useState([]);
const load = useCallback(async () => {
return (await (await fetch('/integrations/list')).json()).integrations;
}, []);
const [collapseMenu, setCollapseMenu] = useCookie('collapseMenu', '0');
const { data } = useSWR('integrations', load, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
revalidateOnMount: true,
refreshWhenHidden: false,
refreshWhenOffline: false,
fallbackData: [],
});
const setIntegration = useCallback(
(integration: Integration) => () => {
if (selected.some((p) => p.id === integration.id)) {
onChange(selected.filter((p) => p.id !== integration.id));
setSelected(selected.filter((p) => p.id !== integration.id));
} else {
onChange([...selected, integration]);
setSelected([...selected, integration]);
}
},
[selected]
);
const sortedIntegrations = useMemo(() => {
return orderBy(
data || [],
['type', 'disabled', 'identifier'],
['desc', 'asc', 'asc']
);
}, [data]);
return (
<div
className={clsx(
'trz bg-newBgColorInner flex flex-col gap-[15px] transition-all relative',
collapseMenu === '1' ? 'group sidebar w-[100px]' : 'w-[260px]'
)}
>
<div className="absolute top-0 start-0 w-full h-full p-[20px] overflow-auto scrollbar scrollbar-thumb-fifth scrollbar-track-newBgColor">
<div className="flex items-center">
<h2 className="group-[.sidebar]:hidden flex-1 text-[20px] font-[500] mb-[15px]">
Select Channels
</h2>
<div
onClick={() => setCollapseMenu(collapseMenu === '1' ? '0' : '1')}
className="-mt-3 group-[.sidebar]:rotate-[180deg] group-[.sidebar]:mx-auto text-btnText bg-btnSimple rounded-[6px] w-[24px] h-[24px] flex items-center justify-center cursor-pointer select-none"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="7"
height="13"
viewBox="0 0 7 13"
fill="none"
>
<path
d="M6 11.5L1 6.5L6 1.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
<div className={clsx('flex flex-col gap-[15px]')}>
{sortedIntegrations.map((integration, index) => (
<div
onClick={setIntegration(integration)}
key={integration.id}
className={clsx(
'flex gap-[12px] items-center group/profile justify-center hover:bg-boxHover rounded-e-[8px] hover:opacity-100 cursor-pointer',
!selected.some((p) => p.id === integration.id) && 'opacity-20'
)}
>
<div
className={clsx(
'relative rounded-full flex justify-center items-center gap-[6px]',
integration.disabled && 'opacity-50'
)}
>
{(integration.inBetweenSteps || integration.refreshNeeded) && (
<div className="absolute start-0 top-0 w-[39px] h-[46px] cursor-pointer">
<div className="bg-red-500 w-[15px] h-[15px] rounded-full start-0 -top-[5px] absolute z-[200] text-[10px] flex justify-center items-center">
!
</div>
<div className="bg-primary/60 w-[39px] h-[46px] start-0 top-0 absolute rounded-full z-[199]" />
</div>
)}
<div className="h-full w-[4px] -ms-[12px] rounded-s-[3px] opacity-0 group-hover/profile:opacity-100 transition-opacity">
<SVGLine />
</div>
<ImageWithFallback
fallbackSrc={`/icons/platforms/${integration.identifier}.png`}
src={integration.picture}
className="rounded-[8px]"
alt={integration.identifier}
width={36}
height={36}
/>
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-[8px] absolute z-10 bottom-[5px] -end-[5px] border border-fifth"
alt={integration.identifier}
width={18.41}
height={18.41}
/>
</div>
<div
className={clsx(
'flex-1 whitespace-nowrap text-ellipsis overflow-hidden group-[.sidebar]:hidden',
integration.disabled && 'opacity-50'
)}
>
{integration.name}
</div>
</div>
))}
</div>
</div>
</div>
);
};
export const PropertiesContext = createContext({ properties: [] });
export const Agent: FC<{ children: ReactNode }> = ({ children }) => {
const [properties, setProperties] = useState([]);
return (
<PropertiesContext.Provider value={{ properties }}>
<AgentList onChange={setProperties} />
<div className="bg-newBgColorInner flex flex-1">{children}</div>
<Threads />
</PropertiesContext.Provider>
);
};
const Threads: FC = () => {
const fetch = useFetch();
const router = useRouter();
const pathname = usePathname();
const threads = useCallback(async () => {
return (await fetch('/copilot/list')).json();
}, []);
const { id } = useParams<{ id: string }>();
const { data } = useSWR('threads', threads);
return (
<div
className={clsx(
'trz bg-newBgColorInner flex flex-col gap-[15px] transition-all relative',
'w-[260px]'
)}
>
<div className="absolute top-0 start-0 w-full h-full p-[20px] overflow-auto scrollbar scrollbar-thumb-fifth scrollbar-track-newBgColor">
<div className="mb-[15px] justify-center flex group-[.sidebar]:pb-[15px]">
<Link
href={`/agents`}
className="text-white whitespace-nowrap flex-1 pt-[12px] pb-[14px] ps-[16px] pe-[20px] group-[.sidebar]:p-0 min-h-[44px] max-h-[44px] rounded-md bg-btnPrimary flex justify-center items-center gap-[5px] outline-none"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="21"
height="20"
viewBox="0 0 21 20"
fill="none"
className="min-w-[21px] min-h-[20px]"
>
<path
d="M10.5001 4.16699V15.8337M4.66675 10.0003H16.3334"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div className="flex-1 text-start text-[16px] group-[.sidebar]:hidden">
Start a new chat
</div>
</Link>
</div>
<div className="flex flex-col gap-[1px]">
{data?.threads?.map((p: any) => (
<Link
className={clsx(
'overflow-ellipsis overflow-hidden whitespace-nowrap hover:bg-newBgColor px-[10px] py-[6px] rounded-[10px] cursor-pointer',
p.id === id && 'bg-newBgColor'
)}
href={`/agents/${p.id}`}
key={p.id}
>
{p.title}
</Link>
))}
</div>
</div>
</div>
);
};

View file

@ -7,6 +7,7 @@ import {
StarsList,
} from '@gitroom/frontend/components/analytics/stars.and.forks.interface';
import dayjs from 'dayjs';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
export const Chart: FC<{
list: StarsList[] | ForksList[];
}> = (props) => {
@ -48,7 +49,7 @@ export const Chart: FC<{
},
},
data: {
labels: list.map((row) => dayjs(row.date).format('DD/MM/YYYY')),
labels: list.map((row) => newDayjs(row.date).format('DD/MM/YYYY')),
datasets: [
{
borderColor: '#fff',

View file

@ -116,7 +116,7 @@ export function RegisterAfter({
...data,
}),
})
.then((response) => {
.then(async (response) => {
setLoading(false);
if (response.status === 200) {
fireEvents('register');
@ -129,7 +129,7 @@ export function RegisterAfter({
});
} else {
form.setError('email', {
message: getHelpfulReasonForRegistrationFailure(response.status),
message: await response.text(),
});
}
})

View file

@ -2,7 +2,7 @@ import React, { FC, Fragment, useCallback, useMemo, useState } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { Button } from '@gitroom/react/form/button';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { Input } from '@gitroom/react/form/input';
import { FormProvider, useForm } from 'react-hook-form';
@ -28,11 +28,8 @@ export const Autopost: FC = () => {
const addWebhook = useCallback(
(data?: any) => () => {
modal.openModal({
title: '',
withCloseButton: false,
classNames: {
modal: 'bg-transparent text-textColor',
},
title: data ? 'Edit Autopost' : 'Add Autopost',
withCloseButton: true,
children: <AddOrEditWebhook data={data} reload={mutate} />,
});
},
@ -228,7 +225,14 @@ export const AddOrEditWebhook: FC<{
},
[]
);
const { data: dataList, isLoading } = useSWR('integrations', integration);
const { data: dataList, isLoading } = useSWR('integrations', integration, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
revalidateOnMount: true,
refreshWhenHidden: false,
refreshWhenOffline: false,
});
const callBack = useCallback(
async (values: any) => {
await fetch(data?.id ? `/autopost/${data?.id}` : '/autopost', {
@ -289,29 +293,7 @@ export const AddOrEditWebhook: FC<{
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(callBack)}>
<div className="relative flex gap-[20px] flex-col flex-1 rounded-[4px] border border-customColor6 bg-sixth p-[16px] pt-0 w-[500px]">
<TopTitle title={data ? 'Edit autopost' : 'Add autopost'} />
<button
className="outline-none absolute end-[20px] top-[15px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
onClick={modal.closeAll}
>
<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>
<div className="relative flex gap-[20px] flex-col flex-1 rounded-[4px] border border-customColor6 pt-0">
<div>
<Input
label="Title"

View file

@ -17,7 +17,7 @@ import { useSWRConfig } from 'swr';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { useRouter, useSearchParams } from 'next/navigation';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { Textarea } from '@gitroom/react/form/textarea';
import { useFireEvents } from '@gitroom/helpers/utils/use.fire.events';
@ -28,6 +28,7 @@ import { TrackEnum } from '@gitroom/nestjs-libraries/user/track.enum';
import { PurchaseCrypto } from '@gitroom/frontend/components/billing/purchase.crypto';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { FinishTrial } from '@gitroom/frontend/components/billing/finish.trial';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
export const Prorate: FC<{
period: 'MONTHLY' | 'YEARLY';
@ -376,7 +377,7 @@ export const MainBillingComponent: FC<{
[monthlyOrYearly, subscription, user, utm]
);
if (user?.isLifetime) {
router.replace('/billing/lifetime');
router.replace('/');
return null;
}
return (
@ -504,8 +505,8 @@ export const MainBillingComponent: FC<{
{t(
'your_subscription_will_be_canceled_at',
'Your subscription will be canceled at'
)}
{dayjs(subscription.cancelAt).local().format('D MMM, YYYY')}
)}{' '}
{newDayjs(subscription.cancelAt).local().format('D MMM, YYYY')}
<br />
{t(
'you_will_never_be_charged_again',

View file

@ -1,14 +1,14 @@
'use client';
import { useModals } from '@mantine/modals';
import React, { FC, useCallback, useMemo } from 'react';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import React, { FC, useCallback, useEffect, useMemo } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { Input } from '@gitroom/react/form/input';
import { FieldValues, FormProvider, useForm } from 'react-hook-form';
import { Button } from '@gitroom/react/form/button';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { ApiKeyDto } from '@gitroom/nestjs-libraries/dtos/integrations/api.key.dto';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { useToaster } from '@gitroom/react/toaster/toaster';
@ -24,17 +24,9 @@ export const useAddProvider = (update?: () => void) => {
return useCallback(async () => {
const data = await (await fetch('/integrations')).json();
modal.openModal({
title: '',
withCloseButton: false,
classNames: {
modal: 'text-textColor',
},
size: 'auto',
children: (
<ModalWrapperComponent title="Add Channel">
<AddProviderComponent update={update} {...data} />
</ModalWrapperComponent>
),
title: 'Add Channel',
withCloseButton: true,
children: <AddProviderComponent update={update} {...data} />,
});
}, []);
};
@ -42,9 +34,16 @@ export const AddProviderButton: FC<{
update?: () => void;
}> = (props) => {
const { update } = props;
const query = useSearchParams();
const add = useAddProvider(update);
const t = useT();
useEffect(() => {
if (query.get('onboarding')) {
add();
}
}, []);
return (
<button
className="text-btnText bg-btnSimple h-[44px] pt-[12px] pb-[14px] ps-[16px] pe-[20px] justify-center items-center flex rounded-[8px] gap-[8px]"
@ -259,6 +258,7 @@ export const CustomVariables: FC<{
});
const submit = useCallback(
async (data: FieldValues) => {
modals.closeAll();
gotoUrl(
`/integrations/social/${identifier}?state=nostate&code=${Buffer.from(
JSON.stringify(data)
@ -342,7 +342,7 @@ export const AddProviderComponent: FC<{
await fetch(`/integrations/social/${identifier}`)
).json();
modal.openModal({
title: '',
title: 'Web3 provider',
withCloseButton: false,
classNames: {
modal: 'bg-transparent text-textColor',
@ -379,7 +379,7 @@ export const AddProviderComponent: FC<{
if (isExternal) {
modal.closeAll();
modal.openModal({
title: '',
title: 'URL',
withCloseButton: false,
classNames: {
modal: 'bg-transparent text-textColor',
@ -391,7 +391,7 @@ export const AddProviderComponent: FC<{
if (customFields) {
modal.closeAll();
modal.openModal({
title: '',
title: 'Add Provider',
withCloseButton: false,
classNames: {
modal: 'bg-transparent text-textColor',
@ -410,9 +410,7 @@ export const AddProviderComponent: FC<{
},
[]
);
const close = useCallback(() => {
modal.closeAll();
}, []);
const showApiButton = useCallback(
(identifier: string, name: string) => async () => {
modal.openModal({
@ -434,7 +432,6 @@ export const AddProviderComponent: FC<{
return (
<div className="w-full flex flex-col gap-[20px] rounded-[4px] relative">
<div className="flex flex-col">
<h2 className="pt-[16px] pb-[10px]">{t('social', 'Social')}</h2>
<div className="grid grid-cols-5 gap-[10px] justify-items-center justify-center">
{social.map((item) => (
<div

View file

@ -1,7 +1,7 @@
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import React, { FC, FormEventHandler, useCallback, useState } from 'react';
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { Input } from '@gitroom/react/form/input';
import { Button } from '@gitroom/react/form/button';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';

View file

@ -20,12 +20,13 @@ import isoWeek from 'dayjs/plugin/isoWeek';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import { extend } from 'dayjs';
import useCookie from 'react-use-cookie';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
extend(isoWeek);
extend(weekOfYear);
export const CalendarContext = createContext({
startDate: dayjs().startOf('isoWeek').format('YYYY-MM-DD'),
endDate: dayjs().endOf('isoWeek').format('YYYY-MM-DD'),
startDate: newDayjs().startOf('isoWeek').format('YYYY-MM-DD'),
endDate: newDayjs().endOf('isoWeek').format('YYYY-MM-DD'),
customer: null as string | null,
sets: [] as { name: string; id: string; content: string[] }[],
signature: undefined as any,
@ -86,7 +87,7 @@ export interface Integrations {
// Helper function to get start and end dates based on display type
function getDateRange(display: string, referenceDate?: string) {
const date = referenceDate ? dayjs(referenceDate) : dayjs();
const date = referenceDate ? newDayjs(referenceDate) : newDayjs();
switch (display) {
case 'day':
@ -153,8 +154,8 @@ export const CalendarWeekProvider: FC<{
const modifiedParams = new URLSearchParams({
display: filters.display,
customer: filters?.customer?.toString() || '',
startDate: dayjs(filters.startDate).startOf('day').utc().format(),
endDate: dayjs(filters.endDate).endOf('day').utc().format(),
startDate: newDayjs(filters.startDate).startOf('day').utc().format(),
endDate: newDayjs(filters.endDate).endOf('day').utc().format(),
}).toString();
const data = (await fetch(`/posts?${modifiedParams}`)).json();
@ -176,8 +177,22 @@ export const CalendarWeekProvider: FC<{
return (await fetch('/sets')).json();
}, []);
const { data: sets, mutate } = useSWR('sets', setList);
const { data: sign } = useSWR('default-sign', defaultSign);
const { data: sets, mutate } = useSWR('sets', setList, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
revalidateOnMount: true,
refreshWhenHidden: false,
refreshWhenOffline: false,
});
const { data: sign } = useSWR('default-sign', defaultSign, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
revalidateOnMount: true,
refreshWhenHidden: false,
refreshWhenOffline: false,
});
const setFiltersWrapper = useCallback(
(filters: {

View file

@ -30,7 +30,7 @@ import 'dayjs/locale/ar';
import 'dayjs/locale/tr';
import 'dayjs/locale/vi';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import clsx from 'clsx';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { ExistingDataContextProvider } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
@ -53,7 +53,7 @@ import { AddEditModal } from '@gitroom/frontend/components/new-launch/add.edit.m
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
import { ModalWrapperComponent } from '../new-launch/modal.wrapper.component';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
// Extend dayjs with necessary plugins
extend(isSameOrAfter);
@ -146,7 +146,7 @@ export const DayView = () => {
{options.map((option) => (
<Fragment key={option[0].time}>
<div className="text-center text-[14px]">
{dayjs()
{newDayjs()
.utc()
.startOf('day')
.add(option[0].time, 'minute')
@ -186,7 +186,7 @@ export const WeekView = () => {
dayjs.locale(currentLanguage);
const days = [];
const weekStart = dayjs(startDate);
const weekStart = newDayjs(startDate);
for (let i = 0; i < 7; i++) {
const day = weekStart.add(i, 'day');
days.push({
@ -214,10 +214,11 @@ export const WeekView = () => {
<div
className={clsx(
'text-[14px] font-[600] flex items-center justify-center gap-[6px]',
day.day === dayjs().format('L') && 'text-newTableTextFocused'
day.day === newDayjs().format('L') &&
'text-newTableTextFocused'
)}
>
{day.day === dayjs().format('L') && (
{day.day === newDayjs().format('L') && (
<div className="w-[6px] h-[6px] bg-newTableTextFocused rounded-full" />
)}
{day.day}
@ -259,17 +260,17 @@ export const MonthView = () => {
const days = [];
// Starting from Monday (1) to Sunday (7)
for (let i = 1; i <= 7; i++) {
days.push(dayjs().day(i).format('dddd'));
days.push(newDayjs().day(i).format('dddd'));
}
return days;
}, [i18next.resolvedLanguage]);
const calendarDays = useMemo(() => {
const monthStart = dayjs(startDate);
const monthStart = newDayjs(startDate);
const currentMonth = monthStart.month();
const currentYear = monthStart.year();
const startOfMonth = dayjs(new Date(currentYear, currentMonth, 1));
const startOfMonth = newDayjs(new Date(currentYear, currentMonth, 1));
// Calculate the day offset for Monday (isoWeekday() returns 1 for Monday)
const startDayOfWeek = startOfMonth.isoWeekday(); // 1 for Monday, 7 for Sunday
@ -314,7 +315,7 @@ export const MonthView = () => {
className="text-center items-center justify-center flex min-h-[100px]"
>
<CalendarColumn
getDate={dayjs(date.day).endOf('day')}
getDate={newDayjs(date.day).endOf('day')}
randomHour={true}
/>
</div>
@ -390,7 +391,9 @@ export const CalendarColumn: FC<{
const isBeforeNow = useMemo(() => {
const originalUtc = getDate.startOf('hour');
return originalUtc.startOf('hour').isBefore(dayjs().startOf('hour').utc());
return originalUtc
.startOf('hour')
.isBefore(newDayjs().startOf('hour').utc());
}, [getDate, num]);
const { start, stop } = useInterval(
@ -460,9 +463,12 @@ export const CalendarColumn: FC<{
? ExistingDataContextProvider
: Fragment;
modal.openModal({
id: 'add-edit-modal',
closeOnClickOutside: false,
removeLayout: true,
closeOnEscape: false,
withCloseButton: false,
askClose: true,
classNames: {
modal: 'w-[100%] max-w-[1400px] text-textColor',
},
@ -514,28 +520,24 @@ export const CalendarColumn: FC<{
? undefined
: await new Promise((resolve) => {
modal.openModal({
title: '',
title: t('select_set', 'Select a Set'),
closeOnClickOutside: true,
askClose: true,
closeOnEscape: true,
withCloseButton: false,
withCloseButton: true,
onClose: () => resolve('exit'),
classNames: {
modal: 'text-textColor',
},
children: (
<ModalWrapperComponent title={t('select_set', 'Select a Set')}>
<SetSelectionModal
sets={sets}
onSelect={(selectedSet) => {
resolve(selectedSet);
modal.closeAll();
}}
onContinueWithoutSet={() => {
resolve(undefined);
modal.closeAll();
}}
/>
</ModalWrapperComponent>
<SetSelectionModal
sets={sets}
onSelect={(selectedSet) => {
resolve(selectedSet);
modal.closeAll();
}}
onContinueWithoutSet={() => {
resolve(undefined);
modal.closeAll();
}}
/>
),
});
});
@ -546,9 +548,12 @@ export const CalendarColumn: FC<{
closeOnClickOutside: false,
closeOnEscape: false,
withCloseButton: false,
removeLayout: true,
askClose: true,
classNames: {
modal: 'w-[100%] max-w-[1400px] text-textColor',
},
id: 'add-edit-modal',
children: (
<AddEditModal
allIntegrations={integrations.map((p) => ({
@ -571,8 +576,8 @@ export const CalendarColumn: FC<{
randomHour
? getDate.hour(Math.floor(Math.random() * 24))
: getDate.format('YYYY-MM-DDTHH:mm:ss') ===
dayjs().startOf('hour').format('YYYY-MM-DDTHH:mm:ss')
? dayjs().add(10, 'minute')
newDayjs().startOf('hour').format('YYYY-MM-DDTHH:mm:ss')
? newDayjs().add(10, 'minute')
: getDate
}
{...(set?.content ? { set: JSON.parse(set.content) } : {})}
@ -585,17 +590,14 @@ export const CalendarColumn: FC<{
const openStatistics = useCallback(
(id: string) => () => {
modal.openModal({
title: t('statistics', 'Statistics'),
closeOnClickOutside: true,
closeOnEscape: true,
withCloseButton: false,
classNames: {
modal: 'w-[100%] max-w-[1400px]',
},
children: (
<ModalWrapperComponent title={t('statistics', 'Statistics')}>
<StatisticsModal postId={id} />
</ModalWrapperComponent>
),
children: <StatisticsModal postId={id} />,
size: '80%',
// title: `Adding posts for ${getDate.format('DD/MM/YYYY HH:mm')}`,
});
@ -911,7 +913,7 @@ const CalendarItem: FC<{
</div>
<div className="w-full relative">
<div className="absolute top-0 start-0 w-full text-ellipsis break-words line-clamp-1 text-left">
{stripHtmlValidation('none', post.content, false, true) ||
{stripHtmlValidation('none', post.content, false, true, false) ||
'no content'}
</div>
</div>

View file

@ -1,7 +1,7 @@
import { FC, useCallback, useEffect, useState } from 'react';
import dayjs from 'dayjs';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { Textarea } from '@gitroom/react/form/textarea';
import { Button } from '@gitroom/react/form/button';
import clsx from 'clsx';

View file

@ -40,9 +40,14 @@ export const ContinueIntegration: FC<{
const data = await fetch(`/integrations/social/${provider}/connect`, {
method: 'POST',
body: JSON.stringify({...modifiedParams, timezone}),
body: JSON.stringify({ ...modifiedParams, timezone }),
});
if (data.status === HttpStatusCode.PreconditionFailed) {
push(`/launches?precondition=true`);
return ;
}
if (data.status === HttpStatusCode.NotAcceptable) {
const { msg } = await data.json();
push(`/launches?msg=${msg}`);

View file

@ -1,6 +1,6 @@
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import React, { FC, useCallback, useEffect, useState } from 'react';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { Integration } from '@prisma/client';
import { Autocomplete } from '@mantine/core';
import useSWR from 'swr';

View file

@ -7,13 +7,14 @@ import { useCallback } from 'react';
import { SelectCustomer } from '@gitroom/frontend/components/launches/select.customer';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import i18next from 'i18next';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
// Helper function to get start and end dates based on display type
function getDateRange(
display: 'day' | 'week' | 'month',
referenceDate?: string
) {
const date = referenceDate ? dayjs(referenceDate) : dayjs();
const date = referenceDate ? newDayjs(referenceDate) : newDayjs();
switch (display) {
case 'day':
@ -44,8 +45,8 @@ export const Filters = () => {
// Calculate display date range text
const getDisplayText = () => {
const startDate = dayjs(calendar.startDate);
const endDate = dayjs(calendar.endDate);
const startDate = newDayjs(calendar.startDate);
const endDate = newDayjs(calendar.endDate);
switch (calendar.display) {
case 'day':
@ -60,7 +61,7 @@ export const Filters = () => {
};
const setToday = useCallback(() => {
const today = dayjs();
const today = newDayjs();
const currentRange = getDateRange(
calendar.display as 'day' | 'week' | 'month'
);
@ -151,7 +152,7 @@ export const Filters = () => {
);
const next = useCallback(() => {
const currentStart = dayjs(calendar.startDate);
const currentStart = newDayjs(calendar.startDate);
let nextStart: dayjs.Dayjs;
switch (calendar.display) {
@ -181,7 +182,7 @@ export const Filters = () => {
}, [calendar]);
const previous = useCallback(() => {
const currentStart = dayjs(calendar.startDate);
const currentStart = newDayjs(calendar.startDate);
let prevStart: dayjs.Dayjs;
switch (calendar.display) {

View file

@ -16,7 +16,16 @@ export const GeneralPreviewComponent: FC<{
const mediaDir = useMediaDirectory();
const renderContent = topValue.map((p) => {
const newContent = stripHtmlValidation('normal', p.content, true);
const newContent = stripHtmlValidation(
'normal',
p.content.replace(
/<span.*?data-mention-id="([.\s\S]*?)"[.\s\S]*?>([.\s\S]*?)<\/span>/gi,
(match, match1, match2) => {
return `[[[${match2}]]]`;
}
),
true
);
const { start, end } = textSlicer(
integration?.identifier || '',
@ -27,21 +36,13 @@ export const GeneralPreviewComponent: FC<{
const finalValue =
newContent
.slice(start, end)
.replace(/(@.+?)(\s)/gi, (match, match1, match2) => {
return `<span class="font-bold" style="color: #ae8afc">${match1.trim()}${match2}</span>`;
})
.replace(/@\[(.+?)]\((.+?)\)/gi, (match, name, id) => {
return `<span class="font-bold" style="color: #ae8afc">@${name}</span>`;
.replace(/\[\[\[([.\s\S]*?)]]]/, (match, match1) => {
return `<span class="font-bold font-[arial]" style="color: #ae8afc">${match1}</span>`;
}) +
`<mark class="bg-red-500" data-tooltip-id="tooltip" data-tooltip-content="This text will be cropped">` +
newContent
.slice(end)
.replace(/(@.+?)(\s)/gi, (match, match1, match2) => {
return `<span class="font-bold" style="color: #ae8afc">${match1.trim()}${match2}</span>`;
})
.replace(/@\[(.+?)]\((.+?)\)/gi, (match, name, id) => {
return `<span class="font-bold" style="color: #ae8afc">@${name}</span>`;
}) +
newContent.slice(end).replace(/\[\[\[([.\s\S]*?)]]]/, (match, match1) => {
return `<span class="font-bold font-[arial]" style="color: #ae8afc">${match1}</span>`;
}) +
`</mark>`;
return { text: finalValue, images: p.image };
@ -110,10 +111,7 @@ export const GeneralPreviewComponent: FC<{
</div>
</div>
<div
className={clsx(
'text-wrap whitespace-pre',
'preview'
)}
className={clsx('text-wrap whitespace-pre', 'preview')}
dangerouslySetInnerHTML={{
__html: value.text,
}}

View file

@ -2,7 +2,7 @@ import React, { FC, useCallback, useMemo, useState } from 'react';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { useRouter } from 'next/navigation';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { GeneratorDto } from '@gitroom/nestjs-libraries/dtos/generator/generator.dto';
@ -153,9 +153,12 @@ const FirstStep: FC = (props) => {
closeOnClickOutside: false,
closeOnEscape: false,
withCloseButton: false,
removeLayout: true,
askClose: true,
classNames: {
modal: 'w-[100%] max-w-[1400px] bg-transparent text-textColor',
},
id: 'add-edit-modal',
children: (
<AddEditModal
allIntegrations={integrations.map((p) => ({
@ -301,7 +304,7 @@ export const GeneratorComponent = () => {
return;
}
modal.openModal({
title: '',
title: 'Generate Posts',
withCloseButton: false,
classNames: {
modal: 'bg-transparent text-textColor',
@ -309,9 +312,7 @@ export const GeneratorComponent = () => {
size: 'xl',
children: (
<CalendarWeekProvider {...all}>
<ModalWrapperComponent title="Generate Posts">
<GeneratorPopup />
</ModalWrapperComponent>
<GeneratorPopup />
</CalendarWeekProvider>
),
});

View file

@ -5,6 +5,7 @@ import { useClickOutside } from '@mantine/hooks';
import { Button } from '@gitroom/react/form/button';
import { isUSCitizen } from './isuscitizen.utils';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
export const DatePicker: FC<{
date: dayjs.Dayjs;
onChange: (day: dayjs.Dayjs) => void;
@ -22,10 +23,10 @@ export const DatePicker: FC<{
const changeDate = useCallback(
(type: 'date' | 'time') => (day: Date) => {
onChange(
dayjs(
newDayjs(
type === 'time'
? date.format('YYYY-MM-DD') + ' ' + dayjs(day).format('HH:mm:ss')
: dayjs(day).format('YYYY-MM-DD') + ' ' + date.format('HH:mm:ss')
? date.format('YYYY-MM-DD') + ' ' + newDayjs(day).format('HH:mm:ss')
: newDayjs(day).format('YYYY-MM-DD') + ' ' + date.format('HH:mm:ss')
)
);
},

View file

@ -1,4 +1,4 @@
export const isUSCitizen = () => {
const userLanguage = navigator.language || navigator.languages[0];
return userLanguage.startsWith('en-US');
const userLanguage = localStorage.getItem('isUS') || ((navigator.language || navigator.languages[0]).startsWith('en-US') ? 'US' : 'GLOBAL');
return userLanguage === 'US';
};

View file

@ -5,6 +5,7 @@ import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store';
import { useVariables } from '@gitroom/react/helpers/variable.context';
const postUrlEmitter = new EventEmitter();
export const MediaSettingsLayout = () => {
@ -97,7 +98,8 @@ export const CreateThumbnail: FC<{
altText?: string;
onAltTextChange?: (altText: string) => void;
}> = (props) => {
const { onSelect, media, altText, onAltTextChange } = props;
const { onSelect, media } = props;
const { backendUrl } = useVariables();
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [currentTime, setCurrentTime] = useState(0);
@ -106,16 +108,12 @@ export const CreateThumbnail: FC<{
const [isCapturing, setIsCapturing] = useState(false);
const handleLoadedMetadata = useCallback(() => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
setIsLoaded(true);
}
setDuration(videoRef?.current?.duration);
setIsLoaded(true);
}, []);
const handleTimeUpdate = useCallback(() => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
}
setCurrentTime(videoRef?.current?.currentTime);
}, []);
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
@ -127,8 +125,6 @@ export const CreateThumbnail: FC<{
}, []);
const captureFrame = useCallback(async () => {
if (!videoRef.current || !canvasRef.current) return;
setIsCapturing(true);
try {
@ -217,7 +213,9 @@ export const CreateThumbnail: FC<{
<div className="relative bg-black rounded-lg overflow-hidden">
<video
ref={videoRef}
src={media.path}
src={
backendUrl + '/public/stream?url=' + encodeURIComponent(media.path)
}
className="w-full h-[200px] object-contain"
onLoadedMetadata={handleLoadedMetadata}
onTimeUpdate={handleTimeUpdate}
@ -299,7 +297,14 @@ export const MediaComponentInner: FC<{
alt: string;
}) => void;
media:
| { id: string; name: string; path: string; thumbnail: string; alt: string, thumbnailTimestamp?: number }
| {
id: string;
name: string;
path: string;
thumbnail: string;
alt: string;
thumbnailTimestamp?: number;
}
| undefined;
}> = (props) => {
const { onClose, onSelect, media } = props;
@ -357,163 +362,132 @@ export const MediaComponentInner: FC<{
}, [altText, newThumbnail, thumbnail, thumbnailTimestamp]);
return (
<div className="text-textColor fixed start-0 top-0 bg-primary/80 z-[300] w-full h-full p-[60px] animate-fade justify-center flex bg-black/40">
<div className="w-full h-full relative">
<div className="w-[500px] bg-sixth border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] absolute left-[50%] top-[100px] -translate-x-[50%]">
<div className="flex">
<div className="flex-1">
<TopTitle title={'Media Setting'} />
</div>
<button
onClick={onClose}
className="outline-none absolute end-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-primary 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>
</div>
<div className="mt-[10px] flex flex-col gap-[20px]">
<div className="flex flex-col space-y-2">
<label className="text-sm text-textColor font-medium">
Alt Text (for accessibility)
</label>
<input
type="text"
value={altText}
onChange={(e) => setAltText(e.target.value)}
placeholder="Describe the image/video content..."
className="w-full px-3 py-2 bg-fifth border border-tableBorder rounded-lg text-textColor placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-forth focus:border-transparent"
/>
</div>
{media?.path.indexOf('mp4') > -1 && (
<>
{/* Alt Text Input */}
<div>
{!isEditingThumbnail ? (
<div className="flex flex-col">
{/* Show existing thumbnail if it exists */}
{(newThumbnail || thumbnail) && (
<div className="flex flex-col space-y-2">
<span className="text-sm text-textColor">
Current Thumbnail:
</span>
<img
src={newThumbnail || thumbnail}
alt="Current thumbnail"
className="max-w-full max-h-[500px] object-contain rounded-lg border border-tableBorder"
/>
</div>
)}
<div className="mt-[10px] flex flex-col gap-[20px]">
<div className="flex flex-col space-y-2">
<label className="text-sm text-textColor font-medium">
Alt Text (for accessibility)
</label>
<input
type="text"
value={altText}
onChange={(e) => setAltText(e.target.value)}
placeholder="Describe the image/video content..."
className="w-full px-3 py-2 bg-fifth border border-tableBorder rounded-lg text-textColor placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-forth focus:border-transparent"
/>
</div>
{media?.path.indexOf('mp4') > -1 && (
<>
{/* Alt Text Input */}
<div>
{!isEditingThumbnail ? (
<div className="flex flex-col">
{/* Show existing thumbnail if it exists */}
{(newThumbnail || thumbnail) && (
<div className="flex flex-col space-y-2">
<span className="text-sm text-textColor">
Current Thumbnail:
</span>
<img
src={newThumbnail || thumbnail}
alt="Current thumbnail"
className="max-w-full max-h-[500px] object-contain rounded-lg border border-tableBorder"
/>
</div>
)}
{/* Action Buttons */}
<div className="flex space-x-2">
<button
disabled={loading}
onClick={() => setIsEditingThumbnail(true)}
className="bg-third text-textColor px-6 py-2 rounded-lg hover:bg-opacity-80 transition-all flex-1 border border-tableBorder"
>
{media.thumbnail || newThumbnail
? 'Edit Thumbnail'
: 'Create Thumbnail'}
</button>
{(thumbnail || newThumbnail) && (
<button
disabled={loading}
onClick={() => {
setNewThumbnail(null);
setThumbnail(null);
}}
className="bg-red-600 text-white px-6 py-2 rounded-lg hover:bg-opacity-80 transition-all flex-1 border border-red-700"
>
Clear Thumbnail
</button>
)}
</div>
</div>
) : (
<div>
{/* Back button */}
<div className="flex justify-start">
<button
onClick={() => setIsEditingThumbnail(false)}
className="text-textColor hover:text-white transition-colors flex items-center space-x-2"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 12H5M12 19L5 12L12 5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>Back</span>
</button>
</div>
{/* Thumbnail Editor */}
<CreateThumbnail
onSelect={(blob: Blob, timestampMs: number) => {
// Convert blob to base64 or handle as needed
const reader = new FileReader();
reader.onload = () => {
// You can handle the result here - for now just call onSelect with the blob URL
const url = URL.createObjectURL(blob);
setNewThumbnail(url);
setThumbnailTimestamp(timestampMs);
setIsEditingThumbnail(false);
};
reader.readAsDataURL(blob);
}}
media={media}
altText={altText}
onAltTextChange={setAltText}
/>
</div>
{/* Action Buttons */}
<div className="flex space-x-2">
<button
disabled={loading}
onClick={() => setIsEditingThumbnail(true)}
className="bg-third text-textColor px-6 py-2 rounded-lg hover:bg-opacity-80 transition-all flex-1 border border-tableBorder"
>
{media.thumbnail || newThumbnail
? 'Edit Thumbnail'
: 'Create Thumbnail'}
</button>
{(thumbnail || newThumbnail) && (
<button
disabled={loading}
onClick={() => {
setNewThumbnail(null);
setThumbnail(null);
}}
className="bg-red-600 text-white px-6 py-2 rounded-lg hover:bg-opacity-80 transition-all flex-1 border border-red-700"
>
Clear Thumbnail
</button>
)}
</div>
</>
)}
</div>
) : (
<div>
{/* Back button */}
<div className="flex justify-start">
<button
onClick={() => setIsEditingThumbnail(false)}
className="text-textColor hover:text-white transition-colors flex items-center space-x-2"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 12H5M12 19L5 12L12 5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>Back</span>
</button>
</div>
{!isEditingThumbnail && (
<div className="flex space-x-2 !mt-[20px]">
<button
disabled={loading}
onClick={onClose}
className="flex-1 bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-opacity-80 transition-all"
>
Cancel
</button>
<button
onClick={save}
className="flex-1 bg-forth text-white px-6 py-2 rounded-lg hover:bg-opacity-80 transition-all"
>
Save Changes
</button>
{/* Thumbnail Editor */}
<CreateThumbnail
onSelect={(blob: Blob, timestampMs: number) => {
// Convert blob to base64 or handle as needed
const reader = new FileReader();
reader.onload = () => {
// You can handle the result here - for now just call onSelect with the blob URL
const url = URL.createObjectURL(blob);
setNewThumbnail(url);
setThumbnailTimestamp(timestampMs);
setIsEditingThumbnail(false);
};
reader.readAsDataURL(blob);
}}
media={media}
altText={altText}
onAltTextChange={setAltText}
/>
</div>
)}
</div>
</>
)}
{!isEditingThumbnail && (
<div className="flex space-x-2 !mt-[20px]">
<button
disabled={loading}
onClick={onClose}
className="flex-1 bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-opacity-80 transition-all"
>
Cancel
</button>
<button
onClick={save}
className="flex-1 bg-forth text-white px-6 py-2 rounded-lg hover:bg-opacity-80 transition-all"
>
Save Changes
</button>
</div>
</div>
)}
</div>
);
};

View file

@ -220,14 +220,14 @@ export const PickPlatforms: FC<{
ref={ref}
>
<div className="innerComponent">
<div className="flex">
<div className="flex gap-[10px] flex-wrap">
{integrations
.filter((f) => !f.inBetweenSteps)
.map((integration) =>
!props.singleSelect ? (
<div
key={integration.id}
className="flex gap-[8px] items-center me-[10px]"
className="flex gap-[8px] items-center"
{...(props.toolTip && {
'data-tooltip-id': 'tooltip',
'data-tooltip-content': integration.name,

View file

@ -12,6 +12,12 @@ export const useIntegrationList = () => {
}, []);
return useSWR('/integrations/list', load, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
revalidateOnMount: true,
refreshWhenHidden: false,
refreshWhenOffline: false,
fallbackData: [],
});
};

View file

@ -3,6 +3,7 @@
import { createContext, useContext } from 'react';
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
import dayjs from 'dayjs';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
export const IntegrationContext = createContext<{
date: dayjs.Dayjs;
integration: Integrations | undefined;
@ -18,7 +19,7 @@ export const IntegrationContext = createContext<{
}>({
integration: undefined,
value: [],
date: dayjs(),
date: newDayjs(),
allIntegrations: [],
});
export const useIntegration = () => useContext(IntegrationContext);

View file

@ -11,7 +11,7 @@ import { useClickOutside } from '@mantine/hooks';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { TimeTable } from '@gitroom/frontend/components/launches/time.table';
import {
Integrations,
@ -137,18 +137,12 @@ export const Menu: FC<{
(integration) => integration.id === id
);
modal.openModal({
classNames: {
modal: 'w-[100%] max-w-[600px] bg-transparent text-textColor',
},
size: '100%',
withCloseButton: false,
closeOnEscape: false,
closeOnClickOutside: false,
children: (
<ModalWrapperComponent title="Time Table Slots" ask={true}>
<TimeTable integration={findIntegration!} mutate={mutate} />
</ModalWrapperComponent>
),
askClose: true,
title: 'Time Table Slots',
children: <TimeTable integration={findIntegration!} mutate={mutate} />,
});
setShow(false);
}, [integrations]);
@ -175,9 +169,12 @@ export const Menu: FC<{
closeOnClickOutside: false,
closeOnEscape: false,
withCloseButton: false,
removeLayout: true,
askClose: true,
classNames: {
modal: 'w-[100%] max-w-[1400px] bg-transparent text-textColor',
},
id: 'add-edit-modal',
children: (
<AddEditModal
allIntegrations={integrations.map((p) => ({
@ -254,40 +251,36 @@ export const Menu: FC<{
classNames: {
modal: 'md',
},
title: '',
title: 'Move / Add to customer',
withCloseButton: false,
closeOnEscape: true,
closeOnClickOutside: true,
children: (
<ModalWrapperComponent title="Move / Add to customer">
<CustomerModal
// @ts-ignore
integration={findIntegration}
onClose={() => {
mutate();
toast.show('Customer Updated', 'success');
}}
/>
</ModalWrapperComponent>
<CustomerModal
// @ts-ignore
integration={findIntegration}
onClose={() => {
mutate();
toast.show('Customer Updated', 'success');
}}
/>
),
});
setShow(false);
}, [integrations]);
const updateCredentials = useCallback(() => {
modal.openModal({
title: '',
title: 'Custom URL',
withCloseButton: false,
classNames: {
modal: 'md',
},
children: (
<ModalWrapperComponent title="Custom URL">
<CustomVariables
identifier={findIntegration.identifier}
gotoUrl={(url: string) => router.push(url)}
variables={findIntegration.customFields}
/>
</ModalWrapperComponent>
<CustomVariables
identifier={findIntegration.identifier}
gotoUrl={(url: string) => router.push(url)}
variables={findIntegration.customFields}
/>
),
});
}, []);

View file

@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import dayjs from 'dayjs';
import { useCalendar } from '@gitroom/frontend/components/launches/calendar.context';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
@ -21,7 +21,7 @@ export const NewPost = () => {
? undefined
: await new Promise((resolve) => {
modal.openModal({
title: '',
title: t('select_set', 'Select a Set'),
closeOnClickOutside: true,
closeOnEscape: true,
withCloseButton: false,
@ -30,19 +30,17 @@ export const NewPost = () => {
modal: 'text-textColor',
},
children: (
<ModalWrapperComponent title={t('select_set', 'Select a Set')}>
<SetSelectionModal
sets={sets}
onSelect={(selectedSet) => {
resolve(selectedSet);
modal.closeAll();
}}
onContinueWithoutSet={() => {
resolve(undefined);
modal.closeAll();
}}
/>
</ModalWrapperComponent>
<SetSelectionModal
sets={sets}
onSelect={(selectedSet) => {
resolve(selectedSet);
modal.closeAll();
}}
onContinueWithoutSet={() => {
resolve(undefined);
modal.closeAll();
}}
/>
),
});
});
@ -53,9 +51,12 @@ export const NewPost = () => {
closeOnClickOutside: false,
closeOnEscape: false,
withCloseButton: false,
removeLayout: true,
askClose: true,
classNames: {
modal: 'w-[100%] max-w-[1400px] bg-transparent text-textColor',
},
id: 'add-edit-modal',
children: (
<AddEditModal
allIntegrations={integrations.map((p) => ({

View file

@ -56,10 +56,12 @@ const ActionControls = ({ store }: any) => {
body: formData,
})
).json();
close.setMedia([{
id: data.id,
path: data.path,
}]);
close.setMedia([
{
id: data.id,
path: data.path,
},
]);
close.close();
}}
>
@ -102,63 +104,34 @@ const Polonto: FC<{
};
}, []);
return (
<div className="fixed start-0 top-0 bg-primary/80 z-[300] w-full min-h-full px-[60px] animate-fade">
<div className="w-full h-full bg-sixth border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] relative">
<div className="flex">
<div className="flex-1">
<TopTitle title="Design Media" />
</div>
<button
onClick={closeModal}
className="outline-none absolute end-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-primary 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>
</div>
<div className="bg-white text-black relative z-[400] polonto">
<CloseContext.Provider
value={{
close: () => closeModal(),
setMedia,
}}
>
<PolotnoContainer
style={{
width: '100%',
height: '700px',
<div className="bg-white text-black relative z-[400] polonto">
<CloseContext.Provider
value={{
close: () => closeModal(),
setMedia,
}}
>
<PolotnoContainer
style={{
width: '100%',
height: '700px',
}}
>
<SidePanelWrap>
<SidePanel store={store} sections={features} />
</SidePanelWrap>
<WorkspaceWrap>
<Toolbar
store={store}
components={{
ActionControls,
}}
>
<SidePanelWrap>
<SidePanel store={store} sections={features} />
</SidePanelWrap>
<WorkspaceWrap>
<Toolbar
store={store}
components={{
ActionControls,
}}
/>
<Workspace store={store} />
<ZoomButtons store={store} />
</WorkspaceWrap>
</PolotnoContainer>
</CloseContext.Provider>
</div>
</div>
/>
<Workspace store={store} />
<ZoomButtons store={store} />
</WorkspaceWrap>
</PolotnoContainer>
</CloseContext.Provider>
</div>
);
};

View file

@ -1,6 +1,6 @@
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import React, { FC, useCallback, useState } from 'react';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { Integration } from '@prisma/client';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { Button } from '@gitroom/react/form/button';

View file

@ -1,5 +1,5 @@
import React, { FC, Fragment, useCallback } from 'react';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useT } from '@gitroom/react/translation/get.transation.service.client';

View file

@ -32,7 +32,14 @@ export const TagsComponent: FC<{
name: string;
color: string;
}[];
}>('tags', loadTags);
}>('tags', loadTags, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
revalidateOnMount: true,
refreshWhenHidden: false,
refreshWhenOffline: false,
});
const onDelete = useCallback(
(tagIndex: number) => {
const modify = tagValue.filter((_, i) => i !== tagIndex);

View file

@ -10,10 +10,11 @@ import timezone from 'dayjs/plugin/timezone';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
// @ts-ignore
import useKeypress from 'react-use-keypress';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { sortBy } from 'lodash';
import { usePreventWindowUnload } from '@gitroom/react/helpers/use.prevent.window.unload';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
const hours = [...Array(24).keys()].map((i, index) => ({
@ -70,12 +71,12 @@ export const TimeTable: FC<{
);
const addHour = useCallback(() => {
const calculateMinutes =
dayjs()
newDayjs()
.utc()
.startOf('day')
.add(hour, 'hours')
.add(minute, 'minutes')
.diff(dayjs().utc().startOf('day'), 'minutes') - dayjs.tz().utcOffset();
.diff(newDayjs().utc().startOf('day'), 'minutes') - dayjs.tz().utcOffset();
setCurrentTimes((prev) => [
...prev,
{

View file

@ -5,7 +5,7 @@ import React, { FC, useMemo, useState, useCallback, useEffect } from 'react';
import { Web3ProviderInterface } from '@gitroom/frontend/components/launches/web3/web3.provider.interface';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
import { ButtonCaster } from '@gitroom/frontend/components/auth/providers/farcaster.provider';
export const WrapcasterProvider: FC<Web3ProviderInterface> = (props) => {

View file

@ -4,7 +4,7 @@ import '@neynar/react/dist/style.css';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import { Web3ProviderInterface } from '@gitroom/frontend/components/launches/web3/web3.provider.interface';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { timer } from '@gitroom/helpers/utils/timer';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';

View file

@ -5,7 +5,7 @@ import React, { FC, useMemo, useState, useCallback, useEffect } from 'react';
import { Web3ProviderInterface } from '@gitroom/frontend/components/launches/web3/web3.provider.interface';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
import {
NeynarAuthButton,

View file

@ -6,6 +6,7 @@ import dayjs from 'dayjs';
import useSWR, { useSWRConfig } from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { continueProviderList } from '@gitroom/frontend/components/new-launch/providers/continue-provider/list';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
export const Null: FC<{
closeModal: () => void;
existingId: string[];
@ -22,6 +23,12 @@ export const ContinueProvider: FC = () => {
return list;
}, []);
const { data: integrations } = useSWR('/integrations/list', load, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
revalidateOnMount: true,
refreshWhenHidden: false,
refreshWhenOffline: false,
fallbackData: [],
});
const closeModal = useCallback(() => {
@ -76,7 +83,7 @@ export const ContinueProvider: FC = () => {
<div className="pt-[16px] max-h-[600px] overflow-hidden overflow-y-auto">
<IntegrationContext.Provider
value={{
date: dayjs(),
date: newDayjs(),
value: [],
allIntegrations: [],
integration: {

View file

@ -1,6 +1,6 @@
'use client';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import {
cookieName,
fallbackLng,
@ -70,7 +70,7 @@ export const ChangeLanguageComponent = () => {
const handleLanguageChange = (language: string) => {
setCookie(language);
i18next.changeLanguage(language);
modals.closeModal('change-language');
modals.closeCurrent();
};
// Function to get language name in its native script
@ -123,16 +123,9 @@ export const LanguageComponent = () => {
const t = useT();
const openModal = () => {
modal.openModal({
title: '',
withCloseButton: false,
modalId: 'change-language',
children: (
<ModalWrapperComponent title={t('change_language', 'Change Language')}>
<ChangeLanguageComponent />
</ModalWrapperComponent>
),
size: 'lg',
centered: true,
title: t('change_language', 'Change Language'),
withCloseButton: true,
children: <ChangeLanguageComponent />,
});
};
return (

View file

@ -73,11 +73,13 @@ function LayoutContextInner(params: { children: ReactNode }) {
: '/analytics?onboarding=true';
return true;
}
if (response?.headers?.get('reload')) {
window.location.reload();
return true;
}
if (response.status === 401) {
if (response.status === 401 || response?.headers?.get('logout')) {
if (!isSecured) {
setCookie('auth', '', -10);
setCookie('showorg', '', -10);

View file

@ -72,6 +72,7 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => {
<CopilotKit
credentials="include"
runtimeUrl={backendUrl + '/copilot/chat'}
showDevConsole={false}
>
<MantineWrapper>
{user.tier === 'FREE' && searchParams.get('check') && (
@ -84,7 +85,6 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => {
<Toaster />
<ShowPostSelector />
<NewSubscription />
{user.tier !== 'FREE' && <Onboarding />}
<Support />
<ContinueProvider />
<div className="min-h-[100vh] w-full max-w-[1440px] mx-auto bg-primary px-6 text-textColor flex flex-col">

View file

@ -0,0 +1,375 @@
import { create } from 'zustand';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { useShallow } from 'zustand/react/shallow';
import React, {
createContext,
FC,
memo,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
} from 'react';
import { Button } from '@gitroom/react/form/button';
import { useHotkeys } from 'react-hotkeys-hook';
import clsx from 'clsx';
import { EventEmitter } from 'events';
interface OpenModalInterface {
title?: string;
closeOnClickOutside?: boolean;
removeLayout?: boolean;
closeOnEscape?: boolean;
withCloseButton?: boolean;
askClose?: boolean;
onClose?: () => void;
children: ReactNode | ((close: () => void) => ReactNode);
classNames?: {
modal?: string;
};
size?: string | number;
height?: string | number;
id?: string;
}
interface ModalManagerStoreInterface {
closeById(id: string): void;
openModal(params: OpenModalInterface): void;
closeAll(): void;
}
interface State extends ModalManagerStoreInterface {
modalManager: Array<{ id: string } & OpenModalInterface>;
}
const useModalStore = create<State>((set) => ({
modalManager: [],
openModal: (params) => {
const newId = params.id || makeId(20);
set((state) => ({
modalManager: [
...state.modalManager,
...(!state.modalManager.some((p) => p.id === newId)
? [{ id: newId, ...params }]
: []),
],
}));
},
closeById: (id) =>
set((state) => ({
modalManager: state.modalManager.filter((modal) => modal.id !== id),
})),
closeAll: () => set({ modalManager: [] }),
}));
const CurrentModalContext = createContext({ id: '' });
interface ModalManagerInterface extends ModalManagerStoreInterface {
closeCurrent(): void;
}
export const useModals = () => {
const { closeAll, openModal, closeById } = useModalStore(
useShallow((state) => ({
openModal: state.openModal,
closeById: state.closeById,
closeAll: state.closeAll,
}))
);
const modalContext = useContext(CurrentModalContext);
return {
openModal,
closeAll,
closeById,
closeCurrent: () => {
if (modalContext.id) {
closeById(modalContext.id);
}
},
} satisfies ModalManagerInterface;
};
export const Component: FC<{
closeModal: (id: string) => void;
zIndex: number;
isLast: boolean;
modal: { id: string } & OpenModalInterface;
}> = memo(({ isLast, modal, closeModal, zIndex }) => {
const decision = useDecisionModal();
const closeModalFunction = useCallback(async () => {
if (modal.askClose) {
const open = await decision.open();
if (!open) {
return;
}
}
modal?.onClose?.();
closeModal(modal.id);
}, [modal.id, closeModal]);
const RenderComponent = useMemo(() => {
return typeof modal.children === 'function'
? modal.children(closeModalFunction)
: modal.children;
}, [modal, closeModalFunction]);
useHotkeys(
'Escape',
() => {
if (isLast) {
closeModalFunction();
}
},
[isLast, closeModalFunction]
);
if (modal.removeLayout) {
return (
<div
style={{ zIndex }}
className={clsx(
'fixed flex left-0 top-0 min-w-full min-h-full bg-popup transition-all animate-fadeIn overflow-y-auto pb-[50px] text-newTextColor',
!isLast && '!overflow-hidden'
)}
>
<div className="relative flex-1">
<div className="absolute top-0 left-0 min-w-full min-h-full">
<div
className="mx-auto py-[48px]"
{...(modal.size && { style: { width: modal.size } })}
>
{typeof modal.children === 'function'
? modal.children(closeModalFunction)
: modal.children}
</div>
</div>
</div>
</div>
);
}
return (
<CurrentModalContext.Provider value={{ id: modal.id }}>
<div
onClick={closeModalFunction}
style={{ zIndex }}
className="fixed flex left-0 top-0 min-w-full min-h-full bg-popup transition-all animate-fadeIn overflow-y-auto pb-[50px] text-newTextColor"
>
<div className="relative flex-1">
<div className="absolute top-0 left-0 min-w-full min-h-full pt-[100px] pb-[100px]">
<div
className={clsx(
!modal.removeLayout && 'gap-[40px] p-[32px]',
'bg-newBgColorInner mx-auto flex flex-col w-fit rounded-[24px] relative',
modal.size ? '' : 'min-w-[600px]'
)}
{...(modal.size && { style: { width: modal.size } })}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center">
<div className="text-[24px] font-[600] flex-1">
{modal.title}
</div>
{typeof modal.withCloseButton === 'undefined' ||
modal.withCloseButton ? (
<div className="cursor-pointer">
<button
className="outline-none absolute end-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
onClick={closeModalFunction}
>
<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>
</div>
) : null}
</div>
<div className="whitespace-pre-line">{RenderComponent}</div>
</div>
</div>
</div>
</div>
</CurrentModalContext.Provider>
);
});
export const ModalManagerInner: FC = () => {
const { closeModal, modalManager } = useModalStore(
useShallow((state) => ({
closeModal: state.closeById,
modalManager: state.modalManager,
}))
);
useEffect(() => {
if (modalManager.length > 0) {
document.querySelector('body')?.classList.add('overflow-hidden');
Array.from(document.querySelectorAll('.blurMe') || []).map((p) =>
p.classList.add('blur-xs', 'pointer-events-none')
);
} else {
document.querySelector('body')?.classList.remove('overflow-hidden');
Array.from(document.querySelectorAll('.blurMe') || []).map((p) =>
p.classList.remove('blur-xs', 'pointer-events-none')
);
}
}, [modalManager]);
if (modalManager.length === 0) {
return null;
}
return (
<>
<style>{`body, html { overflow: hidden !important; }`}</style>
{modalManager.map((modal, index) => (
<Component
isLast={modalManager.length - 1 === index}
key={modal.id}
modal={modal}
zIndex={200 + index}
closeModal={closeModal}
/>
))}
</>
);
};
export const ModalManager: FC<{ children: ReactNode }> = ({ children }) => {
return (
<div>
<ModalManagerEmitter />
<ModalManagerInner />
<div className="transition-all w-full">{children}</div>
</div>
);
};
const emitter = new EventEmitter();
export const showModalEmitter = (params: ModalManagerInterface) => {
emitter.emit('show', params);
};
export const ModalManagerEmitter: FC = () => {
const { showModal } = useModalStore(
useShallow((state) => ({
showModal: state.openModal,
}))
);
useEffect(() => {
emitter.on('show', (params: OpenModalInterface) => {
showModal(params);
});
return () => {
emitter.removeAllListeners('show');
};
}, []);
return null;
};
export const DecisionModal: FC<{
description: string;
approveLabel: string;
cancelLabel: string;
resolution: (value: boolean) => void;
}> = ({ description, cancelLabel, approveLabel, resolution }) => {
const { closeCurrent } = useModals();
return (
<div className="flex flex-col">
<div>{description}</div>
<div className="flex gap-[12px] mt-[16px]">
<Button
onClick={() => {
resolution(true);
closeCurrent();
}}
>
{approveLabel}
</Button>
<Button
onClick={() => {
resolution(false);
closeCurrent();
}}
>
{cancelLabel}
</Button>
</div>
</div>
);
};
export const decisionModalEmitter = new EventEmitter();
export const areYouSure = ({
title = 'Are you sure?',
description = 'Are you sure you want to close this modal?' as any,
approveLabel = 'Yes',
cancelLabel = 'No',
} = {}): Promise<boolean> => {
return new Promise<boolean>((newRes) => {
decisionModalEmitter.emit('open', {
title,
description,
approveLabel,
cancelLabel,
newRes,
});
});
};
export const DecisionEverywhere: FC = () => {
const decision = useDecisionModal();
useEffect(() => {
decisionModalEmitter.on('open', decision.open);
}, []);
return null;
};
export const useDecisionModal = () => {
const modals = useModals();
const open = useCallback(
({
title = 'Are you sure?',
description = 'Are you sure you want to close this modal?' as any,
approveLabel = 'Yes',
cancelLabel = 'No',
newRes = undefined as any,
} = {}) => {
return new Promise<boolean>((res) => {
modals.openModal({
title,
askClose: false,
onClose: () => res(false),
children: (
<DecisionModal
resolution={(value) => (newRes ? newRes(value) : res(value))}
description={description}
approveLabel={approveLabel}
cancelLabel={cancelLabel}
/>
),
});
});
},
[modals]
);
return { open };
};

View file

@ -0,0 +1,46 @@
import React, { FC, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { ModalWrapperComponent } from '@gitroom/frontend/components/new-launch/modal.wrapper.component';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { Button } from '@gitroom/react/form/button';
export const PreConditionComponentModal: FC = () => {
const modal = useModals();
return (
<div className="flex flex-col gap-[16px]">
<div className="whitespace-pre-line">
This social channel was connected previously to another Postiz account.
{'\n'}
To continue, please fast-track your trial for an immediate charge.{'\n'}
{'\n'}
** Please be advised that the account will not eligible for a refund,
and the charge is final.
</div>
<div className="flex gap-[2px] justify-center">
<Button
onClick={() => (window.location.href = '/billing?finishTrial=true')}
>
Fast track - Charge me now
</Button>
<Button onClick={modal.closeCurrent} secondary={true}>Cancel</Button>
</div>
</div>
);
};
export const PreConditionComponent: FC = () => {
const modal = useModals();
const query = useSearchParams();
useEffect(() => {
if (query.get('precondition')) {
modal.openModal({
title: 'Suspicious activity detected',
withCloseButton: true,
classNames: {
modal: 'text-textColor',
},
children: <PreConditionComponentModal />,
});
}
}, []);
return null;
};

View file

@ -0,0 +1,41 @@
'use client';
import dayjs, { ConfigType } from 'dayjs';
import { FC, useEffect } from 'react';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
dayjs.extend(timezone);
dayjs.extend(utc);
const { utc: originalUtc } = dayjs;
export const getTimezone = () => {
if (typeof window === 'undefined') {
return dayjs.tz.guess();
}
return localStorage.getItem('timezone') || dayjs.tz.guess();
};
export const newDayjs = (config?: ConfigType) => {
return dayjs(config);
};
const SetTimezone: FC = () => {
useEffect(() => {
dayjs.utc = (config?: ConfigType, format?: string, strict?: boolean) => {
const result = originalUtc(config, format, strict);
// Attach `.local()` method to the returned Dayjs object
result.local = function () {
return result.tz(getTimezone());
};
return result;
};
if (localStorage.getItem('timezone')) {
dayjs.tz.setDefault(getTimezone());
}
}, []);
return null;
};
export default SetTimezone;

View file

@ -1,6 +1,6 @@
'use client';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import React, {
FC,
Ref,
@ -30,6 +30,7 @@ import { SignaturesComponent } from '@gitroom/frontend/components/settings/signa
import { Autopost } from '@gitroom/frontend/components/autopost/autopost';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { SVGLine } from '@gitroom/frontend/components/launches/launches.component';
import { GlobalSettings } from '@gitroom/frontend/components/settings/global.settings';
export const SettingsPopup: FC<{
getRef?: Ref<any>;
}> = (props) => {
@ -80,22 +81,12 @@ export const SettingsPopup: FC<{
close();
}, []);
const [tab, setTab] = useState(() => {
if (user?.tier?.team_members && isGeneral) {
return 'teams';
}
if (user?.tier?.webhooks) {
return 'webhooks';
}
if (user?.tier?.autoPost) {
return 'autopost';
}
return 'sets';
});
const [tab, setTab] = useState('global_settings');
const t = useT();
const list = useMemo(() => {
const arr = [];
arr.push({ tab: 'global_settings', label: t('global_settings', 'Global Settings') });
// Populate tabs based on user permissions
if (user?.tier?.team_members && isGeneral) {
arr.push({ tab: 'teams', label: t('teams', 'Teams') });
@ -168,6 +159,11 @@ export const SettingsPopup: FC<{
!getRef && 'rounded-[4px]'
)}
>
{tab === 'global_settings' && (
<div>
<GlobalSettings />
</div>
)}
{tab === 'teams' && !!user?.tier?.team_members && isGeneral && (
<div>
<TeamsComponent />

View file

@ -1,7 +1,6 @@
'use client';
import { FC, ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
@ -42,6 +41,24 @@ export const useMenuItem = () => {
),
path: '/launches',
},
{
name: 'Agent',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="23"
height="23"
viewBox="0 0 32 32"
fill="none"
>
<path
d="M21.1963 9.07375C20.2913 6.95494 18.6824 5.21364 16.6416 4.14422C14.6009 3.0748 12.2534 2.74287 9.99616 3.20455C7.73891 3.66623 5.71031 4.8932 4.25334 6.67802C2.79637 8.46284 2.0004 10.696 2 13V21.25C2 21.7141 2.18437 22.1592 2.51256 22.4874C2.84075 22.8156 3.28587 23 3.75 23H10.8337C11.6141 24.7821 12.8964 26.2984 14.5241 27.3638C16.1519 28.4293 18.0546 28.9978 20 29H28.25C28.7141 29 29.1592 28.8156 29.4874 28.4874C29.8156 28.1592 30 27.7141 30 27.25V19C29.9995 16.5553 29.1036 14.1955 27.4814 12.3666C25.8593 10.5376 23.6234 9.36619 21.1963 9.07375ZM4 13C4 11.4177 4.46919 9.87103 5.34824 8.55544C6.22729 7.23984 7.47672 6.21446 8.93853 5.60896C10.4003 5.00346 12.0089 4.84504 13.5607 5.15372C15.1126 5.4624 16.538 6.22432 17.6569 7.34314C18.7757 8.46197 19.5376 9.88743 19.8463 11.4393C20.155 12.9911 19.9965 14.5997 19.391 16.0615C18.7855 17.5233 17.7602 18.7727 16.4446 19.6518C15.129 20.5308 13.5823 21 12 21H4V13ZM28 27H20C18.5854 26.9984 17.1964 26.6225 15.974 25.9106C14.7516 25.1986 13.7394 24.1759 13.04 22.9463C14.4096 22.8041 15.7351 22.3804 16.9333 21.7017C18.1314 21.023 19.1763 20.104 20.0024 19.0023C20.8284 17.9006 21.4179 16.6401 21.7337 15.2998C22.0495 13.9595 22.0848 12.5684 21.8375 11.2137C23.5916 11.6277 25.1545 12.6218 26.273 14.035C27.3915 15.4482 28 17.1977 28 19V27Z"
fill="currentColor"
/>
</svg>
),
path: '/agents',
},
{
name: t('analytics', 'Analytics'),
icon: (
@ -237,7 +254,7 @@ export const useMenuItem = () => {
</svg>
),
path: '/settings',
role: ['ADMIN', "USER", 'SUPERADMIN'],
role: ['ADMIN', 'USER', 'SUPERADMIN'],
},
] satisfies MenuItemInterface[] as MenuItemInterface[];
@ -254,7 +271,7 @@ export const TopMenu: FC = () => {
const { isGeneral, billingEnabled } = useVariables();
return (
<>
<div className="flex flex-1 flex-col gap-[16px]">
<div className="flex flex-1 flex-col gap-[16px] blurMe">
{
// @ts-ignore
user?.orgId &&
@ -286,7 +303,7 @@ export const TopMenu: FC = () => {
))
}
</div>
<div className="flex flex-col gap-[16px]">
<div className="flex flex-col gap-[16px] blurMe">
{secondMenu
.filter((f) => {
if (f.hide) {

View file

@ -20,7 +20,7 @@ import {
import { capitalize, chunk, fill } from 'lodash';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { Textarea } from '@gitroom/react/form/textarea';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';

View file

@ -1,7 +1,7 @@
import React, { FC, useCallback, useContext, useMemo, useState } from 'react';
import { MarketplaceProvider } from '@gitroom/frontend/components/marketplace/marketplace.provider';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { Input } from '@gitroom/react/form/input';
import { CustomSelect } from '@gitroom/react/form/custom.select';

View file

@ -10,7 +10,7 @@ import useSWR from 'swr';
import { Input } from '@gitroom/react/form/input';
import { useDebouncedCallback } from 'use-debounce';
import { OrderList } from '@gitroom/frontend/components/marketplace/order.list';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { Select } from '@gitroom/react/form/select';
import { countries } from '@gitroom/nestjs-libraries/services/stripe.country.list';
import { useT } from '@gitroom/react/translation/get.transation.service.client';

View file

@ -9,12 +9,13 @@ import useSWR from 'swr';
import { capitalize } from 'lodash';
import removeMd from 'remove-markdown';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { Post as PrismaPost } from '@prisma/client';
import dynamic from 'next/dynamic';
import { IntegrationContext } from '@gitroom/frontend/components/launches/helpers/use.integration';
import dayjs from 'dayjs';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
const PreviewPopupDynamic = dynamic(() =>
import('@gitroom/frontend/components/marketplace/preview.popup.dynamic').then(
(mod) => mod.PreviewPopupDynamic
@ -279,7 +280,7 @@ export const Post: FC<{
<IntegrationContext.Provider
value={{
allIntegrations: [],
date: dayjs(),
date: newDayjs(),
integration,
value: [],
}}

View file

@ -30,9 +30,13 @@ import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { ThirdPartyMedia } from '@gitroom/frontend/components/third-parties/third-party.media';
import { ReactSortable } from 'react-sortablejs';
import { useMediaSettings } from '@gitroom/frontend/components/launches/helpers/media.settings.component';
import {
MediaComponentInner,
useMediaSettings,
} from '@gitroom/frontend/components/launches/helpers/media.settings.component';
import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store';
import { AiVideo } from '@gitroom/frontend/components/launches/ai.video';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
const Polonto = dynamic(
() => import('@gitroom/frontend/components/launches/polonto')
);
@ -245,6 +249,10 @@ export const MediaBox: FC<{
const dragAndDrop = useCallback(
async (event: ClipboardEvent<HTMLDivElement> | File[]) => {
if (!ref?.current?.setOptions) {
return;
}
// @ts-ignore
const clipboardItems = event.map((p) => ({
kind: 'file',
@ -560,12 +568,12 @@ export const MultiMediaComponent: FC<{
dummy,
} = props;
const user = useUser();
const modals = useModals();
useEffect(() => {
if (value) {
setCurrentMedia(value);
}
}, [value]);
const [modal, setShowModal] = useState(false);
const [mediaModal, setMediaModal] = useState(false);
const [currentMedia, setCurrentMedia] = useState(value);
const mediaDirectory = useMediaDirectory();
@ -594,17 +602,14 @@ export const MultiMediaComponent: FC<{
[currentMedia]
);
const showModal = useCallback(() => {
if (!modal) {
onOpen?.();
} else {
onClose?.();
}
setShowModal(!modal);
}, [modal, onOpen, onClose]);
const closeDesignModal = useCallback(() => {
onClose?.();
setMediaModal(false);
}, [modal]);
modals.openModal({
askClose: false,
children: (close) => (
<MediaBox setMedia={changeMedia} closeModal={close} />
),
});
}, [changeMedia]);
const clearMedia = useCallback(
(topIndex: number) => () => {
const newMedia = currentMedia?.filter((f, index) => index !== topIndex);
@ -618,10 +623,19 @@ export const MultiMediaComponent: FC<{
},
[currentMedia]
);
const designMedia = useCallback(() => {
onOpen?.();
setMediaModal(true);
}, []);
if (!!user?.tier?.ai && !dummy) {
modals.openModal({
askClose: false,
title: 'Design Media',
size: '80%',
children: (close) => (
<Polonto setMedia={changeMedia} closeModal={close} />
),
});
}
}, [changeMedia]);
const mediaSettings = useMediaSettings();
@ -629,12 +643,7 @@ export const MultiMediaComponent: FC<{
return (
<>
<div className="flex flex-col gap-[8px] bg-bigStrip rounded-bl-[8px] select-none w-full">
{modal && <MediaBox setMedia={changeMedia} closeModal={showModal} />}
{mediaModal && !!user?.tier?.ai && !dummy && (
<Polonto setMedia={changeMedia} closeModal={closeDesignModal} />
)}
<div className="b1 flex flex-col gap-[8px] bg-bigStrip rounded-bl-[8px] select-none w-full">
<div className="flex gap-[10px]">
<Button
onClick={showModal}
@ -683,17 +692,31 @@ export const MultiMediaComponent: FC<{
<div className="w-full h-full relative group">
<div
onClick={async () => {
const data: any = await mediaSettings(media);
console.log(
value?.map((p) => (p.id === data.id ? data : p))
);
onChange({
target: {
name: 'upload',
value: value?.map((p) =>
p.id === data.id ? data : p
),
},
modals.openModal({
title: 'Media Settings',
children: (close) => (
<MediaComponentInner
media={media as any}
onClose={close}
onSelect={(value: any) => {
console.log(value);
onChange({
target: {
name: 'upload',
value: currentMedia.map((p) => {
if (p.id === media.id) {
return {
...p,
...value,
};
}
return p;
}),
},
});
}}
/>
),
});
}}
className="absolute top-[50%] left-[50%] -translate-x-[50%] -translate-y-[50%] bg-black/80 rounded-[10px] opacity-0 group-hover:opacity-100 transition-opacity z-[100]"
@ -734,8 +757,8 @@ export const MultiMediaComponent: FC<{
)}
</div>
{!dummy && (
<div className="flex gap-[10px] bg-newBgLineColor w-full">
<div className="flex py-[10px]">
<div className="flex gap-[10px] bg-newBgLineColor w-full b1">
<div className="flex py-[10px] b2">
<Button
onClick={designMedia}
className="ms-[10px] rounded-[4px] gap-[8px] !text-primary justify-center items-center w-[127px] flex border border-dashed border-newBgLineColor bg-newColColor"

View file

@ -88,18 +88,10 @@ export function useUppyUploader(props: {
// Expand generic types to specific ones
const expandedTypes = allowedTypes.flatMap((type) => {
if (type === 'image/*') {
return [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
];
return ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'];
}
if (type === 'video/*') {
return [
'video/mp4',
'video/mpeg',
];
return ['video/mp4', 'video/mpeg'];
}
return [type];
});
@ -214,12 +206,11 @@ export function useUppyUploader(props: {
return;
}
console.log(result);
if (transloadit.length > 0) {
// @ts-ignore
const allRes = result.transloadit[0].results;
const toSave = uniq<string>(
allRes[Object.keys(allRes)[0]].flatMap((item: any) =>
(allRes[Object.keys(allRes)[0]] || []).flatMap((item: any) =>
item.url.split('/').pop()
)
);

View file

@ -54,6 +54,7 @@ import { MarketplaceProvider } from '@gitroom/frontend/components/marketplace/ma
import { SpecialMessage } from '@gitroom/frontend/components/marketplace/special.message';
import { usePageVisibility } from '@gitroom/react/helpers/use.is.visible';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
export const Message: FC<{
message: Message;
seller: SellerBuyer;
@ -89,7 +90,7 @@ export const Message: FC<{
);
}, [amITheBuyerOrSeller, message]);
const time = useMemo(() => {
return dayjs(message.createdAt).format('h:mm A');
return newDayjs(message.createdAt).format('h:mm A');
}, [message]);
return (
<div className="flex gap-[10px]">

View file

@ -0,0 +1,51 @@
'use client';
import { FC, useCallback } from 'react';
export const AComponent: FC<{
editor: any;
currentValue: string;
}> = ({ editor }) => {
const mark = () => {
const previousUrl = editor?.getAttributes('link')?.href;
const url = window.prompt('URL', previousUrl);
// cancelled
if (url === null) {
return;
}
// empty
if (url === '') {
editor?.chain()?.focus()?.extendMarkRange('link')?.unsetLink()?.run();
return;
}
// update link
try {
editor?.chain()?.focus()?.extendMarkRange('link')?.setLink({ href: url })?.run();
} catch (e) {
}
editor?.commands?.focus();
};
return (
<div
onClick={mark}
className="select-none cursor-pointer w-[40px] p-[5px] text-center"
>
<svg
width="20"
height="20"
viewBox="0 0 26 26"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.7079 8.29252C17.8008 8.38539 17.8746 8.49568 17.9249 8.61708C17.9752 8.73847 18.0011 8.8686 18.0011 9.00002C18.0011 9.13143 17.9752 9.26156 17.9249 9.38296C17.8746 9.50436 17.8008 9.61465 17.7079 9.70752L9.70786 17.7075C9.61495 17.8004 9.50465 17.8741 9.38325 17.9244C9.26186 17.9747 9.13175 18.0006 9.00036 18.0006C8.86896 18.0006 8.73885 17.9747 8.61746 17.9244C8.49607 17.8741 8.38577 17.8004 8.29286 17.7075C8.19995 17.6146 8.12625 17.5043 8.07596 17.3829C8.02568 17.2615 7.9998 17.1314 7.9998 17C7.9998 16.8686 8.02568 16.7385 8.07596 16.6171C8.12625 16.4957 8.19995 16.3854 8.29286 16.2925L16.2929 8.29252C16.3857 8.19954 16.496 8.12578 16.6174 8.07546C16.7388 8.02513 16.8689 7.99923 17.0004 7.99923C17.1318 7.99923 17.2619 8.02513 17.3833 8.07546C17.5047 8.12578 17.615 8.19954 17.7079 8.29252ZM23.9504 2.05002C23.3003 1.39993 22.5286 0.884251 21.6793 0.532423C20.83 0.180596 19.9197 -0.000488281 19.0004 -0.000488281C18.081 -0.000488281 17.1707 0.180596 16.3214 0.532423C15.4721 0.884251 14.7004 1.39993 14.0504 2.05002L10.2929 5.80627C10.1052 5.99391 9.9998 6.2484 9.9998 6.51377C9.9998 6.77913 10.1052 7.03363 10.2929 7.22127C10.4805 7.40891 10.735 7.51432 11.0004 7.51432C11.2657 7.51432 11.5202 7.40891 11.7079 7.22127L15.4654 3.47127C16.4065 2.55083 17.6726 2.03866 18.989 2.04591C20.3053 2.05316 21.5657 2.57924 22.4966 3.50999C23.4276 4.44074 23.9539 5.70105 23.9613 7.01742C23.9688 8.33379 23.4569 9.6 22.5366 10.5413L18.7779 14.2988C18.5902 14.4862 18.4847 14.7406 18.4846 15.0058C18.4845 15.2711 18.5898 15.5255 18.7772 15.7131C18.9647 15.9008 19.219 16.0063 19.4843 16.0064C19.7495 16.0065 20.004 15.9012 20.1916 15.7138L23.9504 11.95C24.6004 11.3 25.1161 10.5283 25.468 9.67897C25.8198 8.82964 26.0009 7.91933 26.0009 7.00002C26.0009 6.0807 25.8198 5.17039 25.468 4.32107C25.1161 3.47174 24.6004 2.70004 23.9504 2.05002ZM14.2929 18.7775L10.5354 22.535C10.073 23.0078 9.52136 23.3842 8.9125 23.6423C8.30365 23.9004 7.64963 24.0352 6.98832 24.0389C6.32702 24.0425 5.67156 23.9149 5.05989 23.6635C4.44823 23.4121 3.89252 23.0418 3.42494 22.5742C2.95736 22.1065 2.5872 21.5507 2.33589 20.939C2.08458 20.3273 1.95711 19.6718 1.96087 19.0105C1.96463 18.3492 2.09954 17.6952 2.35779 17.0864C2.61603 16.4776 2.99249 15.9261 3.46536 15.4638L7.22161 11.7075C7.40925 11.5199 7.51466 11.2654 7.51466 11C7.51466 10.7347 7.40925 10.4802 7.22161 10.2925C7.03397 10.1049 6.77947 9.99946 6.51411 9.99946C6.24874 9.99946 5.99425 10.1049 5.80661 10.2925L2.05036 14.05C0.737536 15.3628 0 17.1434 0 19C0 20.8566 0.737536 22.6372 2.05036 23.95C3.36318 25.2628 5.14375 26.0004 7.00036 26.0004C8.85697 26.0004 10.6375 25.2628 11.9504 23.95L15.7079 20.1913C15.8953 20.0036 16.0006 19.7492 16.0005 19.4839C16.0004 19.2187 15.8949 18.9644 15.7072 18.7769C15.5196 18.5894 15.2652 18.4842 14.9999 18.4843C14.7347 18.4844 14.4803 18.5899 14.2929 18.7775Z"
fill="currentColor"
/>
</svg>
</div>
);
};

View file

@ -8,6 +8,7 @@ import { ManageModal } from '@gitroom/frontend/components/new-launch/manage.moda
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
import { useShallow } from 'zustand/react/shallow';
import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
export interface AddEditModalProps {
dummy?: boolean;
@ -46,7 +47,7 @@ export const AddEditModal: FC<AddEditModalProps> = (props) => {
const integrations = useLaunchStore((state) => state.integrations);
useEffect(() => {
setDummy(!!props.dummy);
setDate(props.date || dayjs());
setDate(props.date || newDayjs());
setAllIntegrations(props.allIntegrations || []);
setIsCreateSet(!!props.addEditSets);
}, []);
@ -116,6 +117,7 @@ export const AddEditModalInnerInner: FC<AddEditModalProps> = (props) => {
internal,
setTags,
setEditor,
setRepeater,
} = useLaunchStore(
useShallow((state) => ({
reset: state.reset,
@ -126,11 +128,15 @@ export const AddEditModalInnerInner: FC<AddEditModalProps> = (props) => {
internal: state.internal,
setTags: state.setTags,
setEditor: state.setEditor,
setRepeater: state.setRepeater
}))
);
useEffect(() => {
if (existingData.integration) {
if (existingData?.posts?.[0]?.intervalInDays) {
setRepeater(existingData.posts[0].intervalInDays);
}
setTags(
// @ts-ignore
existingData?.posts?.[0]?.tags?.map((p: any) => ({

View file

@ -75,9 +75,9 @@ export const BoldText: FC<{
currentValue: string;
}> = ({ editor }) => {
const mark = () => {
editor.commands.unsetUnderline();
editor.commands.toggleBold();
editor.commands.focus();
editor?.commands?.unsetUnderline();
editor?.commands?.toggleBold();
editor?.commands?.focus();
};
return (
<div

View file

@ -7,7 +7,7 @@ export const Bullets: FC<{
currentValue: string;
}> = ({ editor }) => {
const bullet = () => {
editor.commands.toggleBulletList();
editor?.commands?.toggleBulletList();
};
return (
<div

View file

@ -1,5 +1,5 @@
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { useModals } from '@mantine/modals';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import React, { FC } from 'react';
import { Button } from '@gitroom/react/form/button';
import copy from 'copy-to-clipboard';

View file

@ -10,6 +10,7 @@ import React, {
ClipboardEvent,
forwardRef,
useImperativeHandle,
Fragment,
} from 'react';
import clsx from 'clsx';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
@ -20,7 +21,10 @@ import { BoldText } from '@gitroom/frontend/components/new-launch/bold.text';
import { UText } from '@gitroom/frontend/components/new-launch/u.text';
import { SignatureBox } from '@gitroom/frontend/components/signature';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store';
import {
SelectedIntegrations,
useLaunchStore,
} from '@gitroom/frontend/components/new-launch/store';
import { useShallow } from 'zustand/react/shallow';
import { AddPostButton } from '@gitroom/frontend/components/new-launch/add.post.button';
import { MultiMediaComponent } from '@gitroom/frontend/components/media/media.component';
@ -28,10 +32,10 @@ import { UpDownArrow } from '@gitroom/frontend/components/launches/up.down.arrow
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
import { useCopilotAction, useCopilotReadable } from '@copilotkit/react-core';
import { LinkedinCompanyPop } from '@gitroom/frontend/components/launches/helpers/linkedin.component';
import { useDropzone } from 'react-dropzone';
import { useUppyUploader } from '@gitroom/frontend/components/media/new.uploader';
import { Dashboard } from '@uppy/react';
import Link from '@tiptap/extension-link';
import {
useEditor,
EditorContent,
@ -50,6 +54,11 @@ import { BulletList, ListItem } from '@tiptap/extension-list';
import { Bullets } from '@gitroom/frontend/components/new-launch/bullets.component';
import Heading from '@tiptap/extension-heading';
import { HeadingComponent } from '@gitroom/frontend/components/new-launch/heading.component';
import Mention from '@tiptap/extension-mention';
import { suggestion } from '@gitroom/frontend/components/new-launch/mention.component';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { AComponent } from '@gitroom/frontend/components/new-launch/a.component';
import { capitalize } from 'lodash';
const InterceptBoldShortcut = Extension.create({
name: 'preventBoldWithUnderline',
@ -58,8 +67,8 @@ const InterceptBoldShortcut = Extension.create({
return {
'Mod-b': () => {
// For example, toggle bold while removing underline
this.editor.commands.unsetUnderline();
return this.editor.commands.toggleBold();
this?.editor?.commands?.unsetUnderline();
return this?.editor?.commands?.toggleBold();
},
};
},
@ -72,58 +81,13 @@ const InterceptUnderlineShortcut = Extension.create({
return {
'Mod-u': () => {
// For example, toggle bold while removing underline
this.editor.commands.unsetBold();
return this.editor.commands.toggleUnderline();
this?.editor?.commands?.unsetBold();
return this?.editor?.commands?.toggleUnderline();
},
};
},
});
const Span = Node.create({
name: 'mention',
inline: true,
group: 'inline',
selectable: false,
atom: true,
addAttributes() {
return {
linkedinId: {
default: null,
},
label: {
default: '',
},
};
},
parseHTML() {
return [
{
tag: 'span[data-linkedin-id]',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes(
// Exclude linkedinId from HTMLAttributes to avoid duplication
Object.fromEntries(
Object.entries(HTMLAttributes).filter(([key]) => key !== 'linkedinId')
),
{
'data-linkedin-id': HTMLAttributes.linkedinId,
class: 'mention',
}
),
`@${HTMLAttributes.label}`,
];
},
});
export const EditorWrapper: FC<{
totalPosts: number;
value: string;
@ -155,6 +119,8 @@ export const EditorWrapper: FC<{
editor,
loadedState,
setLoadedState,
selectedIntegration,
chars,
} = useLaunchStore(
useShallow((state) => ({
internal: state.internal.find((p) => p.integration.id === state.current),
@ -183,6 +149,8 @@ export const EditorWrapper: FC<{
editor: state.editor,
loadedState: state.loaded,
setLoadedState: state.setLoaded,
selectedIntegration: state.selectedIntegrations,
chars: state.chars,
}))
);
@ -398,6 +366,21 @@ export const EditorWrapper: FC<{
totalChars={totalChars}
appendImages={appendImages(index)}
dummy={dummy}
selectedIntegration={selectedIntegration}
chars={chars}
childButton={
<>
{canEdit ? (
<AddPostButton
num={index}
onClick={addValue(index)}
postComment={postComment}
/>
) : (
<div className="h-[25px]" />
)}
</>
}
/>
</div>
<div className="flex flex-col items-center gap-[10px]">
@ -447,16 +430,6 @@ export const EditorWrapper: FC<{
)}
</div>
</div>
{canEdit ? (
<AddPostButton
num={index}
onClick={addValue(index)}
postComment={postComment}
/>
) : (
<div className="h-[25px]" />
)}
</div>
))}
</div>
@ -477,7 +450,10 @@ export const Editor: FC<{
validateChars?: boolean;
identifier?: string;
totalChars?: number;
selectedIntegration: SelectedIntegrations[];
dummy: boolean;
chars: Record<string, number>;
childButton?: React.ReactNode;
}> = (props) => {
const {
editorType = 'normal',
@ -485,11 +461,13 @@ export const Editor: FC<{
pictures,
setImages,
num,
autoComplete,
validateChars,
identifier,
appendImages,
selectedIntegration,
dummy,
chars,
childButton,
} = props;
const user = useUser();
const [id] = useState(makeId(10));
@ -544,31 +522,14 @@ export const Editor: FC<{
const addText = useCallback(
(emoji: string) => {
editorRef?.current?.editor.commands.insertContent(emoji);
editorRef?.current?.editor.commands.focus();
editorRef?.current?.editor?.commands?.insertContent(emoji);
editorRef?.current?.editor?.commands?.focus();
},
[props.value, id]
);
const addLinkedinTag = useCallback((text: string) => {
const id = text.split('(')[1].split(')')[0];
const name = text.split('[')[1].split(']')[0];
editorRef?.current?.editor
.chain()
.focus()
.insertContent({
type: 'mention',
attrs: {
linkedinId: id,
label: name,
},
})
.run();
}, []);
return (
<div>
<div className="flex flex-col gap-[20px]">
<div className="relative bg-bigStrip" id={id}>
<div className="flex gap-[5px] bg-newBgLineColor border-b border-t border-customColor3 justify-center items-center p-[5px]">
<SignatureBox editor={editorRef?.current?.editor} />
@ -580,27 +541,29 @@ export const Editor: FC<{
editor={editorRef?.current?.editor}
currentValue={props.value!}
/>
{(editorType === 'markdown' || editorType === 'html') && (
<>
<Bullets
editor={editorRef?.current?.editor}
currentValue={props.value!}
/>
<HeadingComponent
editor={editorRef?.current?.editor}
currentValue={props.value!}
/>
</>
)}
{(editorType === 'markdown' || editorType === 'html') &&
identifier !== 'telegram' && (
<>
<AComponent
editor={editorRef?.current?.editor}
currentValue={props.value!}
/>
<Bullets
editor={editorRef?.current?.editor}
currentValue={props.value!}
/>
<HeadingComponent
editor={editorRef?.current?.editor}
currentValue={props.value!}
/>
</>
)}
<div
className="select-none cursor-pointer w-[40px] p-[5px] text-center"
onClick={() => setEmojiPickerOpen(!emojiPickerOpen)}
>
{'\uD83D\uDE00'}
</div>
{identifier === 'linkedin' || identifier === 'linkedin-page' ? (
<LinkedinCompanyPop addText={addLinkedinTag} />
) : null}
<div className="relative">
<div className="absolute z-[200] top-[35px] -start-[50px]">
<EmojiPicker
@ -692,17 +655,48 @@ export const Editor: FC<{
</div>
</div>
</div>
<div className="absolute bottom-10px end-[25px]">
{(props?.totalChars || 0) > 0 && (
<div
className={clsx(
'text-end text-sm mt-1',
valueWithoutHtml.length > props.totalChars && '!text-red-500'
)}
>
{valueWithoutHtml.length}/{props.totalChars}
</div>
)}
<div className="flex">
<div className="flex-1">{childButton}</div>
<div className="bottom-10px end-[25px]">
{(props?.totalChars || 0) > 0 ? (
<div
className={clsx(
'text-end text-sm mt-1',
valueWithoutHtml.length > props.totalChars && '!text-red-500'
)}
>
{valueWithoutHtml.length}/{props.totalChars}
</div>
) : (
<div
className={clsx(
'text-end text-sm mt-1 grid grid-cols-[max-content_max-content] gap-x-[5px]'
)}
>
{selectedIntegration?.map((p) => (
<Fragment key={p.integration.id}>
<div
className={
valueWithoutHtml.length > chars?.[p.integration.id] &&
'!text-red-500'
}
>
{p.integration.name} ({capitalize(p.integration.identifier)}
):
</div>
<div
className={
valueWithoutHtml.length > chars?.[p.integration.id] &&
'!text-red-500'
}
>
{valueWithoutHtml.length}/{chars?.[p.integration.id]}
</div>
</Fragment>
))}
</div>
)}
</div>
</div>
</div>
);
@ -717,6 +711,43 @@ export const OnlyEditor = forwardRef<
paste?: (event: ClipboardEvent | File[]) => void;
}
>(({ editorType, value, onChange, paste }, ref) => {
const fetch = useFetch();
const { internal } = useLaunchStore(
useShallow((state) => ({
internal: state.internal.find((p) => p.integration.id === state.current),
}))
);
const loadList = useCallback(
async (query: string) => {
if (query.length < 2) {
return [];
}
if (!internal?.integration.id) {
return [];
}
try {
const load = await fetch('/integrations/mentions', {
method: 'POST',
body: JSON.stringify({
name: 'mention',
id: internal.integration.id,
data: { query },
}),
});
const result = await load.json();
return result;
} catch (error) {
console.error('Error loading mentions:', error);
return [];
}
},
[internal, fetch]
);
const editor = useEditor({
extensions: [
Document,
@ -726,12 +757,113 @@ export const OnlyEditor = forwardRef<
Bold,
InterceptBoldShortcut,
InterceptUnderlineShortcut,
Span,
BulletList,
ListItem,
Heading.configure({
levels: [1, 2, 3],
}),
...(editorType === 'html' || editorType === 'markdown'
? [
Link.configure({
openOnClick: false,
autolink: true,
defaultProtocol: 'https',
protocols: ['http', 'https'],
isAllowedUri: (url, ctx) => {
try {
// prevent transforming plain emails like foo@bar.com into links
const trimmed = String(url).trim();
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailPattern.test(trimmed)) {
return false;
}
// construct URL
const parsedUrl = url.includes(':')
? new URL(url)
: new URL(`${ctx.defaultProtocol}://${url}`);
// use default validation
if (!ctx.defaultValidate(parsedUrl.href)) {
return false;
}
// disallowed protocols
const disallowedProtocols = ['ftp', 'file', 'mailto'];
const protocol = parsedUrl.protocol.replace(':', '');
if (disallowedProtocols.includes(protocol)) {
return false;
}
// only allow protocols specified in ctx.protocols
const allowedProtocols = ctx.protocols.map((p) =>
typeof p === 'string' ? p : p.scheme
);
if (!allowedProtocols.includes(protocol)) {
return false;
}
// all checks have passed
return true;
} catch {
return false;
}
},
shouldAutoLink: (url) => {
try {
// prevent auto-linking of plain emails like foo@bar.com
const trimmed = String(url).trim();
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailPattern.test(trimmed)) {
return false;
}
// construct URL
const parsedUrl = url.includes(':')
? new URL(url)
: new URL(`https://${url}`);
// only auto-link if the domain is not in the disallowed list
const disallowedDomains = [
'example-no-autolink.com',
'another-no-autolink.com',
];
const domain = parsedUrl.hostname;
return !disallowedDomains.includes(domain);
} catch {
return false;
}
},
}),
]
: []),
...(internal?.integration?.id
? [
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
renderHTML({ options, node }) {
return [
'span',
mergeAttributes(options.HTMLAttributes, {
'data-mention-id': node.attrs.id || '',
'data-mention-label': node.attrs.label || '',
}),
`@${node.attrs.label}`,
];
},
suggestion: suggestion(loadList),
}),
]
: []),
...(editorType === 'html' || editorType === 'markdown'
? [
Heading.configure({
levels: [1, 2, 3],
}),
]
: []),
History.configure({
depth: 100, // default is 100
newGroupDelay: 100, // default is 500ms

View file

@ -50,6 +50,8 @@ export const ThreadFinisher = () => {
<div className="flex gap-[4px]">
<div className="flex-1 editor text-textColor">
<Editor
chars={{}}
selectedIntegration={[]}
onChange={(val) => setValue('thread_finisher', val)}
value={value}
totalPosts={1}

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