From 0f17d56522f762677ee6cb9f161380c3e100099b Mon Sep 17 00:00:00 2001 From: Nevo David Date: Wed, 25 Jun 2025 16:09:24 +0700 Subject: [PATCH] feat: building --- .../src/components/launches/bold.text.tsx | 2 +- .../src/components/launches/calendar.tsx | 3 +- .../src/components/launches/u.text.tsx | 2 +- .../new-launch/add.edit.modal.inner.tsx | 99 +++++ .../components/new-launch/add.edit.modal.tsx | 96 +++++ .../components/new-launch/add.post.button.tsx | 35 ++ .../src/components/new-launch/bold.text.tsx | 121 ++++++ .../src/components/new-launch/editor.tsx | 185 +++++++++ .../new-launch/finisher/thread.finisher.tsx | 65 +++ .../new-launch/helpers/date.picker.tsx | 102 +++++ .../new-launch/helpers/dnd.provider.tsx | 10 + .../new-launch/helpers/isuscitizen.utils.tsx | 6 + .../new-launch/helpers/linkedin.component.tsx | 189 +++++++++ .../helpers/new.image.component.tsx | 91 +++++ .../helpers/top.title.component.tsx | 68 ++++ .../helpers/use.custom.provider.function.ts | 29 ++ .../new-launch/helpers/use.existing.data.tsx | 21 + .../new-launch/helpers/use.expend.tsx | 31 ++ .../new-launch/helpers/use.formatting.ts | 49 +++ .../helpers/use.hide.top.editor.tsx | 31 ++ .../new-launch/helpers/use.integration.tsx | 27 ++ .../helpers/use.move.to.integration.tsx | 47 +++ .../new-launch/helpers/use.values.ts | 96 +++++ .../new-launch/picks.socials.component.tsx | 79 ++++ .../providers/bluesky/bluesky.provider.tsx | 31 ++ .../facebook/facebook.continue.tsx | 121 ++++++ .../instagram/instagram.continue.tsx | 123 ++++++ .../linkedin/linkedin.continue.tsx | 110 ++++++ .../providers/continue-provider/list.tsx | 10 + .../providers/devto/devto.provider.tsx | 95 +++++ .../new-launch/providers/devto/devto.tags.tsx | 73 ++++ .../providers/devto/fonts/SFNS.woff2 | 0 .../providers/devto/select.organization.tsx | 57 +++ .../discord/discord.channel.select.tsx | 57 +++ .../providers/discord/discord.provider.tsx | 22 ++ .../providers/dribbble/dribbble.provider.tsx | 49 +++ .../providers/dribbble/dribbble.teams.tsx | 60 +++ .../providers/facebook/facebook.provider.tsx | 4 + .../providers/hashnode/hashnode.provider.tsx | 97 +++++ .../hashnode/hashnode.publications.tsx | 57 +++ .../providers/hashnode/hashnode.tags.tsx | 78 ++++ .../providers/high.order.provider.tsx | 113 ++++++ .../instagram/instagram.collaborators.tsx | 90 +++++ .../providers/instagram/instagram.tags.tsx | 102 +++++ .../providers/lemmy/lemmy.provider.tsx | 84 ++++ .../new-launch/providers/lemmy/subreddit.tsx | 162 ++++++++ .../providers/linkedin/linkedin.provider.tsx | 52 +++ .../providers/mastodon/mastodon.provider.tsx | 4 + .../medium/fonts/Charter Bold Italic.ttf | 0 .../providers/medium/fonts/Charter Bold.ttf | 0 .../providers/medium/fonts/Charter Italic.ttf | 0 .../medium/fonts/Charter Regular.ttf | 0 .../providers/medium/fonts/stylesheet.css | 37 ++ .../providers/medium/medium.provider.tsx | 91 +++++ .../providers/medium/medium.publications.tsx | 58 +++ .../providers/medium/medium.tags.tsx | 82 ++++ .../providers/nostr/nostr.provider.tsx | 12 + .../providers/pinterest/pinterest.board.tsx | 61 +++ .../pinterest/pinterest.provider.tsx | 76 ++++ .../providers/reddit/reddit.provider.tsx | 221 +++++++++++ .../new-launch/providers/reddit/subreddit.tsx | 285 ++++++++++++++ .../providers/show.all.providers.tsx | 166 ++++++++ .../providers/slack/slack.channel.select.tsx | 57 +++ .../providers/slack/slack.provider.tsx | 22 ++ .../providers/telegram/telegram.provider.tsx | 12 + .../providers/threads/threads.provider.tsx | 17 + .../providers/tiktok/tiktok.provider.tsx | 372 ++++++++++++++++++ .../new-launch/providers/vk/vk.provider.tsx | 12 + .../providers/warpcast/subreddit.tsx | 146 +++++++ .../providers/warpcast/warpcast.provider.tsx | 70 ++++ .../providers/x/fonts/Chirp-Bold.woff2 | 0 .../providers/x/fonts/Chirp-Regular.woff2 | 0 .../new-launch/providers/x/x.provider.tsx | 113 ++++++ .../providers/youtube/youtube.provider.tsx | 74 ++++ .../components/new-launch/select.current.tsx | 70 ++++ .../src/components/new-launch/store.ts | 306 ++++++++++++++ .../src/components/new-launch/u.text.tsx | 128 ++++++ apps/frontend/src/components/signature.tsx | 2 +- package.json | 3 +- pnpm-lock.yaml | 45 ++- 80 files changed, 5652 insertions(+), 21 deletions(-) create mode 100644 apps/frontend/src/components/new-launch/add.edit.modal.inner.tsx create mode 100644 apps/frontend/src/components/new-launch/add.edit.modal.tsx create mode 100644 apps/frontend/src/components/new-launch/add.post.button.tsx create mode 100644 apps/frontend/src/components/new-launch/bold.text.tsx create mode 100644 apps/frontend/src/components/new-launch/editor.tsx create mode 100644 apps/frontend/src/components/new-launch/finisher/thread.finisher.tsx create mode 100644 apps/frontend/src/components/new-launch/helpers/date.picker.tsx create mode 100644 apps/frontend/src/components/new-launch/helpers/dnd.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/helpers/isuscitizen.utils.tsx create mode 100644 apps/frontend/src/components/new-launch/helpers/linkedin.component.tsx create mode 100644 apps/frontend/src/components/new-launch/helpers/new.image.component.tsx create mode 100644 apps/frontend/src/components/new-launch/helpers/top.title.component.tsx create mode 100644 apps/frontend/src/components/new-launch/helpers/use.custom.provider.function.ts create mode 100644 apps/frontend/src/components/new-launch/helpers/use.existing.data.tsx create mode 100644 apps/frontend/src/components/new-launch/helpers/use.expend.tsx create mode 100644 apps/frontend/src/components/new-launch/helpers/use.formatting.ts create mode 100644 apps/frontend/src/components/new-launch/helpers/use.hide.top.editor.tsx create mode 100644 apps/frontend/src/components/new-launch/helpers/use.integration.tsx create mode 100644 apps/frontend/src/components/new-launch/helpers/use.move.to.integration.tsx create mode 100644 apps/frontend/src/components/new-launch/helpers/use.values.ts create mode 100644 apps/frontend/src/components/new-launch/picks.socials.component.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/bluesky/bluesky.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/continue-provider/facebook/facebook.continue.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/continue-provider/instagram/instagram.continue.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/continue-provider/linkedin/linkedin.continue.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/continue-provider/list.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/devto/devto.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/devto/devto.tags.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/devto/fonts/SFNS.woff2 create mode 100644 apps/frontend/src/components/new-launch/providers/devto/select.organization.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/discord/discord.channel.select.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/discord/discord.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/dribbble/dribbble.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/dribbble/dribbble.teams.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/facebook/facebook.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/hashnode/hashnode.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/hashnode/hashnode.publications.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/hashnode/hashnode.tags.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/high.order.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/instagram/instagram.collaborators.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/instagram/instagram.tags.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/lemmy/lemmy.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/lemmy/subreddit.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/linkedin/linkedin.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/mastodon/mastodon.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/medium/fonts/Charter Bold Italic.ttf create mode 100644 apps/frontend/src/components/new-launch/providers/medium/fonts/Charter Bold.ttf create mode 100644 apps/frontend/src/components/new-launch/providers/medium/fonts/Charter Italic.ttf create mode 100644 apps/frontend/src/components/new-launch/providers/medium/fonts/Charter Regular.ttf create mode 100644 apps/frontend/src/components/new-launch/providers/medium/fonts/stylesheet.css create mode 100644 apps/frontend/src/components/new-launch/providers/medium/medium.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/medium/medium.publications.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/medium/medium.tags.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/nostr/nostr.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/pinterest/pinterest.board.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/pinterest/pinterest.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/reddit/subreddit.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/show.all.providers.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/slack/slack.channel.select.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/slack/slack.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/telegram/telegram.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/threads/threads.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/tiktok/tiktok.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/vk/vk.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/warpcast/subreddit.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/warpcast/warpcast.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/x/fonts/Chirp-Bold.woff2 create mode 100644 apps/frontend/src/components/new-launch/providers/x/fonts/Chirp-Regular.woff2 create mode 100644 apps/frontend/src/components/new-launch/providers/x/x.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/youtube/youtube.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/select.current.tsx create mode 100644 apps/frontend/src/components/new-launch/store.ts create mode 100644 apps/frontend/src/components/new-launch/u.text.tsx diff --git a/apps/frontend/src/components/launches/bold.text.tsx b/apps/frontend/src/components/launches/bold.text.tsx index 02262da2..96ad3318 100644 --- a/apps/frontend/src/components/launches/bold.text.tsx +++ b/apps/frontend/src/components/launches/bold.text.tsx @@ -88,7 +88,7 @@ export const BoldText: FC<{ return (
= () => { + const t = useT(); + const ref = useRef(null); + + return ( +
+
+
+ +
asd
+
+ + +
+ +
+
+ +
+
media
+
+
+
+
+
+
+
+
+
+
+
{ + console.log(await ref.current.checkAllValid()); + }} + > + test +
+
+
+
+
+
+
+
+ + + + + +
+
+ +
+
+
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/add.edit.modal.tsx b/apps/frontend/src/components/new-launch/add.edit.modal.tsx new file mode 100644 index 00000000..64b24c1b --- /dev/null +++ b/apps/frontend/src/components/new-launch/add.edit.modal.tsx @@ -0,0 +1,96 @@ +'use client'; +import 'reflect-metadata'; +import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; +import dayjs from 'dayjs'; +import type { CreatePostDto } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto'; +import { FC, useEffect, useState } from 'react'; +import { useExistingData } from '@gitroom/frontend/components/new-launch/helpers/use.existing.data'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { AddEditModalInnerInner } from '@gitroom/frontend/components/new-launch/add.edit.modal.inner'; +import { Integrations } from '@gitroom/frontend/components/launches/calendar.context'; + +export interface AddEditModalProps { + date: dayjs.Dayjs; + integrations: Integrations[]; + allIntegrations?: Integrations[]; + set?: CreatePostDto; + addEditSets?: (data: any) => void; + reopenModal: () => void; + mutate: () => void; + padding?: string; + customClose?: () => void; + onlyValues?: Array<{ + content: string; + id?: string; + image?: Array<{ + id: string; + path: string; + }>; + }>; +} + +export const AddEditModal: FC = (props) => { + const setAllIntegrations = useLaunchStore( + (state) => state.setAllIntegrations + ); + + const integrations = useLaunchStore((state) => state.integrations); + + useEffect(() => { + setAllIntegrations(props.integrations || []); + }, []); + + if (!integrations.length) { + return null; + } + + return ; +}; + +export const AddEditModalInner: FC = (props) => { + const existingData = useExistingData(); + const reset = useLaunchStore((state) => state.reset); + + const addOrRemoveSelectedIntegration = useLaunchStore( + (state) => state.addOrRemoveSelectedIntegration + ); + + const addGlobalValue = useLaunchStore((state) => state.addGlobalValue); + const addInternalValue = useLaunchStore((state) => state.addInternalValue); + const selectedIntegrations = useLaunchStore((state) => state.selectedIntegrations); + const global = useLaunchStore((state) => state.global); + const internal = useLaunchStore((state) => state.internal); + + useEffect(() => { + if (existingData.integration) { + const integration = props.integrations.find( + (i) => i.id === existingData.integration + ); + addOrRemoveSelectedIntegration(integration, existingData.settings); + + addInternalValue(0, existingData.settings, existingData.posts.map((post) => ({ + content: post.content, + id: post.id, + // @ts-ignore + media: post.image as any[], + }))); + } + else { + addGlobalValue(0, [{ + content: '', + id: makeId(10), + media: [], + }]); + } + + return () => { + reset(); + }; + }, []); + + if (!selectedIntegrations.length && !global.length && !internal.length) { + return null; + } + + return ; +}; diff --git a/apps/frontend/src/components/new-launch/add.post.button.tsx b/apps/frontend/src/components/new-launch/add.post.button.tsx new file mode 100644 index 00000000..ae9cd85a --- /dev/null +++ b/apps/frontend/src/components/new-launch/add.post.button.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { Button } from '@gitroom/react/form/button'; +import React, { FC } from 'react'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +export const AddPostButton: FC<{ + onClick: () => void; + num: number; +}> = (props) => { + const { onClick, num } = props; + const t = useT(); + + return ( + + ); +}; diff --git a/apps/frontend/src/components/new-launch/bold.text.tsx b/apps/frontend/src/components/new-launch/bold.text.tsx new file mode 100644 index 00000000..06dd7db0 --- /dev/null +++ b/apps/frontend/src/components/new-launch/bold.text.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { FC, useCallback } from 'react'; +import { Editor, Transforms } from 'slate'; +import { ReactEditor } from 'slate-react'; +const originalMap = { + a: '𝗮', + b: '𝗯', + c: '𝗰', + d: '𝗱', + e: '𝗲', + f: '𝗳', + g: '𝗴', + h: '𝗵', + i: '𝗶', + j: '𝗷', + k: '𝗸', + l: '𝗹', + m: '𝗺', + n: '𝗻', + o: '𝗼', + p: '𝗽', + q: '𝗾', + r: '𝗿', + s: '𝘀', + t: '𝘁', + u: '𝘂', + v: '𝘃', + w: '𝘄', + x: '𝘅', + y: '𝘆', + z: '𝘇', + A: '𝗔', + B: '𝗕', + C: '𝗖', + D: '𝗗', + E: '𝗘', + F: '𝗙', + G: '𝗚', + H: '𝗛', + I: '𝗜', + J: '𝗝', + K: '𝗞', + L: '𝗟', + M: '𝗠', + N: '𝗡', + O: '𝗢', + P: '𝗣', + Q: '𝗤', + R: '𝗥', + S: '𝗦', + T: '𝗧', + U: '𝗨', + V: '𝗩', + W: '𝗪', + X: '𝗫', + Y: '𝗬', + Z: '𝗭', + '1': '𝟭', + '2': '𝟮', + '3': '𝟯', + '4': '𝟰', + '5': '𝟱', + '6': '𝟲', + '7': '𝟳', + '8': '𝟴', + '9': '𝟵', + '0': '𝟬', +}; +const reverseMap = Object.fromEntries( + Object.entries(originalMap).map(([key, value]) => [value, key]) +); +export const BoldText: FC<{ + editor: any; + currentValue: string; +}> = ({ editor }) => { + const mark = () => { + const selectedText = Editor.string(editor, editor.selection); + const newText = Array.from( + !selectedText ? prompt('What do you want to write?') || '' : selectedText + ) + .map((char) => { + // @ts-ignore + return originalMap?.[char] || reverseMap?.[char] || char; + }) + .join(''); + Transforms.insertText(editor, newText); + ReactEditor.focus(editor); + }; + return ( +
+ + + + + + + + + + +
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/editor.tsx b/apps/frontend/src/components/new-launch/editor.tsx new file mode 100644 index 00000000..c46c15b2 --- /dev/null +++ b/apps/frontend/src/components/new-launch/editor.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { FC, useCallback, useMemo, useRef, useState } from 'react'; +import { CopilotTextarea } from '@copilotkit/react-textarea'; +import clsx from 'clsx'; +import { useUser } from '@gitroom/frontend/components/layout/user.context'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { Transforms } from 'slate'; +import EmojiPicker from 'emoji-picker-react'; +import { Theme } from 'emoji-picker-react'; +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 { useShallow } from 'zustand/react/shallow'; +import { AddPostButton } from '@gitroom/frontend/components/new-launch/add.post.button'; +export const EditorWrapper: FC<{ + totalPosts: number; + value: string; +}> = (props) => { + const { + setGlobalValueText, + setInternalValueText, + addRemoveInternal, + internal, + global, + current, + addInternalValue, + addGlobalValue, + } = useLaunchStore( + useShallow((state) => ({ + internal: state.internal.find((p) => p.integration.id === state.current), + global: state.global, + current: state.current, + addRemoveInternal: state.addRemoveInternal, + setInternalValueText: state.setInternalValueText, + setGlobalValueText: state.setGlobalValueText, + addInternalValue: state.addInternalValue, + addGlobalValue: state.addGlobalValue, + })) + ); + + const canEdit = useMemo(() => { + return current === 'global' || !!internal; + }, [current, internal]); + + const items = useMemo(() => { + if (internal) { + return internal.integrationValue; + } + + return global; + }, [current, internal, global]); + + const changeValue = useCallback( + (index: number) => (value: string) => { + if (internal) { + return setInternalValueText(current, index, value); + } + + return setGlobalValueText(index, value); + }, + [current, global, internal] + ); + + const addValue = useCallback( + (index: number) => () => { + if (internal) { + return addInternalValue(index, current, [ + { + content: '', + id: makeId(10), + media: [], + }, + ]); + } + + return addGlobalValue(index, [ + { + content: '', + id: makeId(10), + media: [], + }, + ]); + }, + [current, global, internal] + ); + + return items.map((g, index) => ( +
+ {!canEdit && ( +
addRemoveInternal(current)} + className="select-none cursor-pointer absolute w-full h-full left-0 top-0 bg-red-600/10 z-[100]" + > +
+ Edit +
+
+ )} +
+ +
+ + {canEdit && } +
+ )); +}; + +export const Editor: FC<{ + totalPosts: number; + value: string; + onChange: (value: string) => void; +}> = (props) => { + const user = useUser(); + const [id] = useState(makeId(10)); + const newRef = useRef(null); + const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); + const t = useT(); + + const addText = useCallback( + (emoji: string) => { + setTimeout(() => { + // @ts-ignore + Transforms.insertText(newRef?.current?.editor!, emoji); + }, 10); + }, + [props.value, id] + ); + return ( + <> +
+ 1 && '!max-h-80' + )} + value={props.value} + onChange={(e) => { + props?.onChange?.(e.target.value); + }} + // onPaste={props.onPaste} + placeholder={t('write_your_reply', 'Write your reply...')} + autosuggestionsConfig={{ + textareaPurpose: `Assist me in writing social media posts.`, + chatApiConfigs: {}, + disabled: !user?.tier?.ai, + }} + /> +
+
+ + + +
setEmojiPickerOpen(!emojiPickerOpen)} + > + {t('', '\uD83D\uDE00')} +
+
+
+ { + addText(e.emoji); + setEmojiPickerOpen(false); + }} + open={emojiPickerOpen} + /> +
+ + ); +}; diff --git a/apps/frontend/src/components/new-launch/finisher/thread.finisher.tsx b/apps/frontend/src/components/new-launch/finisher/thread.finisher.tsx new file mode 100644 index 00000000..e659fa1a --- /dev/null +++ b/apps/frontend/src/components/new-launch/finisher/thread.finisher.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { Slider } from '@gitroom/react/form/slider'; +import clsx from 'clsx'; +import { Editor } from '@gitroom/frontend/components/new-launch/editor'; +import { useIntegration } from '@gitroom/frontend/components/new-launch/helpers/use.integration'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; + +export const ThreadFinisher = () => { + const integration = useIntegration(); + const { register, watch, setValue } = useSettings(); + const t = useT(); + + register('active_thread_finisher', { + value: false, + }); + + register('thread_finisher', { + value: t('that_a_wrap', { + username: + integration.integration?.display || integration.integration?.name, + }), + }); + + const slider = watch('active_thread_finisher'); + const value = watch('thread_finisher'); + + return ( +
+
+
Add a thread finisher
+
+ setValue('active_thread_finisher', p === 'on')} + fill={true} + /> +
+
+
+
+
+
+
+ setValue('thread_finisher', val)} + value={value} + height={150} + totalPosts={1} + order={1} + preview="edit" + /> +
+
+
+
+
+
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/helpers/date.picker.tsx b/apps/frontend/src/components/new-launch/helpers/date.picker.tsx new file mode 100644 index 00000000..6d3a812c --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/date.picker.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { FC, useCallback, useState } from 'react'; +import dayjs from 'dayjs'; +import { Calendar, TimeInput } from '@mantine/dates'; +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'; +export const DatePicker: FC<{ + date: dayjs.Dayjs; + onChange: (day: dayjs.Dayjs) => void; +}> = (props) => { + const { date, onChange } = props; + const [open, setOpen] = useState(false); + const t = useT(); + + const changeShow = useCallback(() => { + setOpen((prev) => !prev); + }, []); + const ref = useClickOutside(() => { + setOpen(false); + }); + const changeDate = useCallback( + (type: 'date' | 'time') => (day: Date) => { + onChange( + dayjs( + 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] + ); + return ( +
+
+ {date.format(isUSCitizen() ? 'MM/DD/YYYY hh:mm A' : 'DD/MM/YYYY HH:mm')} +
+
+ + + +
+ {open && ( +
e.stopPropagation()} + className="animate-normalFadeDown absolute top-[100%] mt-[16px] end-0 bg-sixth border border-tableBorder text-textColor rounded-[16px] z-[300] p-[16px] flex flex-col" + > + { + if (modifiers.weekend) { + return '!text-customColor28'; + } + if (modifiers.outside) { + return '!text-gray'; + } + if (modifiers.selected) { + return '!text-white !bg-seventh !outline-none'; + } + return '!text-textColor'; + }} + classNames={{ + day: 'hover:bg-seventh', + calendarHeaderControl: 'text-textColor hover:bg-third', + calendarHeaderLevel: 'text-textColor hover:bg-third', // cell: 'child:!text-textColor' + }} + /> + + +
+ )} +
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/helpers/dnd.provider.tsx b/apps/frontend/src/components/new-launch/helpers/dnd.provider.tsx new file mode 100644 index 00000000..82ff70c9 --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/dnd.provider.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { FC, ReactNode } from 'react'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { DndProvider } from 'react-dnd'; +export const DNDProvider: FC<{ + children: ReactNode; +}> = ({ children }) => { + return {children}; +}; diff --git a/apps/frontend/src/components/new-launch/helpers/isuscitizen.utils.tsx b/apps/frontend/src/components/new-launch/helpers/isuscitizen.utils.tsx new file mode 100644 index 00000000..3490b57d --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/isuscitizen.utils.tsx @@ -0,0 +1,6 @@ +'use client'; + +export const isUSCitizen = () => { + const userLanguage = navigator.language || navigator.languages[0]; + return userLanguage.startsWith('en-US'); +}; diff --git a/apps/frontend/src/components/new-launch/helpers/linkedin.component.tsx b/apps/frontend/src/components/new-launch/helpers/linkedin.component.tsx new file mode 100644 index 00000000..ef5883a8 --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/linkedin.component.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { EventEmitter } from 'events'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { TopTitle } from '@gitroom/frontend/components/new-launch/helpers/top.title.component'; +import { + executeCommand, + ExecuteState, + ICommand, + selectWord, + TextAreaTextApi, +} from '@uiw/react-md-editor'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { Input } from '@gitroom/react/form/input'; +import { Button } from '@gitroom/react/form/button'; +import { useToaster } from '@gitroom/react/toaster/toaster'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +const postUrlEmitter = new EventEmitter(); +export const ShowLinkedinCompany = () => { + const [showPostSelector, setShowPostSelector] = useState(false); + const [id, setId] = useState(''); + const [callback, setCallback] = useState<{ + callback: (tag: string) => void; + // eslint-disable-next-line @typescript-eslint/no-empty-function + } | null>({ + callback: (tag: string) => {}, + } as any); + useEffect(() => { + postUrlEmitter.on( + 'show', + (params: { id: string; callback: (url: string) => void }) => { + setCallback(params); + setId(params.id); + setShowPostSelector(true); + } + ); + return () => { + setShowPostSelector(false); + setCallback(null); + setId(''); + postUrlEmitter.removeAllListeners(); + }; + }, []); + const close = useCallback(() => { + setShowPostSelector(false); + setCallback(null); + setId(''); + }, []); + if (!showPostSelector) { + return <>; + } + return ( + + ); +}; +export const showPostSelector = (id: string) => { + return new Promise((resolve) => { + postUrlEmitter.emit('show', { + id, + callback: (tag: string) => { + resolve(tag); + }, + }); + }); +}; +export const LinkedinCompany: FC<{ + onClose: () => void; + onSelect: (tag: string) => void; + id: string; +}> = (props) => { + const { onClose, onSelect, id } = props; + const fetch = useFetch(); + const [company, setCompany] = useState(null); + const toast = useToaster(); + const t = useT(); + const getCompany = async () => { + if (!company) { + return; + } + try { + const { options } = await ( + await fetch('/integrations/function', { + method: 'POST', + body: JSON.stringify({ + id, + name: 'company', + data: { + url: company, + }, + }), + }) + ).json(); + onSelect(options.value); + onClose(); + } catch (e) { + toast.show('Failed to load profile', 'warning'); + } + }; + return ( +
+
+
+
+ +
+ +
+
+ setCompany(e.target.value)} + placeholder="https://www.linkedin.com/company/gitroom" + /> + +
+
+
+ ); +}; +export const linkedinCompany = (identifier: string, id: string): ICommand[] => { + if (identifier !== 'linkedin' && identifier !== 'linkedin-page') { + return []; + } + return [ + { + name: 'linkedinCompany', + keyCommand: 'linkedinCompany', + shortcuts: 'ctrlcmd+p', + prefix: '', + suffix: '', + buttonProps: { + 'aria-label': 'Add Post Url', + title: 'Add Post Url', + }, + icon: ( + + + + ), + execute: async (state: ExecuteState, api: TextAreaTextApi) => { + const newSelectionRange = selectWord({ + text: state.text, + selection: state.selection, + prefix: state.command.prefix!, + suffix: state.command.suffix, + }); + const state1 = api.setSelectionRange(newSelectionRange); + const media = await showPostSelector(id); + executeCommand({ + api, + selectedText: state1.selectedText, + selection: state.selection, + prefix: media, + suffix: '', + }); + }, + }, + ]; +}; diff --git a/apps/frontend/src/components/new-launch/helpers/new.image.component.tsx b/apps/frontend/src/components/new-launch/helpers/new.image.component.tsx new file mode 100644 index 00000000..d539b430 --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/new.image.component.tsx @@ -0,0 +1,91 @@ +'use client'; + +import React from 'react'; +import { + executeCommand, + ExecuteState, + ICommand, + selectWord, + TextAreaTextApi, +} from '@uiw/react-md-editor'; +import { showMediaBox } from '@gitroom/frontend/components/media/media.component'; +import { loadVars } from '@gitroom/react/helpers/variable.context'; +export const newImage: ICommand = { + name: 'image', + keyCommand: 'image', + shortcuts: 'ctrlcmd+k', + prefix: '![image](', + suffix: ')', + buttonProps: { + 'aria-label': 'Add image (ctrl + k)', + title: 'Add image (ctrl + k)', + }, + icon: ( + + + + ), + execute: (state: ExecuteState, api: TextAreaTextApi) => { + const { uploadDirectory, backendUrl } = loadVars(); + let newSelectionRange = selectWord({ + text: state.text, + selection: state.selection, + prefix: state.command.prefix!, + suffix: state.command.suffix, + }); + let state1 = api.setSelectionRange(newSelectionRange); + if ( + state1.selectedText.includes('http') || + state1.selectedText.includes('www') || + state1.selectedText.includes('(post:') + ) { + executeCommand({ + api, + selectedText: state1.selectedText, + selection: state.selection, + prefix: state.command.prefix!, + suffix: state.command.suffix, + }); + return; + } + newSelectionRange = selectWord({ + text: state.text, + selection: state.selection, + prefix: '![', + suffix: ']()', + }); + state1 = api.setSelectionRange(newSelectionRange); + showMediaBox((media) => { + if (media) { + if (state1.selectedText.length > 0) { + executeCommand({ + api, + selectedText: state1.selectedText, + selection: state.selection, + prefix: '![', + suffix: `](${ + media.path.indexOf('http') === -1 + ? `${backendUrl}/${uploadDirectory}` + : `` + }${media.path})`, + }); + return; + } + executeCommand({ + api, + selectedText: state1.selectedText, + selection: state.selection, + prefix: '![image', + suffix: `](${ + media.path.indexOf('http') === -1 + ? `${backendUrl}/${uploadDirectory}` + : `` + }${media.path})`, + }); + } + }); + }, +}; diff --git a/apps/frontend/src/components/new-launch/helpers/top.title.component.tsx b/apps/frontend/src/components/new-launch/helpers/top.title.component.tsx new file mode 100644 index 00000000..ee45eab5 --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/top.title.component.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { FC, ReactNode } from 'react'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; + +export const TopTitle: FC<{ + title: string; + shouldExpend?: boolean; + removeTitle?: boolean; + expend?: () => void; + collapse?: () => void; + children?: ReactNode; +}> = (props) => { + const { title, removeTitle, children, shouldExpend, expend, collapse } = + props; + const t = useT(); + + // Translate the title using a key derived from the title itself + // This creates a consistent key pattern for each title + const translatedTitle = t( + // Convert to lowercase, replace spaces with underscores + `top_title_${title + .toLowerCase() + .replace(/\s+/g, '_') + .replace(/[^\w]/g, '')}`, + title + ); + + return ( +
+ {!removeTitle &&
{translatedTitle}
} + {children} + {shouldExpend !== undefined && ( +
+ {!shouldExpend ? ( + + + + ) : ( + + + + )} +
+ )} +
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/helpers/use.custom.provider.function.ts b/apps/frontend/src/components/new-launch/helpers/use.custom.provider.function.ts new file mode 100644 index 00000000..0f012ac5 --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/use.custom.provider.function.ts @@ -0,0 +1,29 @@ +'use client'; + +import { useIntegration } from '@gitroom/frontend/components/new-launch/helpers/use.integration'; +import { useCallback } from 'react'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +export const useCustomProviderFunction = () => { + const { integration } = useIntegration(); + const fetch = useFetch(); + const get = useCallback( + async (funcName: string, customData?: any) => { + const load = await fetch('/integrations/function', { + method: 'POST', + body: JSON.stringify({ + name: funcName, + id: integration?.id!, + data: customData, + }), + }); + if (load.status > 299 && load.status < 200) { + throw new Error('Failed to fetch'); + } + return load.json(); + }, + [integration] + ); + return { + get, + }; +}; diff --git a/apps/frontend/src/components/new-launch/helpers/use.existing.data.tsx b/apps/frontend/src/components/new-launch/helpers/use.existing.data.tsx new file mode 100644 index 00000000..c2a3eae5 --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/use.existing.data.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { createContext, FC, ReactNode, useContext } from 'react'; +import { Post } from '@prisma/client'; +const ExistingDataContext = createContext({ + integration: '', + group: undefined as undefined | string, + posts: [] as Post[], + settings: {} as any, +}); +export const ExistingDataContextProvider: FC<{ + children: ReactNode; + value: any; +}> = ({ children, value }) => { + return ( + + {children} + + ); +}; +export const useExistingData = () => useContext(ExistingDataContext); diff --git a/apps/frontend/src/components/new-launch/helpers/use.expend.tsx b/apps/frontend/src/components/new-launch/helpers/use.expend.tsx new file mode 100644 index 00000000..3b392b29 --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/use.expend.tsx @@ -0,0 +1,31 @@ +'use client'; + +import EventEmitter from 'events'; +import { useEffect, useState } from 'react'; +const emitter = new EventEmitter(); +export const useExpend = () => { + const [expend, setExpend] = useState(false); + useEffect(() => { + const hide = () => { + setExpend(false); + }; + const show = () => { + setExpend(true); + }; + emitter.on('hide', hide); + emitter.on('show', show); + return () => { + emitter.off('hide', hide); + emitter.off('show', show); + }; + }, []); + return { + expend, + hide: () => { + emitter.emit('hide'); + }, + show: () => { + emitter.emit('show'); + }, + }; +}; diff --git a/apps/frontend/src/components/new-launch/helpers/use.formatting.ts b/apps/frontend/src/components/new-launch/helpers/use.formatting.ts new file mode 100644 index 00000000..2f6b13d7 --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/use.formatting.ts @@ -0,0 +1,49 @@ +'use client'; + +import { useMemo } from 'react'; +export const useFormatting = ( + text: Array<{ + content: string; + image?: Array<{ + id: string; + path: string; + }>; + id?: string; + }>, + params: { + removeMarkdown?: boolean; + saveBreaklines?: boolean; + specialFunc?: (text: string) => any; + beforeSpecialFunc?: (text: string) => string; + } +) => { + return useMemo(() => { + return text.map((value) => { + let newText = value.content; + if (params.beforeSpecialFunc) { + newText = params.beforeSpecialFunc(newText); + } + if (params.saveBreaklines) { + newText = newText.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢'); + } + newText = newText.replace(/@\w{1,15}/g, function (match) { + return `${match}`; + }); + if (params.saveBreaklines) { + newText = newText.replace('𝔫𝔢𝔴𝔩𝔦𝔫𝔢', '\n'); + } + if (params.specialFunc) { + newText = params.specialFunc(newText); + } + return { + id: value.id, + text: newText, + images: value.image, + count: + params.removeMarkdown && params.saveBreaklines + ? newText.replace(/\n/g, ' ').length + : newText.length, + }; + }); + }, [text]); +}; diff --git a/apps/frontend/src/components/new-launch/helpers/use.hide.top.editor.tsx b/apps/frontend/src/components/new-launch/helpers/use.hide.top.editor.tsx new file mode 100644 index 00000000..5ba28014 --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/use.hide.top.editor.tsx @@ -0,0 +1,31 @@ +'use client'; + +import EventEmitter from 'events'; +import { useEffect, useState } from 'react'; +const emitter = new EventEmitter(); +export const useHideTopEditor = () => { + const [hideTopEditor, setHideTopEditor] = useState(false); + useEffect(() => { + const hide = () => { + setHideTopEditor(true); + }; + const show = () => { + setHideTopEditor(false); + }; + emitter.on('hide', hide); + emitter.on('show', show); + return () => { + emitter.off('hide', hide); + emitter.off('show', show); + }; + }, []); + return { + hideTopEditor, + hide: () => { + emitter.emit('hide'); + }, + show: () => { + emitter.emit('show'); + }, + }; +}; diff --git a/apps/frontend/src/components/new-launch/helpers/use.integration.tsx b/apps/frontend/src/components/new-launch/helpers/use.integration.tsx new file mode 100644 index 00000000..edbc22df --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/use.integration.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import dayjs from 'dayjs'; +import { Integrations } from '@gitroom/frontend/components/launches/calendar.context'; + +const IntegrationContext = createContext<{ + date: dayjs.Dayjs; + integration: Integrations | undefined; + allIntegrations: Integrations[]; + value: Array<{ + content: string; + id?: string; + image?: Array<{ + path: string; + id: string; + }>; + }>; +}>({ + integration: undefined, + value: [], + date: dayjs(), + allIntegrations: [], +}); + +const useIntegration = () => useContext(IntegrationContext); +export {IntegrationContext, useIntegration}; \ No newline at end of file diff --git a/apps/frontend/src/components/new-launch/helpers/use.move.to.integration.tsx b/apps/frontend/src/components/new-launch/helpers/use.move.to.integration.tsx new file mode 100644 index 00000000..3aa00952 --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/use.move.to.integration.tsx @@ -0,0 +1,47 @@ +'use client'; + +import EventEmitter from 'events'; +import { useCallback, useEffect } from 'react'; +const emitter = new EventEmitter(); +export const useMoveToIntegration = () => { + return useCallback( + ({ + identifier, + toPreview, + }: { + identifier: string; + toPreview?: boolean; + }) => { + emitter.emit('moveToIntegration', { + identifier, + toPreview, + }); + }, + [] + ); +}; +export const useMoveToIntegrationListener = ( + useEffectParams: any[], + enabled: boolean, + callback: ({ + identifier, + toPreview, + }: { + identifier: string; + toPreview: boolean; + }) => void +) => { + useEffect(() => { + if (!enabled) { + return; + } + return load(); + }, useEffectParams); + const load = useCallback(() => { + emitter.off('moveToIntegration', callback); + emitter.on('moveToIntegration', callback); + return () => { + emitter.off('moveToIntegration', callback); + }; + }, useEffectParams); +}; diff --git a/apps/frontend/src/components/new-launch/helpers/use.values.ts b/apps/frontend/src/components/new-launch/helpers/use.values.ts new file mode 100644 index 00000000..209c160b --- /dev/null +++ b/apps/frontend/src/components/new-launch/helpers/use.values.ts @@ -0,0 +1,96 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { useForm, useFormContext } from 'react-hook-form'; +import { classValidatorResolver } from '@hookform/resolvers/class-validator'; +import { IsOptional } from 'class-validator'; + +class Empty { + @IsOptional() + empty: string; +} + +const finalInformation = {} as { + [key: string]: { + posts: Array<{ + id?: string; + content: string; + media?: Array; + }>; + settings: () => object; + trigger: () => Promise; + isValid: boolean; + checkValidity?: ( + value: Array< + Array<{ + path: string; + }> + >, + settings: any, + additionalSettings: any, + ) => Promise; + maximumCharacters?: number; + }; +}; +export const useValues = ( + initialValues: object, + integration: string, + identifier: string, + value: Array<{ + id?: string; + content: string; + media?: Array; + }>, + dto: any, + checkValidity?: ( + value: Array< + Array<{ + path: string; + }> + >, + settings: any, + additionalSettings: any, + ) => Promise, + maximumCharacters?: number +) => { + + const form = useForm({ + resolver: classValidatorResolver(dto || Empty), + values: initialValues, + mode: 'onChange', + criteriaMode: 'all', + }); + + const getValues = useMemo(() => { + return () => ({ + ...form.getValues(), + __type: identifier, + }); + }, [form, integration]); + + // @ts-ignore + finalInformation[integration] = finalInformation[integration] || {}; + finalInformation[integration].posts = value; + finalInformation[integration].isValid = form.formState.isValid; + finalInformation[integration].settings = getValues; + finalInformation[integration].trigger = form.trigger; + if (checkValidity) { + finalInformation[integration].checkValidity = checkValidity; + } + if (maximumCharacters) { + finalInformation[integration].maximumCharacters = maximumCharacters; + } + useEffect(() => { + return () => { + delete finalInformation[integration]; + }; + }, []); + return form; +}; +export const useSettings = () => useFormContext(); +export const getValues = () => finalInformation; +export const resetValues = () => { + Object.keys(finalInformation).forEach((key) => { + delete finalInformation[key]; + }); +}; diff --git a/apps/frontend/src/components/new-launch/picks.socials.component.tsx b/apps/frontend/src/components/new-launch/picks.socials.component.tsx new file mode 100644 index 00000000..eb17c34d --- /dev/null +++ b/apps/frontend/src/components/new-launch/picks.socials.component.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { FC } from 'react'; +import clsx from 'clsx'; +import Image from 'next/image'; +import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; +import { useShallow } from 'zustand/react/shallow'; + +export const PicksSocialsComponent: FC<{ toolTip?: boolean }> = ({ + toolTip, +}) => { + const { addOrRemoveSelectedIntegration, integrations, selectedIntegrations } = + useLaunchStore( + useShallow((state) => ({ + integrations: state.integrations, + selectedIntegrations: state.selectedIntegrations, + addOrRemoveSelectedIntegration: state.addOrRemoveSelectedIntegration, + })) + ); + return ( +
+
+
+
+ {integrations + .filter((f) => !f.inBetweenSteps) + .map((integration) => ( +
+
+ addOrRemoveSelectedIntegration(integration, {}) + } + className={clsx( + 'cursor-pointer relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth filter transition-all duration-500', + selectedIntegrations.findIndex( + (p) => p.integration.id === integration.id + ) === -1 + ? 'opacity-40' + : '' + )} + > + {integration.identifier} + {integration.identifier === 'youtube' ? ( + + ) : ( + {integration.identifier} + )} +
+
+ ))} +
+
+
+
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/bluesky/bluesky.provider.tsx b/apps/frontend/src/components/new-launch/providers/bluesky/bluesky.provider.tsx new file mode 100644 index 00000000..6cf97f1d --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/bluesky/bluesky.provider.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { ThreadFinisher } from '@gitroom/frontend/components/new-launch/finisher/thread.finisher'; +import { useFormContext } from 'react-hook-form'; + +const SettingsComponent = () => { + const form = useFormContext(); + return ; +}; + +export default withProvider( + SettingsComponent, + undefined, + undefined, + async (posts) => { + if ( + posts.some( + (p) => p.some((a) => a.path.indexOf('mp4') > -1) && p.length > 1 + ) + ) { + return 'You can only upload one video to Bluesky per post.'; + } + + if (posts.some((p) => p.length > 4)) { + return 'There can be maximum 4 pictures in a post.'; + } + return true; + }, + 300 +); diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/facebook/facebook.continue.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/facebook/facebook.continue.tsx new file mode 100644 index 00000000..a7738e73 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/continue-provider/facebook/facebook.continue.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { FC, useCallback, useMemo, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import useSWR from 'swr'; +import clsx from 'clsx'; +import { Button } from '@gitroom/react/form/button'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { useIntegration } from '@gitroom/frontend/components/new-launch/helpers/use.integration'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +export const FacebookContinue: FC<{ + closeModal: () => void; + existingId: string[]; +}> = (props) => { + const { closeModal, existingId } = props; + const call = useCustomProviderFunction(); + const { integration } = useIntegration(); + const [page, setSelectedPage] = useState(null); + const fetch = useFetch(); + const loadPages = useCallback(async () => { + try { + const pages = await call.get('pages'); + return pages; + } catch (e) { + closeModal(); + } + }, []); + const setPage = useCallback( + (id: string) => () => { + setSelectedPage(id); + }, + [] + ); + const { data, isLoading } = useSWR('load-pages', loadPages, { + refreshWhenHidden: false, + refreshWhenOffline: false, + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnMount: true, + revalidateOnReconnect: false, + refreshInterval: 0, + }); + const t = useT(); + + const saveInstagram = useCallback(async () => { + await fetch(`/integrations/facebook/${integration?.id}`, { + method: 'POST', + body: JSON.stringify({ + page, + }), + }); + closeModal(); + }, [integration, page]); + const filteredData = useMemo(() => { + return ( + data?.filter((p: { id: string }) => !existingId.includes(p.id)) || [] + ); + }, [data]); + if (!isLoading && !data?.length) { + return ( +
+ {t( + 'we_couldn_t_find_any_business_connected_to_the_selected_pages', + "We couldn't find any business connected to the selected pages." + )} +
+ {t( + 'we_recommend_you_to_connect_all_the_pages_and_all_the_businesses', + 'We recommend you to connect all the pages and all the businesses.' + )} +
+ {t( + 'please_close_this_dialog_delete_your_integration_and_add_a_new_channel_again', + 'Please close this dialog, delete your integration and add a new channel\n again.' + )} +
+ ); + } + return ( +
+
{t('select_page', 'Select Page:')}
+
+ {filteredData?.map( + (p: { + id: string; + username: string; + name: string; + picture: { + data: { + url: string; + }; + }; + }) => ( +
+
+ profile +
+
{p.name}
+
+ ) + )} +
+
+ +
+
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/instagram/instagram.continue.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/instagram/instagram.continue.tsx new file mode 100644 index 00000000..2ce5def6 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/continue-provider/instagram/instagram.continue.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { FC, useCallback, useMemo, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import useSWR from 'swr'; +import clsx from 'clsx'; +import { Button } from '@gitroom/react/form/button'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { useIntegration } from '@gitroom/frontend/components/new-launch/helpers/use.integration'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +export const InstagramContinue: FC<{ + closeModal: () => void; + existingId: string[]; +}> = (props) => { + const { closeModal, existingId } = props; + const call = useCustomProviderFunction(); + const { integration } = useIntegration(); + const [page, setSelectedPage] = useState(null); + const fetch = useFetch(); + const loadPages = useCallback(async () => { + try { + const pages = await call.get('pages'); + return pages; + } catch (e) { + closeModal(); + } + }, []); + const t = useT(); + + const setPage = useCallback( + (param: { id: string; pageId: string }) => () => { + setSelectedPage(param); + }, + [] + ); + const { data, isLoading } = useSWR('load-pages', loadPages, { + refreshWhenHidden: false, + refreshWhenOffline: false, + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnMount: true, + revalidateOnReconnect: false, + refreshInterval: 0, + }); + const saveInstagram = useCallback(async () => { + await fetch(`/integrations/instagram/${integration?.id}`, { + method: 'POST', + body: JSON.stringify(page), + }); + closeModal(); + }, [integration, page]); + const filteredData = useMemo(() => { + return ( + data?.filter((p: { id: string }) => !existingId.includes(p.id)) || [] + ); + }, [data]); + if (!isLoading && !data?.length) { + return ( +
+ {t( + 'we_couldn_t_find_any_business_connected_to_the_selected_pages', + "We couldn't find any business connected to the selected pages." + )} +
+ {t( + 'we_recommend_you_to_connect_all_the_pages_and_all_the_businesses', + 'We recommend you to connect all the pages and all the businesses.' + )} +
+ {t( + 'please_close_this_dialog_delete_your_integration_and_add_a_new_channel_again', + 'Please close this dialog, delete your integration and add a new channel\n again.' + )} +
+ ); + } + return ( +
+
{t('select_instagram_account', 'Select Instagram Account:')}
+
+ {filteredData?.map( + (p: { + id: string; + pageId: string; + username: string; + name: string; + picture: { + data: { + url: string; + }; + }; + }) => ( +
+
+ profile +
+
{p.name}
+
+ ) + )} +
+
+ +
+
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/linkedin/linkedin.continue.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/linkedin/linkedin.continue.tsx new file mode 100644 index 00000000..f1bb48a0 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/continue-provider/linkedin/linkedin.continue.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { FC, useCallback, useMemo, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import useSWR from 'swr'; +import clsx from 'clsx'; +import { Button } from '@gitroom/react/form/button'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { useIntegration } from '@gitroom/frontend/components/new-launch/helpers/use.integration'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +export const LinkedinContinue: FC<{ + closeModal: () => void; + existingId: string[]; +}> = (props) => { + const { closeModal, existingId } = props; + const t = useT(); + + const call = useCustomProviderFunction(); + const { integration } = useIntegration(); + const [page, setSelectedPage] = useState(null); + const fetch = useFetch(); + const loadPages = useCallback(async () => { + try { + const pages = await call.get('companies'); + return pages; + } catch (e) { + closeModal(); + } + }, []); + const setPage = useCallback( + (param: { id: string; pageId: string }) => () => { + setSelectedPage(param); + }, + [] + ); + const { data, isLoading } = useSWR('load-pages', loadPages, { + refreshWhenHidden: false, + refreshWhenOffline: false, + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnMount: true, + revalidateOnReconnect: false, + refreshInterval: 0, + }); + const saveLinkedin = useCallback(async () => { + await fetch(`/integrations/linkedin-page/${integration?.id}`, { + method: 'POST', + body: JSON.stringify(page), + }); + closeModal(); + }, [integration, page]); + const filteredData = useMemo(() => { + return ( + data?.filter((p: { id: string }) => !existingId.includes(p.id)) || [] + ); + }, [data]); + if (!isLoading && !data?.length) { + return ( +
+ {t( + 'we_couldn_t_find_any_business_connected_to_your_linkedin_page', + "We couldn't find any business connected to your LinkedIn Page." + )} +
+ {t( + 'please_close_this_dialog_create_a_new_page_and_add_a_new_channel_again', + 'Please close this dialog, create a new page, and add a new channel again.' + )} +
+ ); + } + return ( +
+
{t('select_linkedin_page', 'Select Linkedin Page:')}
+
+ {filteredData?.map( + (p: { + id: string; + pageId: string; + username: string; + name: string; + picture: string; + }) => ( +
+
+ profile +
+
{p.name}
+
+ ) + )} +
+
+ +
+
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/list.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/list.tsx new file mode 100644 index 00000000..03b182e2 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/continue-provider/list.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { InstagramContinue } from '@gitroom/frontend/components/new-launch/providers/continue-provider/instagram/instagram.continue'; +import { FacebookContinue } from '@gitroom/frontend/components/new-launch/providers/continue-provider/facebook/facebook.continue'; +import { LinkedinContinue } from '@gitroom/frontend/components/new-launch/providers/continue-provider/linkedin/linkedin.continue'; +export const continueProviderList = { + instagram: InstagramContinue, + facebook: FacebookContinue, + 'linkedin-page': LinkedinContinue, +}; diff --git a/apps/frontend/src/components/new-launch/providers/devto/devto.provider.tsx b/apps/frontend/src/components/new-launch/providers/devto/devto.provider.tsx new file mode 100644 index 00000000..886d612b --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/devto/devto.provider.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { FC } from 'react'; +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { Input } from '@gitroom/react/form/input'; +import { MediaComponent } from '@gitroom/frontend/components/media/media.component'; +import { SelectOrganization } from '@gitroom/frontend/components/new-launch/providers/devto/select.organization'; +import { DevtoTags } from '@gitroom/frontend/components/new-launch/providers/devto/devto.tags'; +import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory'; +import clsx from 'clsx'; +import localFont from 'next/font/local'; +import MDEditor from '@uiw/react-md-editor'; +import { useIntegration } from '@gitroom/frontend/components/new-launch/helpers/use.integration'; +import { Canonical } from '@gitroom/react/form/canonical'; +const font = localFont({ + src: [ + { + path: './fonts/SFNS.woff2', + }, + ], +}); +const DevtoPreview: FC = () => { + const { value } = useIntegration(); + const settings = useSettings(); + const image = useMediaDirectory(); + const [coverPicture, title, tags] = settings.watch([ + 'main_image', + 'title', + 'tags', + ]); + return ( +
+ {!!coverPicture?.path && ( +
+ cover_picture +
+ )} +
+
{title}
+
+ {tags?.map((p: any) => ( +
#{p.label}
+ ))} +
+
+
+ p.content).join('\n')} + /> +
+
+ ); +}; +const DevtoSettings: FC = () => { + const form = useSettings(); + const { date } = useIntegration(); + return ( + <> + + + +
+ +
+
+ +
+ + ); +}; +export default withProvider(DevtoSettings, DevtoPreview, DevToSettingsDto); diff --git a/apps/frontend/src/components/new-launch/providers/devto/devto.tags.tsx b/apps/frontend/src/components/new-launch/providers/devto/devto.tags.tsx new file mode 100644 index 00000000..f61a875a --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/devto/devto.tags.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { FC, useCallback, useEffect, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { ReactTags } from 'react-tag-autocomplete'; +import interClass from '@gitroom/react/helpers/inter.font'; +export const DevtoTags: FC<{ + name: string; + label: string; + onChange: (event: { + target: { + value: any[]; + name: string; + }; + }) => void; +}> = (props) => { + const { onChange, name, label } = props; + const customFunc = useCustomProviderFunction(); + const [tags, setTags] = useState([]); + const { getValues } = useSettings(); + const [tagValue, setTagValue] = useState([]); + const onDelete = useCallback( + (tagIndex: number) => { + const modify = tagValue.filter((_, i) => i !== tagIndex); + setTagValue(modify); + onChange({ + target: { + value: modify, + name, + }, + }); + }, + [tagValue] + ); + const onAddition = useCallback( + (newTag: any) => { + if (tagValue.length >= 4) { + return; + } + const modify = [...tagValue, newTag]; + setTagValue(modify); + onChange({ + target: { + value: modify, + name, + }, + }); + }, + [tagValue] + ); + useEffect(() => { + customFunc.get('tags').then((data) => setTags(data)); + const settings = getValues()[props.name]; + if (settings) { + setTagValue(settings); + } + }, []); + if (!tags.length) { + return null; + } + return ( +
+
{label}
+ +
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/devto/fonts/SFNS.woff2 b/apps/frontend/src/components/new-launch/providers/devto/fonts/SFNS.woff2 new file mode 100644 index 00000000..e69de29b diff --git a/apps/frontend/src/components/new-launch/providers/devto/select.organization.tsx b/apps/frontend/src/components/new-launch/providers/devto/select.organization.tsx new file mode 100644 index 00000000..8451fa23 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/devto/select.organization.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { FC, useEffect, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import { Select } from '@gitroom/react/form/select'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +export const SelectOrganization: FC<{ + name: string; + onChange: (event: { + target: { + value: string; + name: string; + }; + }) => void; +}> = (props) => { + const { onChange, name } = props; + const t = useT(); + const customFunc = useCustomProviderFunction(); + const [orgs, setOrgs] = useState([]); + const { getValues } = useSettings(); + const [currentMedia, setCurrentMedia] = useState(); + const onChangeInner = (event: { + target: { + value: string; + name: string; + }; + }) => { + setCurrentMedia(event.target.value); + onChange(event); + }; + useEffect(() => { + customFunc.get('organizations').then((data) => setOrgs(data)); + const settings = getValues()[props.name]; + if (settings) { + setCurrentMedia(settings); + } + }, []); + if (!orgs.length) { + return null; + } + return ( + + ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/discord/discord.channel.select.tsx b/apps/frontend/src/components/new-launch/providers/discord/discord.channel.select.tsx new file mode 100644 index 00000000..ce282790 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/discord/discord.channel.select.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { FC, useEffect, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import { Select } from '@gitroom/react/form/select'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +export const DiscordChannelSelect: FC<{ + name: string; + onChange: (event: { + target: { + value: string; + name: string; + }; + }) => void; +}> = (props) => { + const { onChange, name } = props; + const t = useT(); + const customFunc = useCustomProviderFunction(); + const [publications, setOrgs] = useState([]); + const { getValues } = useSettings(); + const [currentMedia, setCurrentMedia] = useState(); + const onChangeInner = (event: { + target: { + value: string; + name: string; + }; + }) => { + setCurrentMedia(event.target.value); + onChange(event); + }; + useEffect(() => { + customFunc.get('channels').then((data) => setOrgs(data)); + const settings = getValues()[props.name]; + if (settings) { + setCurrentMedia(settings); + } + }, []); + if (!publications.length) { + return null; + } + return ( + + ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/discord/discord.provider.tsx b/apps/frontend/src/components/new-launch/providers/discord/discord.provider.tsx new file mode 100644 index 00000000..1fdf9f60 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/discord/discord.provider.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { FC } from 'react'; +import { DiscordDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/discord.dto'; +import { DiscordChannelSelect } from '@gitroom/frontend/components/new-launch/providers/discord/discord.channel.select'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +const DiscordComponent: FC = () => { + const form = useSettings(); + return ( +
+ +
+ ); +}; +export default withProvider( + DiscordComponent, + undefined, + DiscordDto, + undefined, + 1980 +); diff --git a/apps/frontend/src/components/new-launch/providers/dribbble/dribbble.provider.tsx b/apps/frontend/src/components/new-launch/providers/dribbble/dribbble.provider.tsx new file mode 100644 index 00000000..a2dd6024 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/dribbble/dribbble.provider.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { FC } from 'react'; +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { Input } from '@gitroom/react/form/input'; +import { DribbbleTeams } from '@gitroom/frontend/components/new-launch/providers/dribbble/dribbble.teams'; +import { DribbbleDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dribbble.dto'; +const DribbbleSettings: FC = () => { + const { register, control } = useSettings(); + return ( +
+ + +
+ ); +}; +export default withProvider( + DribbbleSettings, + undefined, + DribbbleDto, + async ([firstItem, ...otherItems]) => { + const isMp4 = firstItem?.find((item) => item.path.indexOf('mp4') > -1); + if (firstItem.length !== 1) { + return 'Dribbble requires one item'; + } + if (isMp4) { + return 'Dribbble does not support mp4 files'; + } + const details = await new Promise<{ + width: number; + height: number; + }>((resolve, reject) => { + const url = new Image(); + url.onload = function () { + // @ts-ignore + resolve({ width: this.width, height: this.height }); + }; + url.src = firstItem[0].path; + }); + if ( + (details?.width === 400 && details?.height === 300) || + (details?.width === 800 && details?.height === 600) + ) { + return true; + } + return 'Invalid image size. Dribbble requires 400x300 or 800x600 px images.'; + } +); diff --git a/apps/frontend/src/components/new-launch/providers/dribbble/dribbble.teams.tsx b/apps/frontend/src/components/new-launch/providers/dribbble/dribbble.teams.tsx new file mode 100644 index 00000000..d6457372 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/dribbble/dribbble.teams.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { FC, useEffect, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import { Select } from '@gitroom/react/form/select'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +export const DribbbleTeams: FC<{ + name: string; + onChange: (event: { + target: { + value: string; + name: string; + }; + }) => void; +}> = (props) => { + const { onChange, name } = props; + const t = useT(); + const customFunc = useCustomProviderFunction(); + const [orgs, setOrgs] = useState(); + const { getValues } = useSettings(); + const [currentMedia, setCurrentMedia] = useState(); + const onChangeInner = (event: { + target: { + value: string; + name: string; + }; + }) => { + setCurrentMedia(event.target.value); + onChange(event); + }; + useEffect(() => { + customFunc.get('teams').then((data) => setOrgs(data)); + const settings = getValues()[props.name]; + if (settings) { + setCurrentMedia(settings); + } + }, []); + if (!orgs) { + return null; + } + if (!orgs.length) { + return <>; + } + return ( + + ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/facebook/facebook.provider.tsx b/apps/frontend/src/components/new-launch/providers/facebook/facebook.provider.tsx new file mode 100644 index 00000000..26d3cd8b --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/facebook/facebook.provider.tsx @@ -0,0 +1,4 @@ +'use client'; + +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +export default withProvider(null, undefined, undefined, undefined, 63206); diff --git a/apps/frontend/src/components/new-launch/providers/hashnode/hashnode.provider.tsx b/apps/frontend/src/components/new-launch/providers/hashnode/hashnode.provider.tsx new file mode 100644 index 00000000..c1614075 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/hashnode/hashnode.provider.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { FC } from 'react'; +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { Input } from '@gitroom/react/form/input'; +import { HashnodePublications } from '@gitroom/frontend/components/new-launch/providers/hashnode/hashnode.publications'; +import { HashnodeTags } from '@gitroom/frontend/components/new-launch/providers/hashnode/hashnode.tags'; +import { HashnodeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/hashnode.settings.dto'; +import { useIntegration } from '@gitroom/frontend/components/new-launch/helpers/use.integration'; +import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory'; +import clsx from 'clsx'; +import MDEditor from '@uiw/react-md-editor'; +import { Plus_Jakarta_Sans } from 'next/font/google'; +import { MediaComponent } from '@gitroom/frontend/components/media/media.component'; +import { Canonical } from '@gitroom/react/form/canonical'; +const font = Plus_Jakarta_Sans({ + subsets: ['latin'], +}); +const HashnodePreview: FC = () => { + const { value } = useIntegration(); + const settings = useSettings(); + const image = useMediaDirectory(); + const [coverPicture, title, subtitle] = settings.watch([ + 'main_image', + 'title', + 'subtitle', + ]); + return ( +
+ {!!coverPicture?.path && ( +
+ cover_picture +
+ )} +
+
+ {title} +
+
+ {subtitle} +
+
+
+ p.content).join('\n')} + /> +
+
+ ); +}; +const HashnodeSettings: FC = () => { + const form = useSettings(); + const { date } = useIntegration(); + return ( + <> + + + + +
+ +
+
+ +
+ + ); +}; +export default withProvider( + HashnodeSettings, + HashnodePreview, + HashnodeSettingsDto +); diff --git a/apps/frontend/src/components/new-launch/providers/hashnode/hashnode.publications.tsx b/apps/frontend/src/components/new-launch/providers/hashnode/hashnode.publications.tsx new file mode 100644 index 00000000..9e6b8139 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/hashnode/hashnode.publications.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { FC, useEffect, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import { Select } from '@gitroom/react/form/select'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +export const HashnodePublications: FC<{ + name: string; + onChange: (event: { + target: { + value: string; + name: string; + }; + }) => void; +}> = (props) => { + const { onChange, name } = props; + const t = useT(); + const customFunc = useCustomProviderFunction(); + const [publications, setOrgs] = useState([]); + const { getValues } = useSettings(); + const [currentMedia, setCurrentMedia] = useState(); + const onChangeInner = (event: { + target: { + value: string; + name: string; + }; + }) => { + setCurrentMedia(event.target.value); + onChange(event); + }; + useEffect(() => { + customFunc.get('publications').then((data) => setOrgs(data)); + const settings = getValues()[props.name]; + if (settings) { + setCurrentMedia(settings); + } + }, []); + if (!publications.length) { + return null; + } + return ( + + ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/hashnode/hashnode.tags.tsx b/apps/frontend/src/components/new-launch/providers/hashnode/hashnode.tags.tsx new file mode 100644 index 00000000..2bcebcce --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/hashnode/hashnode.tags.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { ReactTags } from 'react-tag-autocomplete'; +import interClass from '@gitroom/react/helpers/inter.font'; +export const HashnodeTags: FC<{ + name: string; + label: string; + onChange: (event: { + target: { + value: any[]; + name: string; + }; + }) => void; +}> = (props) => { + const { onChange, name, label } = props; + const customFunc = useCustomProviderFunction(); + const [tags, setTags] = useState([]); + const { getValues, formState: form } = useSettings(); + const [tagValue, setTagValue] = useState([]); + const onDelete = useCallback( + (tagIndex: number) => { + const modify = tagValue.filter((_, i) => i !== tagIndex); + setTagValue(modify); + onChange({ + target: { + value: modify, + name, + }, + }); + }, + [tagValue] + ); + const onAddition = useCallback( + (newTag: any) => { + if (tagValue.length >= 4) { + return; + } + const modify = [...tagValue, newTag]; + setTagValue(modify); + onChange({ + target: { + value: modify, + name, + }, + }); + }, + [tagValue] + ); + useEffect(() => { + customFunc.get('tags').then((data) => setTags(data)); + const settings = getValues()[props.name]; + if (settings) { + setTagValue(settings); + } + }, []); + const err = useMemo(() => { + if (!form || !form.errors[props?.name!]) return; + return form?.errors?.[props?.name!]?.message! as string; + }, [form?.errors?.[props?.name!]?.message]); + if (!tags.length) { + return null; + } + return ( +
+
{label}
+ +
{err || <> }
+
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx new file mode 100644 index 00000000..83188e33 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx @@ -0,0 +1,113 @@ +'use client'; + +import React, { FC, forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { IsOptional } from 'class-validator'; +import { classValidatorResolver } from '@hookform/resolvers/class-validator'; +import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; +import { IntegrationContext } from '@gitroom/frontend/components/new-launch/helpers/use.integration'; +import { useShallow } from 'zustand/react/shallow'; +import { timer } from '@gitroom/helpers/utils/timer'; + +class Empty { + @IsOptional() + empty: string; +} + +export const TriggerComponent: FC<{form: any}> = ({ form }) => { + useEffect(() => { + form.trigger(); + }, []); + + return null; +} + +export const withProvider = function ( + SettingsComponent: FC<{ + values?: any; + }> | null, + CustomPreviewComponent?: FC<{ + maximumCharacters?: number; + }>, + dto?: any, + checkValidity?: ( + value: Array< + Array<{ + path: string; + }> + >, + settings: T, + additionalSettings: any + ) => Promise, + maximumCharacters?: number | ((settings: any) => number) +) { + return forwardRef((props: { id: string }, ref) => { + const { + current, + integrations, + selectedIntegration, + internal, + global, + date, + } = useLaunchStore( + useShallow((state) => ({ + date: state.date, + global: state.global, + internal: state.internal.find((p) => p.integration.id === props.id), + integrations: state.selectedIntegrations, + current: state.current === props.id, + selectedIntegration: state.selectedIntegrations.find( + (p) => p.integration.id === props.id + ), + })) + ); + + const value = useMemo(() => { + if (internal) { + return internal.integrationValue; + } + + return global; + }, []); + + const form = useForm({ + resolver: classValidatorResolver(dto || Empty), + values: {...selectedIntegration.settings}, + mode: 'all', + criteriaMode: 'all', + }); + + useImperativeHandle(ref, () => ({ + isValid: async () => { + return { + id: props.id, + identifier: selectedIntegration.integration.identifier, + valid: form.formState.isValid, + values: form.getValues(), + errors: form.formState.errors, + }; + }, + }), [form, props.id, selectedIntegration]); + + return ( + p.integration), + value: value, + }} + > + +
+ {SettingsComponent && ( +
+ +
+ )} +
+
+
+ ); + }); +}; diff --git a/apps/frontend/src/components/new-launch/providers/instagram/instagram.collaborators.tsx b/apps/frontend/src/components/new-launch/providers/instagram/instagram.collaborators.tsx new file mode 100644 index 00000000..d36abc8d --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/instagram/instagram.collaborators.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { FC } from 'react'; +import { Select } from '@gitroom/react/form/select'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { InstagramDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/instagram.dto'; +import { InstagramCollaboratorsTags } from '@gitroom/frontend/components/new-launch/providers/instagram/instagram.tags'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +const postType = [ + { + value: 'post', + label: 'Post / Reel', + }, + { + value: 'story', + label: 'Story', + }, +]; +const InstagramCollaborators: FC<{ + values?: any; +}> = (props) => { + const t = useT(); + const { watch, register, formState, control } = useSettings(); + const postCurrentType = watch('post_type'); + return ( + <> + + + {postCurrentType !== 'story' && ( + + )} + + ); +}; +export default withProvider( + InstagramCollaborators, + undefined, + InstagramDto, + async ([firstPost, ...otherPosts], settings) => { + if (!firstPost.length) { + return 'Instagram should have at least one media'; + } + if (firstPost.length > 1 && settings.post_type === 'story') { + return 'Instagram stories can only have one media'; + } + const checkVideosLength = await Promise.all( + firstPost + .filter((f) => f.path.indexOf('mp4') > -1) + .flatMap((p) => p.path) + .map((p) => { + return new Promise((res) => { + const video = document.createElement('video'); + video.preload = 'metadata'; + video.src = p; + video.addEventListener('loadedmetadata', () => { + res(video.duration); + }); + }); + }) + ); + for (const video of checkVideosLength) { + if (video > 60 && settings.post_type === 'story') { + return 'Instagram stories should be maximum 60 seconds'; + } + if (video > 180 && settings.post_type === 'post') { + return 'Instagram reel should be maximum 180 seconds'; + } + } + return true; + }, + 2200 +); diff --git a/apps/frontend/src/components/new-launch/providers/instagram/instagram.tags.tsx b/apps/frontend/src/components/new-launch/providers/instagram/instagram.tags.tsx new file mode 100644 index 00000000..e9d65f1a --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/instagram/instagram.tags.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { ReactTags } from 'react-tag-autocomplete'; +import interClass from '@gitroom/react/helpers/inter.font'; +import { useIntegration } from '@gitroom/frontend/components/new-launch/helpers/use.integration'; +import clsx from 'clsx'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; + +export const InstagramCollaboratorsTags: FC<{ + name: string; + label: string; + onChange: (event: { + target: { + value: any[]; + name: string; + }; + }) => void; +}> = (props) => { + const { onChange, name, label } = props; + const { getValues } = useSettings(); + const { integration } = useIntegration(); + const [tagValue, setTagValue] = useState([]); + const [suggestions, setSuggestions] = useState(''); + const t = useT(); + + const onDelete = useCallback( + (tagIndex: number) => { + const modify = tagValue.filter((_, i) => i !== tagIndex); + setTagValue(modify); + onChange({ + target: { + value: modify, + name, + }, + }); + }, + [tagValue] + ); + const onAddition = useCallback( + (newTag: any) => { + if (tagValue.length >= 3) { + return; + } + const modify = [...tagValue, newTag]; + setTagValue(modify); + onChange({ + target: { + value: modify, + name, + }, + }); + }, + [tagValue] + ); + useEffect(() => { + const settings = getValues()[props.name]; + if (settings) { + setTagValue(settings); + } + }, []); + const suggestionsArray = useMemo(() => { + return [ + ...tagValue, + { + label: suggestions, + value: suggestions, + }, + ].filter((f) => f.label); + }, [suggestions, tagValue]); + return ( +
+
+
+ {label} +
+ +
+
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/lemmy/lemmy.provider.tsx b/apps/frontend/src/components/new-launch/providers/lemmy/lemmy.provider.tsx new file mode 100644 index 00000000..172e2f24 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/lemmy/lemmy.provider.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { FC, useCallback } from 'react'; +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { useFieldArray } from 'react-hook-form'; +import { Button } from '@gitroom/react/form/button'; +import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; +import { Subreddit } from './subreddit'; +import { LemmySettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/lemmy.dto'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +const LemmySettings: FC = () => { + const { register, control } = useSettings(); + const { fields, append, remove } = useFieldArray({ + control, + // control props comes from useForm (optional: if you are using FormContext) + name: 'subreddit', // unique name for your Field Array + }); + const t = useT(); + + const addField = useCallback(() => { + append({}); + }, [fields, append]); + const deleteField = useCallback( + (index: number) => async () => { + if ( + !(await deleteDialog( + t( + 'are_you_sure_you_want_to_delete_this_subreddit', + 'Are you sure you want to delete this Subreddit?' + ) + )) + ) + return; + remove(index); + }, + [fields, remove] + ); + return ( + <> +
+ {fields.map((field, index) => ( +
+
+ x +
+ +
+ ))} +
+ + {fields.length === 0 && ( +
+ {t( + 'please_add_at_least_one_subreddit', + 'Please add at least one Subreddit' + )} +
+ )} + + ); +}; +export default withProvider( + LemmySettings, + undefined, + LemmySettingsDto, + async (items) => { + const [firstItems] = items; + if ( + firstItems.length && + firstItems[0].path.indexOf('png') === -1 && + firstItems[0].path.indexOf('jpg') === -1 && + firstItems[0].path.indexOf('jpef') === -1 && + firstItems[0].path.indexOf('gif') === -1 + ) { + return 'You can set only one picture for a cover'; + } + return true; + }, + 10000 +); diff --git a/apps/frontend/src/components/new-launch/providers/lemmy/subreddit.tsx b/apps/frontend/src/components/new-launch/providers/lemmy/subreddit.tsx new file mode 100644 index 00000000..e4b3a473 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/lemmy/subreddit.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { FC, FormEvent, useCallback, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import { Input } from '@gitroom/react/form/input'; +import { useDebouncedCallback } from 'use-debounce'; +import { useWatch } from 'react-hook-form'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +export const Subreddit: FC<{ + onChange: (event: { + target: { + name: string; + value: { + id: string; + subreddit: string; + title: string; + name: string; + url: string; + body: string; + media: any[]; + }; + }; + }) => void; + name: string; +}> = (props) => { + const { onChange, name } = props; + const state = useSettings(); + const split = name.split('.'); + const [loading, setLoading] = useState(false); + // @ts-ignore + const errors = state?.formState?.errors?.[split?.[0]]?.[split?.[1]]?.value; + const [results, setResults] = useState([]); + const func = useCustomProviderFunction(); + const value = useWatch({ + name, + }); + const [searchValue, setSearchValue] = useState(''); + const setResult = (result: { id: string; name: string }) => async () => { + setLoading(true); + setSearchValue(''); + onChange({ + target: { + name, + value: { + id: String(result.id), + subreddit: result.name, + title: '', + name: '', + url: '', + body: '', + media: [], + }, + }, + }); + setLoading(false); + }; + const setTitle = useCallback( + (e: any) => { + onChange({ + target: { + name, + value: { + ...value, + title: e.target.value, + }, + }, + }); + }, + [value] + ); + const setURL = useCallback( + (e: any) => { + onChange({ + target: { + name, + value: { + ...value, + url: e.target.value, + }, + }, + }); + }, + [value] + ); + const search = useDebouncedCallback( + useCallback(async (e: FormEvent) => { + // @ts-ignore + setResults([]); + // @ts-ignore + if (!e.target.value) { + return; + } + // @ts-ignore + const results = await func.get('subreddits', { word: e.target.value }); + // @ts-ignore + setResults(results); + }, []), + 500 + ); + return ( +
+ {value?.subreddit ? ( + <> + + + + + ) : ( +
+ { + // @ts-ignore + setSearchValue(e.target.value); + await search(e); + }} + /> + {!!results.length && !loading && ( +
+ {results.map((r: { id: string; name: string }) => ( +
+ {r.name} +
+ ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/linkedin/linkedin.provider.tsx b/apps/frontend/src/components/new-launch/providers/linkedin/linkedin.provider.tsx new file mode 100644 index 00000000..ecdd7940 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/linkedin/linkedin.provider.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { Checkbox } from '@gitroom/react/form/checkbox'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { LinkedinDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/linkedin.dto'; + +const LinkedInSettings = () => { + const t = useT(); + const { watch, register, formState, control } = useSettings(); + + return ( +
+ +
+ ); +}; +export default withProvider( + LinkedInSettings, + undefined, + LinkedinDto, + async (posts, vals) => { + const [firstPost, ...restPosts] = posts; + + if ( + vals.post_as_images_carousel && + (firstPost.length < 2 || + firstPost.some((p) => p.path.indexOf('mp4') > -1)) + ) { + return 'LinkedIn carousel can only be created with 2 or more images and no videos.'; + } + + if ( + firstPost.length > 1 && + firstPost.some((p) => p.path.indexOf('mp4') > -1) + ) { + return 'LinkedIn can have maximum 1 media when selecting a video.'; + } + if (restPosts.some((p) => p.length > 0)) { + return 'LinkedIn comments can only contain text.'; + } + return true; + }, + 3000 +); diff --git a/apps/frontend/src/components/new-launch/providers/mastodon/mastodon.provider.tsx b/apps/frontend/src/components/new-launch/providers/mastodon/mastodon.provider.tsx new file mode 100644 index 00000000..77e3963a --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/mastodon/mastodon.provider.tsx @@ -0,0 +1,4 @@ +'use client'; + +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +export default withProvider(null, undefined, undefined, undefined, 500); diff --git a/apps/frontend/src/components/new-launch/providers/medium/fonts/Charter Bold Italic.ttf b/apps/frontend/src/components/new-launch/providers/medium/fonts/Charter Bold Italic.ttf new file mode 100644 index 00000000..e69de29b diff --git a/apps/frontend/src/components/new-launch/providers/medium/fonts/Charter Bold.ttf b/apps/frontend/src/components/new-launch/providers/medium/fonts/Charter Bold.ttf new file mode 100644 index 00000000..e69de29b diff --git a/apps/frontend/src/components/new-launch/providers/medium/fonts/Charter Italic.ttf b/apps/frontend/src/components/new-launch/providers/medium/fonts/Charter Italic.ttf new file mode 100644 index 00000000..e69de29b diff --git a/apps/frontend/src/components/new-launch/providers/medium/fonts/Charter Regular.ttf b/apps/frontend/src/components/new-launch/providers/medium/fonts/Charter Regular.ttf new file mode 100644 index 00000000..e69de29b diff --git a/apps/frontend/src/components/new-launch/providers/medium/fonts/stylesheet.css b/apps/frontend/src/components/new-launch/providers/medium/fonts/stylesheet.css new file mode 100644 index 00000000..2c0106c2 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/medium/fonts/stylesheet.css @@ -0,0 +1,37 @@ +/* Generated by Font Squirrel (http://www.fontsquirrel.com) on July 10, 2013 */ + +@font-face { + font-family: 'charterbold_italic'; + src: url('charter_bold_italic-webfont.eot'); + src: url('charter_bold_italic-webfont.eot?#iefix') format('embedded-opentype'), + url('charter_bold_italic-webfont.woff') format('woff'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'charterbold'; + src: url('charter_bold-webfont.eot'); + src: url('charter_bold-webfont.eot?#iefix') format('embedded-opentype'), + url('charter_bold-webfont.woff') format('woff'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'charteritalic'; + src: url('charter_italic-webfont.eot'); + src: url('charter_italic-webfont.eot?#iefix') format('embedded-opentype'), + url('charter_italic-webfont.woff') format('woff'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'charterregular'; + src: url('charter_regular-webfont.eot'); + src: url('charter_regular-webfont.eot?#iefix') format('embedded-opentype'), + url('charter_regular-webfont.woff') format('woff'); + font-weight: normal; + font-style: normal; +} diff --git a/apps/frontend/src/components/new-launch/providers/medium/medium.provider.tsx b/apps/frontend/src/components/new-launch/providers/medium/medium.provider.tsx new file mode 100644 index 00000000..5b552d96 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/medium/medium.provider.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { FC } from 'react'; +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { Input } from '@gitroom/react/form/input'; +import { MediumPublications } from '@gitroom/frontend/components/new-launch/providers/medium/medium.publications'; +import { MediumTags } from '@gitroom/frontend/components/new-launch/providers/medium/medium.tags'; +import { MediumSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/medium.settings.dto'; +import { useIntegration } from '@gitroom/frontend/components/new-launch/helpers/use.integration'; +import clsx from 'clsx'; +import MDEditor from '@uiw/react-md-editor'; +import localFont from 'next/font/local'; +import { Canonical } from '@gitroom/react/form/canonical'; +import interClass from '@gitroom/react/helpers/inter.font'; +const charter = localFont({ + src: [ + { + path: './fonts/Charter Regular.ttf', + weight: 'normal', + style: 'normal', + }, + { + path: './fonts/Charter Italic.ttf', + weight: 'normal', + style: 'italic', + }, + { + path: './fonts/Charter Bold.ttf', + weight: '700', + style: 'normal', + }, + { + path: './fonts/Charter Bold Italic.ttf', + weight: '700', + style: 'italic', + }, + ], +}); +const MediumPreview: FC = () => { + const { value } = useIntegration(); + const settings = useSettings(); + const [title, subtitle] = settings.watch(['title', 'subtitle']); + return ( +
+
+
{title}
+
+ {subtitle} +
+
+
+ p.content).join('\n')} + /> +
+
+ ); +}; +const MediumSettings: FC = () => { + const form = useSettings(); + const { date } = useIntegration(); + return ( + <> + + + +
+ +
+
+ +
+ + ); +}; +export default withProvider(MediumSettings, MediumPreview, MediumSettingsDto); diff --git a/apps/frontend/src/components/new-launch/providers/medium/medium.publications.tsx b/apps/frontend/src/components/new-launch/providers/medium/medium.publications.tsx new file mode 100644 index 00000000..c12c456b --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/medium/medium.publications.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { FC, useEffect, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import { Select } from '@gitroom/react/form/select'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +export const MediumPublications: FC<{ + name: string; + onChange: (event: { + target: { + value: string; + name: string; + }; + }) => void; +}> = (props) => { + const { onChange, name } = props; + const t = useT(); + + const customFunc = useCustomProviderFunction(); + const [publications, setOrgs] = useState([]); + const { getValues } = useSettings(); + const [currentMedia, setCurrentMedia] = useState(); + const onChangeInner = (event: { + target: { + value: string; + name: string; + }; + }) => { + setCurrentMedia(event.target.value); + onChange(event); + }; + useEffect(() => { + customFunc.get('publications').then((data) => setOrgs(data)); + const settings = getValues()[props.name]; + if (settings) { + setCurrentMedia(settings); + } + }, []); + if (!publications.length) { + return null; + } + return ( + + ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/medium/medium.tags.tsx b/apps/frontend/src/components/new-launch/providers/medium/medium.tags.tsx new file mode 100644 index 00000000..0363170a --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/medium/medium.tags.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { ReactTags } from 'react-tag-autocomplete'; +import interClass from '@gitroom/react/helpers/inter.font'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; + +export const MediumTags: FC<{ + name: string; + label: string; + onChange: (event: { + target: { + value: any[]; + name: string; + }; + }) => void; +}> = (props) => { + const { onChange, name, label } = props; + const { getValues } = useSettings(); + const [tagValue, setTagValue] = useState([]); + const [suggestions, setSuggestions] = useState(''); + const t = useT(); + + const onDelete = useCallback( + (tagIndex: number) => { + const modify = tagValue.filter((_, i) => i !== tagIndex); + setTagValue(modify); + onChange({ + target: { + value: modify, + name, + }, + }); + }, + [tagValue] + ); + const onAddition = useCallback( + (newTag: any) => { + if (tagValue.length >= 3) { + return; + } + const modify = [...tagValue, newTag]; + setTagValue(modify); + onChange({ + target: { + value: modify, + name, + }, + }); + }, + [tagValue] + ); + useEffect(() => { + const settings = getValues()[props.name]; + if (settings) { + setTagValue(settings); + } + }, []); + const suggestionsArray = useMemo(() => { + return [ + ...tagValue, + { + label: suggestions, + value: suggestions, + }, + ].filter((f) => f.label); + }, [suggestions, tagValue]); + return ( +
+
{label}
+ +
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/nostr/nostr.provider.tsx b/apps/frontend/src/components/new-launch/providers/nostr/nostr.provider.tsx new file mode 100644 index 00000000..3ee13fe2 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/nostr/nostr.provider.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +export default withProvider( + null, + undefined, + undefined, + async () => { + return true; + }, + undefined +); diff --git a/apps/frontend/src/components/new-launch/providers/pinterest/pinterest.board.tsx b/apps/frontend/src/components/new-launch/providers/pinterest/pinterest.board.tsx new file mode 100644 index 00000000..30e9ab89 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/pinterest/pinterest.board.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { FC, useEffect, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import { Select } from '@gitroom/react/form/select'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +export const PinterestBoard: FC<{ + name: string; + onChange: (event: { + target: { + value: string; + name: string; + }; + }) => void; +}> = (props) => { + const { onChange, name } = props; + const t = useT(); + + const customFunc = useCustomProviderFunction(); + const [orgs, setOrgs] = useState(); + const { getValues } = useSettings(); + const [currentMedia, setCurrentMedia] = useState(); + const onChangeInner = (event: { + target: { + value: string; + name: string; + }; + }) => { + setCurrentMedia(event.target.value); + onChange(event); + }; + useEffect(() => { + customFunc.get('boards').then((data) => setOrgs(data)); + const settings = getValues()[props.name]; + if (settings) { + setCurrentMedia(settings); + } + }, []); + if (!orgs) { + return null; + } + if (!orgs.length) { + return 'No boards found, you have to create a board first'; + } + return ( + + ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/pinterest/pinterest.provider.tsx b/apps/frontend/src/components/new-launch/providers/pinterest/pinterest.provider.tsx new file mode 100644 index 00000000..cfb2371e --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/pinterest/pinterest.provider.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { FC } from 'react'; +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { PinterestBoard } from '@gitroom/frontend/components/new-launch/providers/pinterest/pinterest.board'; +import { PinterestSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/pinterest.dto'; +import { Input } from '@gitroom/react/form/input'; +import { ColorPicker } from '@gitroom/react/form/color.picker'; +const PinterestSettings: FC = () => { + const { register, control } = useSettings(); + return ( +
+ + + + +
+ ); +}; +export default withProvider( + PinterestSettings, + undefined, + PinterestSettingsDto, + async ([firstItem, ...otherItems]) => { + const isMp4 = firstItem?.find((item) => item.path.indexOf('mp4') > -1); + const isPicture = firstItem?.find( + (item) => item.path.indexOf('mp4') === -1 + ); + if (firstItem.length === 0) { + return 'Pinterest requires at least one media'; + } + if (isMp4 && firstItem.length !== 2 && !isPicture) { + return 'If posting a video to Pinterest you have to also include a cover image as second media'; + } + if (isMp4 && firstItem.length > 2) { + return 'If posting a video to Pinterest you can only have two media items'; + } + if (otherItems.length) { + return 'Pinterest can only have one post'; + } + if ( + firstItem.length > 1 && + firstItem.every((p) => p.path.indexOf('mp4') == -1) + ) { + const loadAll: Array<{ + width: number; + height: number; + }> = (await Promise.all( + firstItem.map((p) => { + return new Promise((resolve, reject) => { + const url = new Image(); + url.onload = function () { + // @ts-ignore + resolve({ width: this.width, height: this.height }); + }; + url.src = p.path; + }); + }) + )) as any; + const checkAllTheSameWidthHeight = loadAll.every((p, i, arr) => { + return p.width === arr[0].width && p.height === arr[0].height; + }); + if (!checkAllTheSameWidthHeight) { + return 'Pinterest requires all images to have the same width and height'; + } + } + return true; + }, + 500 +); diff --git a/apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx b/apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx new file mode 100644 index 00000000..f0427d97 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { FC, useCallback } from 'react'; +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { useIntegration } from '@gitroom/frontend/components/new-launch/helpers/use.integration'; +import { useFormatting } from '@gitroom/frontend/components/new-launch/helpers/use.formatting'; +import { Subreddit } from '@gitroom/frontend/components/new-launch/providers/reddit/subreddit'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { useFieldArray, useWatch } from 'react-hook-form'; +import { Button } from '@gitroom/react/form/button'; +import { + RedditSettingsDto, + RedditSettingsValueDto, +} from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/reddit.dto'; +import clsx from 'clsx'; +import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory'; +import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; +import MDEditor from '@uiw/react-md-editor'; +import interClass from '@gitroom/react/helpers/inter.font'; +import Image from 'next/image'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +const RenderRedditComponent: FC<{ + type: string; + images?: Array<{ + id: string; + path: string; + }>; +}> = (props) => { + const { value: topValue } = useIntegration(); + const showMedia = useMediaDirectory(); + const t = useT(); + + const { type, images } = props; + const [firstPost] = topValue; + switch (type) { + case 'self': + return ( + + ); + case 'link': + return ( +
+ {t('link', 'Link')} +
+ ); + case 'media': + return ( +
+ {!!images?.length && + images.map((image, index) => ( + + + + ))} +
+ ); + } + return <>; +}; +const RedditPreview: FC = (props) => { + const { value: topValue, integration } = useIntegration(); + const settings = useWatch({ + name: 'subreddit', + }) as Array; + const [, ...restOfPosts] = useFormatting(topValue, { + removeMarkdown: true, + saveBreaklines: true, + specialFunc: (text: string) => { + return text.slice(0, 280); + }, + }); + if (!settings || !settings.length) { + return <>Please add at least one Subreddit from the settings; + } + return ( +
+ {settings + .filter(({ value }) => value?.subreddit) + .map(({ value }, index) => ( +
+
+
+
+
+
+ {value.subreddit} +
+
{integration?.name}
+
+
+
+ {value.title} +
+ +
+ {restOfPosts.map((p, index) => ( +
+
+ x + x +
+
+
+ {integration?.name} +
+ +
+
+ ))} +
+
+
+ ))} +
+ ); +}; +const RedditSettings: FC = () => { + const { register, control } = useSettings(); + const { fields, append, remove } = useFieldArray({ + control, + // control props comes from useForm (optional: if you are using FormContext) + name: 'subreddit', // unique name for your Field Array + }); + const t = useT(); + + const addField = useCallback(() => { + append({}); + }, [fields, append]); + const deleteField = useCallback( + (index: number) => async () => { + if ( + !(await deleteDialog( + t( + 'are_you_sure_you_want_to_delete_this_subreddit', + 'Are you sure you want to delete this Subreddit?' + ) + )) + ) + return; + remove(index); + }, + [fields, remove] + ); + return ( + <> +
+ {fields.map((field, index) => ( +
+
+ x +
+ +
+ ))} +
+ + {fields.length === 0 && ( +
+ {t( + 'please_add_at_least_one_subreddit', + 'Please add at least one Subreddit' + )} +
+ )} + + ); +}; +export default withProvider( + RedditSettings, + RedditPreview, + RedditSettingsDto, + undefined, + 10000 +); diff --git a/apps/frontend/src/components/new-launch/providers/reddit/subreddit.tsx b/apps/frontend/src/components/new-launch/providers/reddit/subreddit.tsx new file mode 100644 index 00000000..07704150 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/reddit/subreddit.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { FC, FormEvent, useCallback, useMemo, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import { Input } from '@gitroom/react/form/input'; +import { useDebouncedCallback } from 'use-debounce'; +import { Button } from '@gitroom/react/form/button'; +import clsx from 'clsx'; +import { MultiMediaComponent } from '@gitroom/frontend/components/media/media.component'; +import { useWatch } from 'react-hook-form'; +import { Select } from '@gitroom/react/form/select'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { Canonical } from '@gitroom/react/form/canonical'; +import { useIntegration } from '@gitroom/frontend/components/new-launch/helpers/use.integration'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +export const RenderOptions: FC<{ + options: Array<'self' | 'link' | 'media'>; + onClick: (current: 'self' | 'link' | 'media') => void; + value: 'self' | 'link' | 'media'; +}> = (props) => { + const { options, onClick, value } = props; + const mapValues = useMemo(() => { + return options.map((p) => ({ + children: ( + <> + {p === 'self' + ? 'Post' + : p === 'link' + ? 'Link' + : p === 'media' + ? 'Media' + : ''} + + ), + id: p, + onClick: () => onClick(p), + })); + }, [options]); + return ( +
+ {mapValues.map((p) => ( +
+ ); +}; +export const Subreddit: FC<{ + onChange: (event: { + target: { + name: string; + value: { + id: string; + name: string; + }; + }; + }) => void; + name: string; +}> = (props) => { + const { onChange, name } = props; + const state = useSettings(); + const t = useT(); + + const { date } = useIntegration(); + const split = name.split('.'); + const [loading, setLoading] = useState(false); + // @ts-ignore + const errors = state?.formState?.errors?.[split?.[0]]?.[split?.[1]]?.value; + const [results, setResults] = useState([]); + const func = useCustomProviderFunction(); + const value = useWatch({ + name, + }); + const [searchValue, setSearchValue] = useState(''); + const setResult = (result: { id: string; name: string }) => async () => { + setLoading(true); + setSearchValue(''); + const restrictions = await func.get('restrictions', { + subreddit: result.name, + }); + onChange({ + target: { + name, + value: { + ...restrictions, + type: restrictions.allow[0], + media: [], + }, + }, + }); + setLoading(false); + }; + const setTitle = useCallback( + (e: any) => { + onChange({ + target: { + name, + value: { + ...value, + title: e.target.value, + }, + }, + }); + }, + [value] + ); + const setType = useCallback( + (e: string) => { + onChange({ + target: { + name, + value: { + ...value, + type: e, + }, + }, + }); + }, + [value] + ); + const setMedia = useCallback( + (e: any) => { + onChange({ + target: { + name, + value: { + ...value, + media: e.target.value.map((p: any) => p), + }, + }, + }); + }, + [value] + ); + const setURL = useCallback( + (e: any) => { + onChange({ + target: { + name, + value: { + ...value, + url: e.target.value, + }, + }, + }); + }, + [value] + ); + const setFlair = useCallback( + (e: any) => { + onChange({ + target: { + name, + value: { + ...value, + flair: value.flairs.find((p: any) => p.id === e.target.value), + }, + }, + }); + }, + [value] + ); + const search = useDebouncedCallback( + useCallback(async (e: FormEvent) => { + // @ts-ignore + setResults([]); + // @ts-ignore + if (!e.target.value) { + return; + } + // @ts-ignore + const results = await func.get('subreddits', { word: e.target.value }); + // @ts-ignore + setResults(results); + }, []), + 500 + ); + return ( +
+ {value?.subreddit ? ( + <> + +
+ +
+ + + {value.type === 'link' && ( + + )} + {value.type === 'media' && ( +
+
+
+ +
+
+ )} + + ) : ( +
+ { + // @ts-ignore + setSearchValue(e.target.value); + await search(e); + }} + /> + {!!results.length && !loading && ( +
+ {results.map((r: { id: string; name: string }) => ( +
+ {r.name} +
+ ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx b/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx new file mode 100644 index 00000000..62d7ea20 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx @@ -0,0 +1,166 @@ +'use client'; + +import DevtoProvider from '@gitroom/frontend/components/new-launch/providers/devto/devto.provider'; +import XProvider from '@gitroom/frontend/components/new-launch/providers/x/x.provider'; +import LinkedinProvider from '@gitroom/frontend/components/new-launch/providers/linkedin/linkedin.provider'; +import RedditProvider from '@gitroom/frontend/components/new-launch/providers/reddit/reddit.provider'; +import MediumProvider from '@gitroom/frontend/components/new-launch/providers/medium/medium.provider'; +import HashnodeProvider from '@gitroom/frontend/components/new-launch/providers/hashnode/hashnode.provider'; +import FacebookProvider from '@gitroom/frontend/components/new-launch/providers/facebook/facebook.provider'; +import InstagramProvider from '@gitroom/frontend/components/new-launch/providers/instagram/instagram.collaborators'; +import YoutubeProvider from '@gitroom/frontend/components/new-launch/providers/youtube/youtube.provider'; +import TiktokProvider from '@gitroom/frontend/components/new-launch/providers/tiktok/tiktok.provider'; +import PinterestProvider from '@gitroom/frontend/components/new-launch/providers/pinterest/pinterest.provider'; +import DribbbleProvider from '@gitroom/frontend/components/new-launch/providers/dribbble/dribbble.provider'; +import ThreadsProvider from '@gitroom/frontend/components/new-launch/providers/threads/threads.provider'; +import DiscordProvider from '@gitroom/frontend/components/new-launch/providers/discord/discord.provider'; +import SlackProvider from '@gitroom/frontend/components/new-launch/providers/slack/slack.provider'; +import MastodonProvider from '@gitroom/frontend/components/new-launch/providers/mastodon/mastodon.provider'; +import BlueskyProvider from '@gitroom/frontend/components/new-launch/providers/bluesky/bluesky.provider'; +import LemmyProvider from '@gitroom/frontend/components/new-launch/providers/lemmy/lemmy.provider'; +import WarpcastProvider from '@gitroom/frontend/components/new-launch/providers/warpcast/warpcast.provider'; +import TelegramProvider from '@gitroom/frontend/components/new-launch/providers/telegram/telegram.provider'; +import NostrProvider from '@gitroom/frontend/components/new-launch/providers/nostr/nostr.provider'; +import VkProvider from '@gitroom/frontend/components/new-launch/providers/vk/vk.provider'; +import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; +import { useShallow } from 'zustand/react/shallow'; +import { createRef, FC, forwardRef, useImperativeHandle } from 'react'; +export const Providers = [ + { + identifier: 'devto', + component: DevtoProvider, + }, + { + identifier: 'x', + component: XProvider, + }, + { + identifier: 'linkedin', + component: LinkedinProvider, + }, + { + identifier: 'linkedin-page', + component: LinkedinProvider, + }, + { + identifier: 'reddit', + component: RedditProvider, + }, + { + identifier: 'medium', + component: MediumProvider, + }, + { + identifier: 'hashnode', + component: HashnodeProvider, + }, + { + identifier: 'facebook', + component: FacebookProvider, + }, + { + identifier: 'instagram', + component: InstagramProvider, + }, + { + identifier: 'instagram-standalone', + component: InstagramProvider, + }, + { + identifier: 'youtube', + component: YoutubeProvider, + }, + { + identifier: 'tiktok', + component: TiktokProvider, + }, + { + identifier: 'pinterest', + component: PinterestProvider, + }, + { + identifier: 'dribbble', + component: DribbbleProvider, + }, + { + identifier: 'threads', + component: ThreadsProvider, + }, + { + identifier: 'discord', + component: DiscordProvider, + }, + { + identifier: 'slack', + component: SlackProvider, + }, + { + identifier: 'mastodon', + component: MastodonProvider, + }, + { + identifier: 'bluesky', + component: BlueskyProvider, + }, + { + identifier: 'lemmy', + component: LemmyProvider, + }, + { + identifier: 'wrapcast', + component: WarpcastProvider, + }, + { + identifier: 'telegram', + component: TelegramProvider, + }, + { + identifier: 'nostr', + component: NostrProvider, + }, + { + identifier: 'vk', + component: VkProvider, + }, +]; +export const ShowAllProviders = forwardRef((props, ref) => { + const { current, selectedIntegrations } = useLaunchStore( + useShallow((state) => ({ + selectedIntegrations: state.selectedIntegrations, + current: state.current, + })) + ); + + useImperativeHandle(ref, () => ({ + checkAllValid: async () => { + return Promise.all( + selectedIntegrations.map(async (p) => await p.ref?.current.isValid()) + ); + }, + })); + + return ( + <> + {selectedIntegrations.map((integration) => { + const { component: ProviderComponent } = Providers.find( + (provider) => + provider.identifier === integration.integration.identifier + ) || { + component: Empty, + }; + + return ( + + ); + })} + + ); +}); + +export const Empty: FC = () => { + return null; +}; diff --git a/apps/frontend/src/components/new-launch/providers/slack/slack.channel.select.tsx b/apps/frontend/src/components/new-launch/providers/slack/slack.channel.select.tsx new file mode 100644 index 00000000..510bf891 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/slack/slack.channel.select.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { FC, useEffect, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import { Select } from '@gitroom/react/form/select'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +export const SlackChannelSelect: FC<{ + name: string; + onChange: (event: { + target: { + value: string; + name: string; + }; + }) => void; +}> = (props) => { + const { onChange, name } = props; + const t = useT(); + const customFunc = useCustomProviderFunction(); + const [publications, setOrgs] = useState([]); + const { getValues } = useSettings(); + const [currentMedia, setCurrentMedia] = useState(); + const onChangeInner = (event: { + target: { + value: string; + name: string; + }; + }) => { + setCurrentMedia(event.target.value); + onChange(event); + }; + useEffect(() => { + customFunc.get('channels').then((data) => setOrgs(data)); + const settings = getValues()[props.name]; + if (settings) { + setCurrentMedia(settings); + } + }, []); + if (!publications.length) { + return null; + } + return ( + + ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/slack/slack.provider.tsx b/apps/frontend/src/components/new-launch/providers/slack/slack.provider.tsx new file mode 100644 index 00000000..3393f5f5 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/slack/slack.provider.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { FC } from 'react'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { SlackChannelSelect } from '@gitroom/frontend/components/new-launch/providers/slack/slack.channel.select'; +import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/slack.dto'; +const SlackComponent: FC = () => { + const form = useSettings(); + return ( +
+ +
+ ); +}; +export default withProvider( + SlackComponent, + undefined, + SlackDto, + undefined, + 280 +); diff --git a/apps/frontend/src/components/new-launch/providers/telegram/telegram.provider.tsx b/apps/frontend/src/components/new-launch/providers/telegram/telegram.provider.tsx new file mode 100644 index 00000000..2abe6db7 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/telegram/telegram.provider.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +export default withProvider( + null, + undefined, + undefined, + async () => { + return true; + }, + 4096 +); diff --git a/apps/frontend/src/components/new-launch/providers/threads/threads.provider.tsx b/apps/frontend/src/components/new-launch/providers/threads/threads.provider.tsx new file mode 100644 index 00000000..2fad94b6 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/threads/threads.provider.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { ThreadFinisher } from '@gitroom/frontend/components/new-launch/finisher/thread.finisher'; +const SettingsComponent = () => { + return ; +}; + +export default withProvider( + SettingsComponent, + undefined, + undefined, + async () => { + return true; + }, + 500 +); diff --git a/apps/frontend/src/components/new-launch/providers/tiktok/tiktok.provider.tsx b/apps/frontend/src/components/new-launch/providers/tiktok/tiktok.provider.tsx new file mode 100644 index 00000000..d0cea233 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/tiktok/tiktok.provider.tsx @@ -0,0 +1,372 @@ +'use client'; + +import { + FC, + ReactEventHandler, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { TikTokDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/tiktok.dto'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { Select } from '@gitroom/react/form/select'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import { Checkbox } from '@gitroom/react/form/checkbox'; +import clsx from 'clsx'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; + +const CheckTikTokValidity: FC<{ + picture: string; +}> = (props) => { + const { register } = useSettings(); + const t = useT(); + + const func = useCustomProviderFunction(); + const [maxVideoLength, setMaxVideoLength] = useState(0); + const [isValidVideo, setIsValidVideo] = useState( + undefined + ); + const registerVideo = register('isValidVideo'); + const video = useMemo(() => { + return props.picture; + }, [props.picture]); + useEffect(() => { + loadStats(); + }, []); + const loadStats = useCallback(async () => { + const { maxDurationSeconds } = await func.get('maxVideoLength'); + // setMaxVideoLength(5); + setMaxVideoLength(maxDurationSeconds); + }, []); + const loadVideo: ReactEventHandler = useCallback( + (e) => { + // @ts-ignore + setIsValidVideo(e.target.duration <= maxVideoLength); + registerVideo.onChange({ + target: { + name: 'isValidVideo', + // @ts-ignore + value: String(e.target.duration <= maxVideoLength), + }, + }); + }, + [maxVideoLength, registerVideo] + ); + if (!maxVideoLength || !video || video.indexOf('mp4') === -1) { + return null; + } + return ( + <> + {isValidVideo === false && ( +
+ {t( + 'video_length_is_invalid_must_be_up_to', + 'Video length is invalid, must be up to' + )} + {maxVideoLength} + {t('seconds', 'seconds')} +
+ )} + + + ); +}; +const TikTokSettings: FC<{ + values?: any; +}> = (props) => { + const { watch, register, formState, control } = useSettings(); + const t = useT(); + + const disclose = watch('disclose'); + const brand_organic_toggle = watch('brand_organic_toggle'); + const brand_content_toggle = watch('brand_content_toggle'); + const content_posting_method = watch('content_posting_method'); + const isUploadMode = content_posting_method === 'UPLOAD'; + + const privacyLevel = [ + { + value: 'PUBLIC_TO_EVERYONE', + label: t('public_to_everyone', 'Public to everyone'), + }, + { + value: 'MUTUAL_FOLLOW_FRIENDS', + label: t('mutual_follow_friends', 'Mutual follow friends'), + }, + { + value: 'FOLLOWER_OF_CREATOR', + label: t('follower_of_creator', 'Follower of creator'), + }, + { + value: 'SELF_ONLY', + label: t('self_only', 'Self only'), + }, + ]; + const contentPostingMethod = [ + { + value: 'DIRECT_POST', + label: t( + 'post_content_directly_to_tiktok', + 'Post content directly to TikTok' + ), + }, + { + value: 'UPLOAD', + label: t( + 'upload_content_to_tiktok_without_posting', + 'Upload content to TikTok without posting it' + ), + }, + ]; + const yesNo = [ + { + value: 'yes', + label: t('yes', 'Yes'), + }, + { + value: 'no', + label: t('no', 'No'), + }, + ]; + + return ( +
+ + +
+ {t( + 'choose_upload_without_posting_description', + `Choose upload without posting if you want to review and edit your content within TikTok's app before publishing. + This gives you access to TikTok's built-in editing tools and lets you make final adjustments before posting.` + )} +
+ + +
+ {t( + 'this_feature_available_only_for_photos', + 'This feature available only for photos, it will add a default music that\n you can change later.' + )} +
+
+
+ {t('allow_user_to', 'Allow User To:')} +
+
+ + + +
+
+
+ + {disclose && ( +
+
+ + + +
+
+ {t( + 'your_video_will_be_labeled_promotional', + 'Your video will be labeled "Promotional Content".' + )} +
+ {t( + 'this_cannot_be_changed_once_posted', + 'This cannot be changed once your video is posted.' + )} +
+
+ )} +
+ {t( + 'turn_on_to_disclose_video_promotes', + 'Turn on to disclose that this video promotes goods or services in\n exchange for something of value. You video could promote yourself, a\n third party, or both.' + )} +
+
+
+ +
+ {t( + 'you_are_promoting_yourself', + 'You are promoting yourself or your own brand.' + )} +
+ {t( + 'this_video_will_be_classified_brand_organic', + 'This video will be classified as Brand Organic.' + )} +
+ +
+ {t( + 'you_are_promoting_another_brand', + 'You are promoting another brand or a third party.' + )} +
+ {t( + 'this_video_will_be_classified_branded_content', + 'This video will be classified as Branded Content.' + )} +
+ {(brand_organic_toggle || brand_content_toggle) && ( +
+ {t( + 'by_posting_you_agree_to_tiktoks', + "By posting, you agree to TikTok's" + )} + {[ + brand_organic_toggle || brand_content_toggle ? ( + + {t('music_usage_confirmation', 'Music Usage Confirmation')} + + ) : undefined, + brand_content_toggle ? <> {t('and', 'and')} : undefined, + brand_content_toggle ? ( + + {t('branded_content_policy', 'Branded Content Policy')} + + ) : undefined, + ].filter((f) => f)} +
+ )} +
+
+ ); +}; +export default withProvider( + TikTokSettings, + undefined, + TikTokDto, + async (items) => { + const [firstItems] = items; + if (items.length !== 1) { + return 'Tiktok items should be one'; + } + if (firstItems.length === 0) { + return 'No video / images selected'; + } + if ( + firstItems.length > 1 && + firstItems?.some((p) => p?.path?.indexOf('mp4') > -1) + ) { + return 'Only pictures are supported when selecting multiple items'; + } else if ( + firstItems?.length !== 1 && + firstItems?.[0]?.path?.indexOf('mp4') > -1 + ) { + return 'You need one media'; + } + return true; + }, + 2000 +); diff --git a/apps/frontend/src/components/new-launch/providers/vk/vk.provider.tsx b/apps/frontend/src/components/new-launch/providers/vk/vk.provider.tsx new file mode 100644 index 00000000..3a2a4fd7 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/vk/vk.provider.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +export default withProvider( + null, + undefined, + undefined, + async (posts) => { + return true; + }, + 2048 +); diff --git a/apps/frontend/src/components/new-launch/providers/warpcast/subreddit.tsx b/apps/frontend/src/components/new-launch/providers/warpcast/subreddit.tsx new file mode 100644 index 00000000..56239784 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/warpcast/subreddit.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { FC, FormEvent, useCallback, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/new-launch/helpers/use.custom.provider.function'; +import { Input } from '@gitroom/react/form/input'; +import { useDebouncedCallback } from 'use-debounce'; +import { useWatch } from 'react-hook-form'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +export const Subreddit: FC<{ + onChange: (event: { + target: { + name: string; + value: { + id: string; + subreddit: string; + title: string; + name: string; + url: string; + body: string; + media: any[]; + }; + }; + }) => void; + name: string; +}> = (props) => { + const { onChange, name } = props; + const state = useSettings(); + const split = name.split('.'); + const [loading, setLoading] = useState(false); + // @ts-ignore + const errors = state?.formState?.errors?.[split?.[0]]?.[split?.[1]]?.value; + const [results, setResults] = useState([]); + const func = useCustomProviderFunction(); + const value = useWatch({ + name, + }); + const [searchValue, setSearchValue] = useState(''); + const setResult = (result: { id: string; name: string }) => async () => { + setLoading(true); + setSearchValue(''); + onChange({ + target: { + name, + value: { + id: String(result.id), + subreddit: result.name, + title: '', + name: '', + url: '', + body: '', + media: [], + }, + }, + }); + setLoading(false); + }; + const setTitle = useCallback( + (e: any) => { + onChange({ + target: { + name, + value: { + ...value, + title: e.target.value, + }, + }, + }); + }, + [value] + ); + const setURL = useCallback( + (e: any) => { + onChange({ + target: { + name, + value: { + ...value, + url: e.target.value, + }, + }, + }); + }, + [value] + ); + const search = useDebouncedCallback( + useCallback(async (e: FormEvent) => { + // @ts-ignore + setResults([]); + // @ts-ignore + if (!e.target.value) { + return; + } + // @ts-ignore + const results = await func.get('subreddits', { word: e.target.value }); + // @ts-ignore + setResults(results); + }, []), + 500 + ); + return ( +
+ {value?.subreddit ? ( + <> + + + ) : ( +
+ { + // @ts-ignore + setSearchValue(e.target.value); + await search(e); + }} + /> + {!!results.length && !loading && ( +
+ {results.map((r: { id: string; name: string }) => ( +
+ {r.name} +
+ ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/warpcast/warpcast.provider.tsx b/apps/frontend/src/components/new-launch/providers/warpcast/warpcast.provider.tsx new file mode 100644 index 00000000..27c12622 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/warpcast/warpcast.provider.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { FC, useCallback } from 'react'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { useFieldArray } from 'react-hook-form'; +import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; +import { Button } from '@gitroom/react/form/button'; +import { Subreddit } from './subreddit'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +const WrapcastProvider: FC = () => { + const { register, control } = useSettings(); + const { fields, append, remove } = useFieldArray({ + control, + // control props comes from useForm (optional: if you are using FormContext) + name: 'subreddit', // unique name for your Field Array + }); + const t = useT(); + + const addField = useCallback(() => { + append({}); + }, [fields, append]); + const deleteField = useCallback( + (index: number) => async () => { + if ( + !(await deleteDialog( + t( + 'are_you_sure_you_want_to_delete_this_subreddit', + 'Are you sure you want to delete this Subreddit?' + ) + )) + ) + return; + remove(index); + }, + [fields, remove] + ); + return ( + <> +
+ {fields.map((field, index) => ( +
+
+ x +
+ +
+ ))} +
+ + + ); +}; +export default withProvider( + WrapcastProvider, + undefined, + undefined, + async (list) => { + if ( + list.some((item) => item.some((field) => field.path.indexOf('mp4') > -1)) + ) { + return 'Warpcast can only accept images'; + } + return true; + }, + 800 +); diff --git a/apps/frontend/src/components/new-launch/providers/x/fonts/Chirp-Bold.woff2 b/apps/frontend/src/components/new-launch/providers/x/fonts/Chirp-Bold.woff2 new file mode 100644 index 00000000..e69de29b diff --git a/apps/frontend/src/components/new-launch/providers/x/fonts/Chirp-Regular.woff2 b/apps/frontend/src/components/new-launch/providers/x/fonts/Chirp-Regular.woff2 new file mode 100644 index 00000000..e69de29b diff --git a/apps/frontend/src/components/new-launch/providers/x/x.provider.tsx b/apps/frontend/src/components/new-launch/providers/x/x.provider.tsx new file mode 100644 index 00000000..d8b824b4 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/x/x.provider.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { ThreadFinisher } from '@gitroom/frontend/components/new-launch/finisher/thread.finisher'; +import { Select } from '@gitroom/react/form/select'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { XDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/x.dto'; +import { Input } from '@gitroom/react/form/input'; + +const whoCanReply = [ + { + label: 'Everyone', + value: 'everyone', + }, + { + label: 'Accounts you follow', + value: 'following', + }, + { + label: 'Mentioned accounts', + value: 'mentionedUsers', + }, + { + label: 'Subscribers', + value: 'subscribers', + }, + { + label: 'Verified accounts', + value: 'verified', + } +] + +const SettingsComponent = () => { + const t = useT(); + const { register, watch, setValue } = useSettings(); + + return ( + <> + + + + + + + ); +}; + +export default withProvider( + SettingsComponent, + undefined, + XDto, + async (posts, settings, additionalSettings: any) => { + const premium = additionalSettings?.find((p: any) => p?.title === 'Verified')?.value || false; + if (posts.some((p) => p.length > 4)) { + return 'There can be maximum 4 pictures in a post.'; + } + if ( + posts.some( + (p) => p.some((m) => m.path.indexOf('mp4') > -1) && p.length > 1 + ) + ) { + return 'There can be maximum 1 video in a post.'; + } + for (const load of posts.flatMap((p) => p.flatMap((a) => a.path))) { + if (load.indexOf('mp4') > -1) { + const isValid = await checkVideoDuration(load, premium); + if (!isValid) { + return 'Video duration must be less than or equal to 140 seconds.'; + } + } + } + return true; + }, + (settings) => { + if (settings?.[0]?.value) { + return 4000; + } + return 280; + } +); +const checkVideoDuration = async (url: string, isPremium = false): Promise => { + return new Promise((resolve, reject) => { + const video = document.createElement('video'); + video.src = url; + video.preload = 'metadata'; + video.onloadedmetadata = () => { + // Check if the duration is less than or equal to 140 seconds + const duration = video.duration; + if ((!isPremium && duration <= 140) || isPremium) { + resolve(true); // Video duration is acceptable + } else { + resolve(false); // Video duration exceeds 140 seconds + } + }; + video.onerror = () => { + reject(new Error('Failed to load video metadata.')); + }; + }); +}; diff --git a/apps/frontend/src/components/new-launch/providers/youtube/youtube.provider.tsx b/apps/frontend/src/components/new-launch/providers/youtube/youtube.provider.tsx new file mode 100644 index 00000000..f790a9bd --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/youtube/youtube.provider.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { FC } from 'react'; +import { withProvider } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { YoutubeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/youtube.settings.dto'; +import { useSettings } from '@gitroom/frontend/components/new-launch/helpers/use.values'; +import { Input } from '@gitroom/react/form/input'; +import { MediumTags } from '@gitroom/frontend/components/new-launch/providers/medium/medium.tags'; +import { MediaComponent } from '@gitroom/frontend/components/media/media.component'; +import { Select } from '@gitroom/react/form/select'; +const type = [ + { + label: 'Public', + value: 'public', + }, + { + label: 'Private', + value: 'private', + }, + { + label: 'Unlisted', + value: 'unlisted', + }, +]; +const YoutubeSettings: FC = () => { + const { register, control } = useSettings(); + return ( +
+ + + +
+ +
+
+ ); +}; +export default withProvider( + YoutubeSettings, + undefined, + YoutubeSettingsDto, + async (items) => { + const [firstItems] = items; + if (items.length !== 1) { + return 'Youtube items should be one'; + } + if (items[0].length !== 1) { + return 'You need one media'; + } + if (firstItems[0].path.indexOf('mp4') === -1) { + return 'Item must be a video'; + } + return true; + }, + 5000 +); diff --git a/apps/frontend/src/components/new-launch/select.current.tsx b/apps/frontend/src/components/new-launch/select.current.tsx new file mode 100644 index 00000000..befc7ad3 --- /dev/null +++ b/apps/frontend/src/components/new-launch/select.current.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { FC } from 'react'; +import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; +import clsx from 'clsx'; +import Image from 'next/image'; +import { useShallow } from 'zustand/react/shallow'; + +export const SelectCurrent: FC = () => { + const { selectedIntegrations, current, setCurrent } = useLaunchStore( + useShallow((state) => ({ + selectedIntegrations: state.selectedIntegrations, + current: state.current, + setCurrent: state.setCurrent, + })) + ); + + return ( +
+
setCurrent('global')} + className="cursor-pointer flex gap-[8px] items-center bg-customColor2 p-[10px] rounded-tl-[4px] rounded-tr-[4px]" + > +
setCurrent('global')} + className={clsx(current !== 'global' ? 'opacity-40' : '')} + > + T +
+
+ {selectedIntegrations.map(({ integration }) => ( +
setCurrent(integration.id)} + key={integration.id} + className="cursor-pointer flex gap-[8px] items-center bg-customColor2 p-[10px] rounded-tl-[4px] rounded-tr-[4px]" + > +
+ {integration.identifier} + {integration.identifier === 'youtube' ? ( + + ) : ( + {integration.identifier} + )} +
+
+ ))} +
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/store.ts b/apps/frontend/src/components/new-launch/store.ts new file mode 100644 index 00000000..86403e8d --- /dev/null +++ b/apps/frontend/src/components/new-launch/store.ts @@ -0,0 +1,306 @@ +'use client'; + +import { create } from 'zustand'; +import dayjs from 'dayjs'; +import { Integrations } from '@gitroom/frontend/components/launches/calendar.context'; +import { createRef, RefObject } from 'react'; + +interface Values { + id: string; + content: string; + media: { id: string; path: string }[]; +} + +interface Internal { + integration: Integrations; + integrationValue: Values[]; +} + +interface SelectedIntegrations { + settings: any; + integration: Integrations; + ref?: RefObject; +} + +interface StoreState { + date: dayjs.Dayjs; + current: string; + integrations: Integrations[]; + selectedIntegrations: SelectedIntegrations[]; + global: Values[]; + internal: Internal[]; + addGlobalValue: (index: number, value: Values[]) => void; + addInternalValue: ( + index: number, + integrationId: string, + value: Values[] + ) => void; + deleteGlobalValue: (index: number) => void; + deleteInternalValue: (integrationId: string, index: number) => void; + addRemoveInternal: (integrationId: string) => void; + changeOrderGlobal: (fromIndex: number, toIndex: number) => void; + changeOrderInternal: ( + integrationId: string, + fromIndex: number, + toIndex: number + ) => void; + setGlobalValueText: (index: number, content: string) => void; + addGlobalValueMedia: ( + index: number, + media: { id: string; path: string }[] + ) => void; + removeGlobalValueMedia: (index: number, mediaIndex: number) => void; + setInternalValueText: ( + integrationId: string, + index: number, + content: string + ) => void; + addInternalValueMedia: ( + integrationId: string, + index: number, + media: { id: string; path: string }[] + ) => void; + removeInternalValueMedia: ( + integrationId: string, + index: number, + mediaIndex: number + ) => void; + setAllIntegrations: (integrations: Integrations[]) => void; + setCurrent: (current: string) => void; + addOrRemoveSelectedIntegration: ( + integration: Integrations, + settings: any + ) => void; + reset: () => void; +} + +const initialState = { + date: dayjs(), + current: 'global', + integrations: [] as Integrations[], + selectedIntegrations: [] as SelectedIntegrations[], + global: [] as Values[], + internal: [] as Internal[], +}; + +export const useLaunchStore = create()((set) => ({ + ...initialState, + setCurrent: (current: string) => + set((state) => ({ + current: current, + })), + addOrRemoveSelectedIntegration: ( + integration: Integrations, + settings: any + ) => { + set((state) => { + const existingIndex = state.selectedIntegrations.findIndex( + (i) => i.integration.id === integration.id + ); + + if (existingIndex > -1) { + return { + selectedIntegrations: state.selectedIntegrations.filter( + (_, index) => index !== existingIndex + ), + }; + } + + return { + selectedIntegrations: [ + ...state.selectedIntegrations, + { integration, settings, ref: createRef() }, + ], + }; + }); + }, + addGlobalValue: (index: number, value: Values[]) => + set((state) => { + if (!state.global.length) { + return { global: value }; + } + + return { + global: state.global.reduce((acc, item, i) => { + console.log(i, index); + acc.push(item); + if (i === index) { + acc.push(...value); + } + return acc; + }, []), + }; + }), + // Add value after index + addInternalValue: (index: number, integrationId: string, value: Values[]) => + set((state) => { + const newInternal = state.internal.map((i) => { + if (i.integration.id === integrationId) { + const newIntegrationValue = [...i.integrationValue]; + newIntegrationValue.splice(index + 1, 0, ...value); + return { ...i, integrationValue: newIntegrationValue }; + } + return i; + }); + return { internal: newInternal }; + }), + deleteGlobalValue: (index: number) => + set((state) => ({ + global: state.global.filter((_, i) => i !== index), + })), + deleteInternalValue: (integrationId: string, index: number) => + set((state) => { + return { + internal: state.internal.map((i) => { + if (i.integration.id === integrationId) { + return { + ...i, + integrationValue: i.integrationValue.filter( + (_, idx) => idx !== index + ), + }; + } + return i; + }), + }; + }), + addRemoveInternal: (integrationId: string) => + set((state) => { + const integration = state.selectedIntegrations.find( + (i) => i.integration.id === integrationId + ); + const findIntegrationIndex = state.internal.findIndex( + (i) => i.integration.id === integrationId + ); + + if (findIntegrationIndex > -1) { + return { + internal: state.internal.filter( + (i) => i.integration.id !== integrationId + ), + }; + } + + return { + internal: [ + ...state.internal, + { + integration: integration.integration, + integrationValue: state.global.slice(0).map((p) => p), + }, + ], + }; + }), + changeOrderGlobal: (fromIndex: number, toIndex: number) => + set((state) => { + const updatedGlobal = [...state.global]; + const [movedItem] = updatedGlobal.splice(fromIndex, 1); + updatedGlobal.splice(toIndex, 0, movedItem); + return { global: updatedGlobal }; + }), + changeOrderInternal: ( + integrationId: string, + fromIndex: number, + toIndex: number + ) => + set((state) => { + const updatedInternal = state.internal.map((i) => { + if (i.integration.id === integrationId) { + const updatedValues = [...i.integrationValue]; + const [movedItem] = updatedValues.splice(fromIndex, 1); + updatedValues.splice(toIndex, 0, movedItem); + return { ...i, integrationValue: updatedValues }; + } + return i; + }); + return { internal: updatedInternal }; + }), + setGlobalValueText: (index: number, content: string) => + set((state) => ({ + global: state.global.map((item, i) => + i === index ? { ...item, content } : item + ), + })), + addGlobalValueMedia: (index: number, media: { id: string; path: string }[]) => + set((state) => ({ + global: state.global.map((item, i) => + i === index ? { ...item, media: [...item.media, ...media] } : item + ), + })), + removeGlobalValueMedia: (index: number, mediaIndex: number) => + set((state) => ({ + global: state.global.map((item, i) => + i === index + ? { + ...item, + media: item.media.filter((_, idx) => idx !== mediaIndex), + } + : item + ), + })), + setInternalValueText: ( + integrationId: string, + index: number, + content: string + ) => + set((state) => ({ + internal: state.internal.map((item) => + item.integration.id === integrationId + ? { + ...item, + integrationValue: item.integrationValue.map((v, i) => + i === index ? { ...v, content } : v + ), + } + : item + ), + })), + addInternalValueMedia: ( + integrationId: string, + index: number, + media: { id: string; path: string }[] + ) => + set((state) => ({ + internal: state.internal.map((item) => + item.integration.id === integrationId + ? { + ...item, + integrationValue: item.integrationValue.map((v, i) => + i === index ? { ...v, media: [...v.media, ...media] } : v + ), + } + : item + ), + })), + removeInternalValueMedia: ( + integrationId: string, + index: number, + mediaIndex: number + ) => + set((state) => ({ + internal: state.internal.map((item) => + item.integration.id === integrationId + ? { + ...item, + integrationValue: item.integrationValue.map((v, i) => + i === index + ? { + ...v, + media: v.media.filter((_, idx) => idx !== mediaIndex), + } + : v + ), + } + : item + ), + })), + reset: () => + set((state) => ({ + ...state, + ...initialState, + })), + setAllIntegrations: (integrations: Integrations[]) => + set((state) => ({ + integrations: integrations, + })), +})); diff --git a/apps/frontend/src/components/new-launch/u.text.tsx b/apps/frontend/src/components/new-launch/u.text.tsx new file mode 100644 index 00000000..b014ecba --- /dev/null +++ b/apps/frontend/src/components/new-launch/u.text.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { FC, useCallback } from 'react'; +import { Editor, Transforms } from 'slate'; +import { ReactEditor } from 'slate-react'; +const underlineMap = { + a: 'a̲', + b: 'b̲', + c: 'c̲', + d: 'd̲', + e: 'e̲', + f: 'f̲', + g: 'g̲', + h: 'h̲', + i: 'i̲', + j: 'j̲', + k: 'k̲', + l: 'l̲', + m: 'm̲', + n: 'n̲', + o: 'o̲', + p: 'p̲', + q: 'q̲', + r: 'r̲', + s: 's̲', + t: 't̲', + u: 'u̲', + v: 'v̲', + w: 'w̲', + x: 'x̲', + y: 'y̲', + z: 'z̲', + A: 'A̲', + B: 'B̲', + C: 'C̲', + D: 'D̲', + E: 'E̲', + F: 'F̲', + G: 'G̲', + H: 'H̲', + I: 'I̲', + J: 'J̲', + K: 'K̲', + L: 'L̲', + M: 'M̲', + N: 'N̲', + O: 'O̲', + P: 'P̲', + Q: 'Q̲', + R: 'R̲', + S: 'S̲', + T: 'T̲', + U: 'U̲', + V: 'V̲', + W: 'W̲', + X: 'X̲', + Y: 'Y̲', + Z: 'Z̲', + '1': '1̲', + '2': '2̲', + '3': '3̲', + '4': '4̲', + '5': '5̲', + '6': '6̲', + '7': '7̲', + '8': '8̲', + '9': '9̲', + '0': '0̲', +}; +const reverseMap = Object.fromEntries( + Object.entries(underlineMap).map(([key, value]) => [value, key]) +); +export const UText: FC<{ + editor: any; + currentValue: string; +}> = ({ editor }) => { + const mark = () => { + const selectedText = Editor.string(editor, editor.selection); + const setUnderline = selectedText.indexOf('̲') === -1; + const newText = Array.from( + !selectedText + ? prompt('What do you want to write?') || '' + : selectedText.replace(/̲/g, '') + ) + .map((char) => { + // @ts-ignore + return ((setUnderline ? underlineMap?.[char] : reverseMap?.[char]) || char); + }) + .join(''); + Transforms.insertText(editor, newText); + ReactEditor.focus(editor); + }; + return ( +
+ + + + + + + + + + + +
+ ); +}; diff --git a/apps/frontend/src/components/signature.tsx b/apps/frontend/src/components/signature.tsx index edf1adfc..27814b4d 100644 --- a/apps/frontend/src/components/signature.tsx +++ b/apps/frontend/src/components/signature.tsx @@ -23,7 +23,7 @@ export const SignatureBox: FC<{ )}
=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -19602,21 +19623,6 @@ snapshots: - typescript - verdaccio - '@nrwl/js@19.7.2(@babel/traverse@7.27.1)(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13))(@types/node@18.16.9)(nx@19.7.2(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13)))(typescript@5.5.4)': - dependencies: - '@nx/js': 19.7.2(@babel/traverse@7.27.1)(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13))(@types/node@18.16.9)(nx@19.7.2(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13)))(typescript@5.5.4) - transitivePeerDependencies: - - '@babel/traverse' - - '@swc-node/register' - - '@swc/core' - - '@swc/wasm' - - '@types/node' - - debug - - nx - - supports-color - - typescript - - verdaccio - '@nrwl/nest@19.7.2(@babel/traverse@7.27.1)(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13))(@types/node@18.16.9)(@zkochan/js-yaml@0.0.7)(babel-plugin-macros@3.1.0)(chokidar@3.5.3)(eslint@8.57.0)(nx@19.7.2(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13)))(ts-node@10.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@types/node@18.16.9)(typescript@5.5.4))(typescript@5.5.4)': dependencies: '@nx/nest': 19.7.2(@babel/traverse@7.27.1)(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13))(@types/node@18.16.9)(@zkochan/js-yaml@0.0.7)(babel-plugin-macros@3.1.0)(chokidar@3.5.3)(eslint@8.57.0)(nx@19.7.2(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13)))(ts-node@10.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@types/node@18.16.9)(typescript@5.5.4))(typescript@5.5.4) @@ -19963,7 +19969,7 @@ snapshots: '@babel/preset-env': 7.27.1(@babel/core@7.27.1) '@babel/preset-typescript': 7.27.1(@babel/core@7.27.1) '@babel/runtime': 7.27.1 - '@nrwl/js': 19.7.2(@babel/traverse@7.27.1)(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13))(@types/node@18.16.9)(nx@19.7.2(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13)))(typescript@5.5.4) + '@nrwl/js': 19.7.2(@babel/traverse@7.27.1)(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13))(@types/node@18.16.9)(nx@19.7.2(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13)))(typescript@5.4.5) '@nx/devkit': 19.7.2(nx@19.7.2(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13))) '@nx/workspace': 19.7.2(@swc-node/register@1.9.2(@swc/core@1.5.7(@swc/helpers@0.5.13))(@swc/types@0.1.7)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.13)) babel-plugin-const-enum: 1.2.0(@babel/core@7.27.1) @@ -35453,4 +35459,11 @@ snapshots: zod@3.24.4: {} + zustand@5.0.5(@types/react@18.3.1)(immer@9.0.21)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1)): + optionalDependencies: + '@types/react': 18.3.1 + immer: 9.0.21 + react: 18.3.1 + use-sync-external-store: 1.5.0(react@18.3.1) + zwitch@2.0.4: {}