') > -1
- ? post.content
- : post.content
- .split('\n')
- .map((line: string) => `
= (props) => {
props.onlyValues?.length
? props.onlyValues.map((p) => ({
content:
- p.content.indexOf('') > -1
+ /
]/i.test(p.content)
? p.content
: p.content
.split('\n')
@@ -214,7 +249,7 @@ export const AddEditModalInnerInner: FC = (props) => {
? props.set.posts[0].value.map((p: any) => ({
id: makeId(10),
content:
- p.content.indexOf('') > -1
+ /
]/i.test(p.content)
? p.content
: p.content
.split('\n')
diff --git a/apps/frontend/src/components/new-launch/add.post.button.tsx b/apps/frontend/src/components/new-launch/add.post.button.tsx
index c43c991e30..837fea7cea 100644
--- a/apps/frontend/src/components/new-launch/add.post.button.tsx
+++ b/apps/frontend/src/components/new-launch/add.post.button.tsx
@@ -16,7 +16,7 @@ export const AddPostButton: FC<{
-
+
{t(
...(props.postComment === PostComment.ALL
? ['add_comment_or_post', 'Add comment or post']
diff --git a/apps/frontend/src/components/new-launch/bold.text.tsx b/apps/frontend/src/components/new-launch/bold.text.tsx
index 76ef1d80f0..f0c7c13bd6 100644
--- a/apps/frontend/src/components/new-launch/bold.text.tsx
+++ b/apps/frontend/src/components/new-launch/bold.text.tsx
@@ -84,12 +84,13 @@ export const BoldText: FC<{
data-tooltip-id="tooltip"
data-tooltip-content="Bold Text"
onClick={mark}
- className="select-none cursor-pointer rounded-[6px] w-[30px] h-[30px] bg-newColColor flex justify-center items-center"
+ className="select-none cursor-pointer rounded-[6px] mobile:rounded-[8px] w-[30px] h-[30px] mobile:w-[40px] mobile:h-[40px] bg-newColColor mobile:bg-newSettings flex justify-center items-center"
>
diff --git a/apps/frontend/src/components/new-launch/bullets.component.tsx b/apps/frontend/src/components/new-launch/bullets.component.tsx
index 24cb334476..2fbf846668 100644
--- a/apps/frontend/src/components/new-launch/bullets.component.tsx
+++ b/apps/frontend/src/components/new-launch/bullets.component.tsx
@@ -14,12 +14,13 @@ export const Bullets: FC<{
data-tooltip-id="tooltip"
data-tooltip-content="Bullets"
onClick={bullet}
- className="select-none cursor-pointer rounded-[6px] w-[30px] h-[30px] bg-newColColor flex justify-center items-center"
+ className="select-none cursor-pointer rounded-[6px] mobile:rounded-[8px] w-[30px] h-[30px] mobile:w-[40px] mobile:h-[40px] bg-newColColor mobile:bg-newSettings flex justify-center items-center"
>
diff --git a/apps/frontend/src/components/new-launch/editor.tsx b/apps/frontend/src/components/new-launch/editor.tsx
index 83214e2a07..75b3e1959f 100644
--- a/apps/frontend/src/components/new-launch/editor.tsx
+++ b/apps/frontend/src/components/new-launch/editor.tsx
@@ -38,6 +38,7 @@ import {
useEditor,
EditorContent,
Extension,
+ Node,
mergeAttributes,
} from '@tiptap/react';
import Document from '@tiptap/extension-document';
@@ -50,12 +51,13 @@ import { History } from '@tiptap/extension-history';
import { BulletList, ListItem } from '@tiptap/extension-list';
import { Bullets } from '@gitroom/frontend/components/new-launch/bullets.component';
import Heading from '@tiptap/extension-heading';
+import { Slice } from '@tiptap/pm/model';
import { HeadingComponent } from '@gitroom/frontend/components/new-launch/heading.component';
import Mention from '@tiptap/extension-mention';
import { suggestion } from '@gitroom/frontend/components/new-launch/mention.component';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { AComponent } from '@gitroom/frontend/components/new-launch/a.component';
-import { Placeholder } from '@tiptap/extensions';
+import { Dropcursor, Placeholder, TrailingNode } from '@tiptap/extensions';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { InformationComponent } from '@gitroom/frontend/components/launches/information.component';
import {
@@ -98,6 +100,48 @@ const InterceptUnderlineShortcut = Extension.create({
},
});
+const EditorImage = Node.create({
+ name: 'image',
+ group: 'block',
+ draggable: true,
+
+ addAttributes() {
+ return {
+ src: { default: null },
+ alt: { default: null },
+ };
+ },
+
+ // same rule as the server sanitizer, any other source is dropped on save
+ parseHTML() {
+ return [{ tag: 'img[src^="https://"], img[src^="http://"]' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['img', HTMLAttributes];
+ },
+});
+
+const insertImages = (
+ editor: any,
+ media: { path: string; alt?: string }[]
+) => {
+ if (!media.length) {
+ return;
+ }
+
+ editor
+ ?.chain()
+ ?.focus()
+ ?.insertContent(
+ media.map((p) => ({
+ type: 'image',
+ attrs: { src: p.path, alt: p.alt },
+ }))
+ )
+ ?.run();
+};
+
export const EditorWrapper: FC<{
totalPosts: number;
value: string;
@@ -130,6 +174,7 @@ export const EditorWrapper: FC<{
postComment,
dummy,
editor,
+ inlineImages,
loadedState,
setLoadedState,
selectedIntegration,
@@ -164,6 +209,7 @@ export const EditorWrapper: FC<{
appendGlobalValueMedia: state.appendGlobalValueMedia,
postComment: state.postComment,
editor: state.editor,
+ inlineImages: state.inlineImages,
loadedState: state.loaded,
setLoadedState: state.setLoaded,
selectedIntegration: state.selectedIntegrations,
@@ -195,6 +241,13 @@ export const EditorWrapper: FC<{
return global;
}, [internal, global]);
+ // phones show one toolbar, for the post that is being edited
+ const [activeIndex, setActiveIndex] = useState(null);
+ const [focusIndex, setFocusIndex] = useState(null);
+ // without comments only the first post is shown
+ const last = comments ? items.length - 1 : 0;
+ const active = canEdit ? Math.min(activeIndex ?? last, last) : -1;
+
const setValue = useCallback(
(value: string[]) => {
const newValue = value.map((p, index) => {
@@ -271,6 +324,7 @@ export const EditorWrapper: FC<{
const changeOrder = useCallback(
(index: number) => (direction: 'up' | 'down') => {
+ setFocusIndex(null);
if (internal) {
changeOrderInternal(current, index, direction);
return setLoaded(false);
@@ -305,6 +359,10 @@ export const EditorWrapper: FC<{
top: document.querySelector('#social-content').scrollHeight,
});
}, 20);
+ setActiveIndex(index + 1);
+ if (window.matchMedia('(max-width: 1025px)').matches) {
+ setFocusIndex(index + 1);
+ }
if (internal) {
return addInternalValue(index, current, [
{
@@ -342,6 +400,8 @@ export const EditorWrapper: FC<{
return;
}
+ setActiveIndex((p) => (p !== null && p > index ? p - 1 : p));
+ setFocusIndex(null);
if (internal) {
deleteInternalValue(current, index);
return setLoaded(false);
@@ -353,6 +413,41 @@ export const EditorWrapper: FC<{
[current, global, internal, t]
);
+ const postButtons = (index: number) => (
+
+
+ {comments && (
+
+ )}
+
+ {!!internal && !existingData?.integration && (
+
+
+
+
+ {t('editing_a_specific_network', 'Editing a Specific Network')}
+
+
+
+
+
+
+
+ {t('back_to_global', 'Back to global')}
+
+
+
+ )}
+
+ );
+
if (!loaded || !loadedState) {
return null;
}
@@ -360,10 +455,11 @@ export const EditorWrapper: FC<{
return (
{isCreateSet && current !== 'global' && (
@@ -373,16 +469,16 @@ export const EditorWrapper: FC<{
-
+
-
+
{t(
'cant_edit_networks_when_creating_set',
"You can't edit networks when creating a set"
)}
-
+
>
)}
{!canEdit && !isCreateSet && (
@@ -398,9 +494,9 @@ export const EditorWrapper: FC<{
-
+
-
+
{t(
'click_to_exit_global_editing',
'Click this button to exit global editing and customize the post for this channel'
@@ -412,30 +508,39 @@ export const EditorWrapper: FC<{
-
+
>
)}
{items.map((g, index) => (
0) || (!comments && index > 0)) && 'hidden'
)}
>
-
+
{index > 0 && (
-
+
)}
{
+ setActiveIndex(index);
+ setFocusIndex(null);
+ }}
comments={comments}
editorType={editor}
+ inlineImages={editor === 'html' && inlineImages && index === 0}
allValues={items}
onChange={changeValue(index)}
key={index}
@@ -453,55 +558,29 @@ export const EditorWrapper: FC<{
selectedIntegration={selectedIntegration}
chars={chars}
childButton={
- <>
- {(canEdit && items.length - 1 === index) || !comments ? (
-
-
- {comments && (
-
- )}
-
- {!!internal && !existingData?.integration && (
-
-
-
-
- {t(
- 'editing_a_specific_network',
- 'Editing a Specific Network'
- )}
-
-
-
-
-
-
-
- {t('back_to_global', 'Back to global')}
-
-
-
- )}
-
- ) : null}
- >
+ (canEdit && items.length - 1 === index) || !comments
+ ? postButtons(index)
+ : null
}
+ toolBarEnd={active === index ? postButtons(index) : undefined}
/>
{comments && (
-
-
+
+
+
+
{items.length > 1 && (
void;
+ toolBarEnd?: React.ReactNode;
editorType?: 'none' | 'normal' | 'markdown' | 'html';
+ inlineImages?: boolean;
totalPosts: number;
value: string;
num?: number;
@@ -546,7 +632,13 @@ export const Editor: FC<{
childButton?: React.ReactNode;
}> = (props) => {
const {
+ flat,
+ active,
+ autoFocus,
+ onActive,
+ toolBarEnd,
editorType = 'normal',
+ inlineImages = false,
allValues,
pictures,
setImages,
@@ -563,10 +655,23 @@ export const Editor: FC<{
const t = useT();
const toaster = useToaster();
const editorRef = useRef(undefined);
+ // the toolbar gets the editor from state, a ref can't be read while rendering
+ const [editorInstance, setEditorInstance] = useState(undefined);
const [loading, setLoading] = useState(false);
+ // the uploader is created once, so it reads the latest value from here
+ const inlineImagesRef = useRef(inlineImages);
+ useEffect(() => {
+ inlineImagesRef.current = inlineImages;
+ }, [inlineImages]);
const uppy = useUppyUploader({
onUploadSuccess: (result: any) => {
+ const editor = editorRef?.current?.editor;
+ if (inlineImagesRef.current && editor) {
+ insertImages(editor, result);
+ return;
+ }
+
appendImages(result);
},
allowedFileTypes: 'image/*,video/mp4',
@@ -574,8 +679,36 @@ export const Editor: FC<{
onEnd: () => setLoading(false),
});
+ // inline images go into the content, which only holds pictures
+ const filterFiles = useCallback(
+ (files: File[]) => {
+ if (!inlineImages) {
+ return files;
+ }
+
+ const images = files.filter((file) => file.type.startsWith('image/'));
+ if (images.length < files.length) {
+ toaster.show(
+ t(
+ 'only_images_can_be_added_to_the_content',
+ 'Only images can be added to the content'
+ ),
+ 'warning'
+ );
+ }
+
+ return images;
+ },
+ [inlineImages, toaster, t]
+ );
+
const onDrop = useCallback(
- (acceptedFiles: File[]) => {
+ (droppedFiles: File[]) => {
+ const acceptedFiles = filterFiles(droppedFiles);
+ if (!acceptedFiles.length) {
+ return;
+ }
+
const totalSize = acceptedFiles.reduce((acc, file) => acc + file.size, 0);
if (totalSize > MAX_UPLOAD_SIZE) {
@@ -595,31 +728,43 @@ export const Editor: FC<{
uppy.addFile(file);
}
},
- [uppy, toaster, t]
+ [uppy, toaster, t, filterFiles]
);
const paste = useCallback(
- async (event: ClipboardEvent | File[]) => {
+ (event: ClipboardEvent | File[], slice?: Slice) => {
if (num > 0 && comments === 'no-media') {
- return;
+ return false;
}
// @ts-ignore
const clipboardItems = event.clipboardData?.items;
if (!clipboardItems) {
- return;
+ return false;
}
- const files: File[] = [];
+ const clipboardFiles: File[] = [];
// @ts-ignore
for (const item of clipboardItems) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
- files.push(file);
+ clipboardFiles.push(file);
}
}
}
+ // a copied picture also comes as html linking to the site it was copied
+ // from, so the file wins unless the html has text of its own
+ // @ts-ignore
+ const html = event.clipboardData.getData('text/html');
+ const uploadInline =
+ !!clipboardFiles.length &&
+ (!html || !slice?.content.textBetween(0, slice.content.size).trim());
+ if (inlineImages && !uploadInline) {
+ return false;
+ }
+
+ const files = filterFiles(clipboardFiles);
const totalSize = files.reduce((acc, file) => acc + file.size, 0);
if (totalSize > MAX_UPLOAD_SIZE) {
@@ -630,7 +775,7 @@ export const Editor: FC<{
),
'warning'
);
- return;
+ return false;
}
if (files.length > 0) {
@@ -640,12 +785,41 @@ export const Editor: FC<{
for (const file of files) {
uppy.addFile(file);
}
+
+ // the uploaded pictures replace the pasted html
+ return inlineImages;
},
- [uppy, num, comments, toaster, t]
+ [uppy, num, comments, toaster, t, inlineImages, filterFiles]
);
+ // a drag that starts on the page (like moving a picture inside the editor)
+ // is not an upload, but Chrome reports a dragged as "Files"
+ const [internalDrag, setInternalDrag] = useState(false);
+ useEffect(() => {
+ if (!inlineImages) {
+ return;
+ }
+
+ const dragStart = (event: DragEvent) => {
+ if (!event.defaultPrevented) {
+ setInternalDrag(true);
+ }
+ };
+ const dragEnd = () => setInternalDrag(false);
+
+ window.addEventListener('dragstart', dragStart);
+ window.addEventListener('dragend', dragEnd);
+ window.addEventListener('drop', dragEnd);
+ return () => {
+ window.removeEventListener('dragstart', dragStart);
+ window.removeEventListener('dragend', dragEnd);
+ window.removeEventListener('drop', dragEnd);
+ setInternalDrag(false);
+ };
+ }, [inlineImages]);
+
const { getRootProps, isDragActive } = useDropzone({
- onDrop: (files) => {
+ onDrop: (files, _, event) => {
if (loading) {
toaster.show(
'Upload current in progress, please wait and then try again.',
@@ -653,9 +827,23 @@ export const Editor: FC<{
);
return;
}
+
+ if (inlineImages) {
+ // uploads land at the cursor, move it to where the files were dropped
+ const editor = editorRef?.current?.editor;
+ const { clientX, clientY } = event as DragEvent;
+ const position = editor?.view?.posAtCoords({
+ left: clientX,
+ top: clientY,
+ });
+ if (position) {
+ editor.commands.focus(position.pos);
+ }
+ }
+
onDrop(files);
},
- noDrag: num > 0 && comments === 'no-media',
+ noDrag: internalDrag || (num > 0 && comments === 'no-media'),
});
const valueWithoutHtml = useMemo(() => {
@@ -670,15 +858,16 @@ export const Editor: FC<{
[props.value, id]
);
- const [loadedEditor, setLoadedEditor] = useState(editorType);
+ // the extensions are fixed once the editor is created, rebuild it when they change
+ const editorKey = `${editorType}-${inlineImages}`;
+ const [loadedEditor, setLoadedEditor] = useState(editorKey);
const [showEditor, setShowEditor] = useState(true);
- useEffect(() => {
- if (editorType === loadedEditor) {
- return;
- }
- setLoadedEditor(editorType);
+ // adjusted while rendering instead of in an effect, so the editor with the
+ // old extensions is never rendered
+ if (editorKey !== loadedEditor) {
+ setLoadedEditor(editorKey);
setShowEditor(false);
- }, [editorType]);
+ }
useEffect(() => {
if (showEditor) {
@@ -689,16 +878,63 @@ export const Editor: FC<{
}, 20);
}, [showEditor]);
+ // phones: a tap on an empty part of the post starts typing in it
+ const focusOnEmpty = useCallback((e: React.MouseEvent) => {
+ const control = (e.target as HTMLElement).closest(
+ '.ProseMirror, .cursor-pointer, .group, .post-toolbar, button, input, aside, img, video'
+ );
+ if (
+ !window.matchMedia('(max-width: 1025px)').matches ||
+ editorRef?.current?.editor?.isFocused ||
+ (control && e.currentTarget.contains(control))
+ ) {
+ return;
+ }
+ editorRef?.current?.editor?.commands?.focus('end');
+ }, []);
+
+ // phones: the toolbar of the edited post sits right above the on-screen keyboard
+ const [keyboard, setKeyboard] = useState(0);
+ useEffect(() => {
+ const viewport = window.visualViewport;
+ if (!flat || !active || !viewport) {
+ return;
+ }
+
+ const update = () => {
+ setKeyboard(
+ Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)
+ );
+ };
+ update();
+ viewport.addEventListener('resize', update);
+ viewport.addEventListener('scroll', update);
+ return () => {
+ viewport.removeEventListener('resize', update);
+ viewport.removeEventListener('scroll', update);
+ };
+ }, [flat, active]);
+
if (!showEditor) {
return null;
}
return (
-
+
0 && '!rounded-bs-[0]'
+ num > 0 && '!rounded-bs-[0]',
+ // the composer has no cards on phones, the text lines up with the page
+ flat && 'mobile:px-0 mobile:pb-0',
+ // the space above a comment belongs to it, so a tap there types in it
+ flat && (num ? 'mobile:pt-[24px]' : 'mobile:pt-0')
)}
id={id}
>
@@ -712,17 +948,32 @@ export const Editor: FC<{
>
{t('drop_files_here_to_upload', 'Drop your files here to upload')}
-
+
{
if (editorRef?.current?.editor?.isFocused) {
return;
@@ -731,7 +982,7 @@ export const Editor: FC<{
}}
/>
-
+
{
if (editorRef?.current?.editor?.isFocused) {
return;
@@ -754,100 +1009,141 @@ export const Editor: FC<{
editorRef?.current?.editor?.commands?.focus('end');
}}
/>
-
- {setImages && (
-
0 && comments === 'no-media'}
- allData={allValues}
- text={valueWithoutHtml}
- label={t('attachments', 'Attachments')}
- description=""
- value={props.pictures}
- dummy={dummy}
- name="image"
- information={
- 0}
- chars={chars}
- totalChars={valueWithoutHtml.length}
- totalAllowedChars={props.totalChars}
- text={valueWithoutHtml}
- />
- }
- toolBar={
-
-
- {editorType !== 'none' && (
- <>
-
-
- >
- )}
- {(editorType === 'markdown' || editorType === 'html') &&
- identifier !== 'telegram' && (
+
+
+ {setImages && (
+
0 && comments === 'no-media'}
+ allData={allValues}
+ text={valueWithoutHtml}
+ label={t('attachments', 'Attachments')}
+ description=""
+ value={props.pictures}
+ dummy={dummy}
+ name="image"
+ insertInContent={
+ inlineImages
+ ? (media) =>
+ insertImages(editorRef?.current?.editor, media)
+ : undefined
+ }
+ information={
+ 0}
+ chars={chars}
+ totalChars={valueWithoutHtml.length}
+ totalAllowedChars={props.totalChars}
+ text={valueWithoutHtml}
+ />
+ }
+ toolBar={
+
+
+ {editorType !== 'none' && (
<>
-
-
-
>
)}
-
setEmojiPickerOpen(!emojiPickerOpen)}
- >
-
-
-
+ {(editorType === 'markdown' || editorType === 'html') &&
+ identifier !== 'telegram' && (
+ <>
+
+
+
+ >
+ )}
1
- ? 'top-[35px]'
- : 'bottom-[35px]'
+ data-tooltip-id="tooltip"
+ data-tooltip-content={t(
+ 'insert_emoji',
+ 'Insert Emoji'
)}
+ className="select-none cursor-pointer rounded-[6px] mobile:rounded-[8px] w-[30px] h-[30px] mobile:w-[40px] mobile:h-[40px] bg-newColColor mobile:bg-newSettings flex justify-center items-center"
+ onClick={() => setEmojiPickerOpen(!emojiPickerOpen)}
>
- {
- addText(e.emoji);
- setEmojiPickerOpen(false);
- }}
- open={emojiPickerOpen}
- />
+
+
+
+ {emojiPickerOpen && (
+
{
+ e.stopPropagation();
+ setEmojiPickerOpen(false);
+ }}
+ />
+ )}
+
1
+ ? 'top-[35px]'
+ : 'bottom-[35px]',
+ // the toolbar scrolls on phones and would cut the picker
+ 'mobile:fixed mobile:inset-x-0 mobile:top-auto mobile:bottom-[72px] mobile:flex mobile:justify-center mobile:pointer-events-none mobile:child:pointer-events-auto'
+ )}
+ >
+ {
+ addText(e.emoji);
+ setEmojiPickerOpen(false);
+ }}
+ open={emojiPickerOpen}
+ />
+
-
- }
- onChange={(value) => {
- setImages(value.target.value);
- }}
- onOpen={() => {}}
- onClose={() => {}}
- />
- )}
+ }
+ toolsClassName={
+ flat
+ ? active
+ ? clsx(
+ 'post-toolbar mobile:fixed mobile:bottom-0 mobile:inset-x-0 mobile:items-center mobile:px-[16px] mobile:py-[12px] mobile:bg-newBgColorInner',
+ // the picker opens from the bar and has to cover the top bar
+ emojiPickerOpen ? 'mobile:z-[500]' : 'mobile:z-[440]'
+ )
+ : 'mobile:hidden'
+ : undefined
+ }
+ toolsStyle={
+ flat && active && keyboard ? { bottom: keyboard } : undefined
+ }
+ toolBarEnd={
+ flat && active ? (
+
{toolBarEnd}
+ ) : undefined
+ }
+ onChange={(value) => {
+ setImages(value.target.value);
+ }}
+ onOpen={() => {}}
+ onClose={() => {}}
+ />
+ )}
+
+ {childButton}
-
{childButton}
@@ -859,11 +1155,23 @@ export const OnlyEditor = forwardRef<
any,
{
editorType: 'none' | 'normal' | 'markdown' | 'html';
+ inlineImages?: boolean;
value: string;
onChange: (value: string) => void;
- paste?: (event: ClipboardEvent | File[]) => void;
+ paste?: (event: ClipboardEvent | File[], slice?: Slice) => boolean;
+ autoFocus?: boolean;
+ onEditor?: (editor: any) => void;
}
->(({ editorType, value, onChange, paste }, ref) => {
+>((props, ref) => {
+ const {
+ editorType,
+ inlineImages,
+ value,
+ onChange,
+ paste,
+ autoFocus,
+ onEditor,
+ } = props;
const t = useT();
const fetch = useFetch();
@@ -906,7 +1214,11 @@ export const OnlyEditor = forwardRef<
const editor = useEditor({
extensions: [
Document,
- Paragraph,
+ Paragraph.configure({
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ }),
Text,
Underline,
Bold,
@@ -1020,6 +1332,24 @@ export const OnlyEditor = forwardRef<
? [
Heading.configure({
levels: [1, 2, 3],
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ }),
+ ]
+ : []),
+ ...(inlineImages
+ ? [
+ EditorImage,
+ Dropcursor.configure({
+ color: 'var(--new-btn-primary)',
+ width: 2,
+ }),
+ // a picture can't hold the cursor, keep a paragraph after a
+ // trailing one so clicking below the content doesn't select it
+ TrailingNode.configure({
+ node: 'paragraph',
+ notAfter: ['heading', 'bulletList'],
}),
]
: []),
@@ -1029,10 +1359,12 @@ export const OnlyEditor = forwardRef<
}),
],
content: value || '',
+ autofocus: autoFocus ? 'end' : false,
shouldRerenderOnTransaction: true,
immediatelyRender: false,
- // @ts-ignore
- onPaste: paste,
+ editorProps: {
+ handlePaste: (view, event, slice) => !!paste?.(event as any, slice),
+ },
onUpdate: (innerProps) => {
onChange?.(innerProps.editor.getHTML());
},
@@ -1042,5 +1374,9 @@ export const OnlyEditor = forwardRef<
editor,
}));
+ useEffect(() => {
+ onEditor?.(editor);
+ }, [editor, onEditor]);
+
return
;
});
diff --git a/apps/frontend/src/components/new-launch/heading.component.tsx b/apps/frontend/src/components/new-launch/heading.component.tsx
index 99ba129cf5..e840598495 100644
--- a/apps/frontend/src/components/new-launch/heading.component.tsx
+++ b/apps/frontend/src/components/new-launch/heading.component.tsx
@@ -13,13 +13,14 @@ export const HeadingComponent: FC<{
};
return (
-
+
-
+
-
+
-
+
= (props) => {
const modal = useModals();
const [showSettings, setShowSettings] = useState(false);
const [mobileTab, setMobileTab] = useState<'edit' | 'preview'>('edit');
+ const [mobileSheet, setMobileSheet] = useState<
+ 'channels' | 'settings' | 'tags' | 'repeat' | 'date' | null
+ >(null);
const [showPostNow, setShowPostNow] = useState(false);
const postNowRef = useClickOutside(() => {
setShowPostNow(false);
@@ -78,12 +103,16 @@ export const ManageModal: FC = (props) => {
current,
activateExitButton,
setHide,
+ channelDates,
+ setChannelDate,
} = useLaunchStore(
useShallow((state) => ({
hide: state.hide,
setHide: state.setHide,
date: state.date,
setDate: state.setDate,
+ channelDates: state.channelDates,
+ setChannelDate: state.setChannelDate,
current: state.current,
repeater: state.repeater,
setRepeater: state.setRepeater,
@@ -96,6 +125,33 @@ export const ManageModal: FC = (props) => {
activateExitButton: state.activateExitButton,
}))
);
+ // the other channels of an existing post show and move only their own date
+ const channelDate = channelDates[current];
+ const pickerDate = channelDate || date;
+ const setPickerDate = useCallback(
+ (newDate: dayjs.Dayjs) => {
+ if (channelDate) {
+ return setChannelDate(current, newDate);
+ }
+
+ setDate(newDate);
+ },
+ [channelDate, current, setChannelDate, setDate]
+ );
+
+ // the date sheet applies its changes only on save
+ const [dateDraft, setDateDraft] = useState(date);
+
+ // the channels the post already has, each with its own post
+ const existingPosts = useMemo(
+ () => [existingData, ...(existingData.siblings || [])],
+ [existingData]
+ );
+ // the channel in view, or the one that was opened
+ const channel = useMemo(
+ () => existingPosts.find((p) => p.integration === current) || existingData,
+ [existingPosts, current, existingData]
+ );
useEffect(() => {
if (hide) {
@@ -107,10 +163,10 @@ export const ManageModal: FC = (props) => {
if (current === 'global') {
return (
-
+
-
Settings
+
{t('settings', 'Settings')}
);
}
@@ -119,7 +175,7 @@ export const ManageModal: FC
= (props) => {
return (
-
+
= (props) => {
const deletePost = useCallback(async () => {
setLoading(true);
+ if (existingData.siblings?.length) {
+ const groups = await new Promise
((resolve) => {
+ modal.openModal({
+ id: 'delete-post-channels',
+ title: t('delete_post', 'Delete Post'),
+ onClose: () => resolve([]),
+ children: (
+
+
+ {t(
+ 'delete_post_from_all_channels_question',
+ 'This post was created for more than one channel. Do you want to delete it from all of them?'
+ )}
+
+
+
+ {
+ modal.closeById('delete-post-channels');
+ resolve(existingPosts.map((p) => p.group!));
+ }}
+ >
+ {t('delete_from_all_channels', 'Delete from all channels')}
+
+
+
+ {
+ modal.closeById('delete-post-channels');
+ resolve([channel.group!]);
+ }}
+ >
+ {t('delete_only_from', 'Only from')}{' '}
+ {
+ integrations.find((p) => p.id === channel.integration)
+ ?.name
+ }
+
+
+
+
+ ),
+ });
+ });
+
+ if (!groups.length) {
+ setLoading(false);
+ return;
+ }
+
+ await Promise.all(
+ groups.map((group) =>
+ fetch(`/posts/${group}`, {
+ method: 'DELETE',
+ })
+ )
+ );
+ mutate();
+ modal.closeAll();
+ return;
+ }
+
if (
!(await deleteDialog(
t(
@@ -194,26 +317,181 @@ export const ManageModal: FC = (props) => {
mutate();
modal.closeAll();
return;
- }, [existingData, mutate, modal]);
+ }, [
+ existingData,
+ existingPosts,
+ channel,
+ fetch,
+ mutate,
+ modal,
+ integrations,
+ t,
+ ]);
const schedule = useCallback(
(type: 'draft' | 'now' | 'schedule' | 'update') => async () => {
let republish = false;
+ // the channels saved as another type, the rest are saved as `type`
+ const saveAs: Record = {};
+ const saveTypeOf = (id: string) => saveAs[id] || type;
+ // the channels that already went out, or are going out right now
+ const published = existingPosts.filter(
+ (p) =>
+ p.posts?.[0]?.state === 'PUBLISHED' ||
+ (p.posts?.[0]?.state === 'QUEUE' &&
+ dayjs().isAfter((channelDates[p.integration] || date).utc()))
+ );
+ const others = existingPosts.filter((p) => p !== channel);
+
+ // another channel whose date already passed would go out right away, it
+ // only gets its details saved
+ if (type === 'schedule') {
+ for (const p of others) {
+ if (
+ !published.includes(p) &&
+ dayjs().isAfter((channelDates[p.integration] || date).utc())
+ ) {
+ saveAs[p.integration] = 'update';
+ }
+ }
+ }
+
+ // a draft of a post with other channels leaves the published ones as they are
+ if (type === 'draft' && existingData.siblings?.length) {
+ for (const p of published) {
+ if (p.posts[0].state === 'PUBLISHED') {
+ saveAs[p.integration] = 'update';
+ }
+ }
+ }
+
+ // the save that leaves a channel in its state
+ const asItIs = (p: (typeof existingPosts)[number]) =>
+ p.posts?.[0]?.state === 'DRAFT'
+ ? 'draft'
+ : p.posts?.[0]?.state === 'QUEUE' && !published.includes(p)
+ ? 'schedule'
+ : 'update';
+
+ // like the delete, a save that changes the state of the other channels
+ // (scheduling their drafts, moving them to drafts) can do it only for
+ // the channel in view, the others keep their state
if (
- (type === 'now' || type === 'schedule') &&
- (existingData?.posts?.[0]?.state === 'PUBLISHED' ||
- (existingData?.posts?.[0]?.state === 'QUEUE' &&
- dayjs().isAfter(date.utc())))
+ type !== 'update' &&
+ others.some(
+ (p) =>
+ !saveAs[p.integration] &&
+ // the republish question covers the published ones
+ (type === 'draft' || !published.includes(p)) &&
+ asItIs(p) !== type
+ )
) {
- const channels = selectedIntegrations
- .map((p) => p.integration.name)
+ const question =
+ type === 'draft'
+ ? t(
+ 'draft_post_in_all_channels_question',
+ 'This post was created for more than one channel. Do you want to move all of them to drafts?'
+ )
+ : type === 'now'
+ ? t(
+ 'post_now_in_all_channels_question',
+ 'This post was created for more than one channel. Do you want to post all of them now?'
+ )
+ : t(
+ 'schedule_post_in_all_channels_question',
+ 'This post was created for more than one channel. Do you want to schedule all of them?'
+ );
+
+ const allChannels =
+ type === 'draft'
+ ? t('draft_all_channels', 'Move all channels to drafts')
+ : type === 'now'
+ ? t('post_now_all_channels', 'Post all channels now')
+ : t('schedule_all_channels', 'Schedule all channels');
+
+ const whichChannels = await new Promise((resolve) => {
+ modal.openModal({
+ id: 'change-post-channels',
+ title: t('what_do_you_want_to_do', 'What do you want to do?'),
+ onClose: () => resolve(undefined),
+ children: (
+
+
{question}
+
+
+ {
+ modal.closeById('change-post-channels');
+ resolve('all');
+ }}
+ >
+ {allChannels}
+
+
+ {/* a published channel in view stays published on a draft */}
+ {!saveAs[channel.integration] && (
+
+ {
+ modal.closeById('change-post-channels');
+ resolve('only');
+ }}
+ >
+ {t('only_channel', 'Only')}{' '}
+ {
+ integrations.find((p) => p.id === channel.integration)
+ ?.name
+ }
+
+
+ )}
+
+
+ ),
+ });
+ });
+
+ if (!whichChannels) {
+ return;
+ }
+
+ if (whichChannels === 'only') {
+ for (const p of others) {
+ saveAs[p.integration] = asItIs(p);
+ }
+ }
+ }
+
+ // the published channels that would go out again
+ const republishing = published.filter((p) => !saveAs[p.integration]);
+
+ if ((type === 'now' || type === 'schedule') && republishing.length) {
+ const channels = republishing
+ .map(
+ (p) =>
+ `${integrations.find((i) => i.id === p.integration)?.name} ${t(
+ 'republish_at',
+ 'at'
+ )} ${(channelDates[p.integration] || date).format(
+ 'DD/MM/YYYY HH:mm'
+ )}`
+ )
.join(', ');
const isRecurring =
!!repeater || !!existingData?.posts?.[0]?.intervalInDays;
+ // it closes on a choice, so a save that then fails its checks shows
+ // the channel to fix
const whatToDo = await new Promise((resolve) => {
modal.openModal({
+ id: 'republish-post',
title: t('what_do_you_want_to_do', 'What do you want to do?'),
+ onClose: () => resolve(undefined),
children: (
@@ -221,8 +499,7 @@ export const ManageModal: FC
= (props) => {
'post_already_published_republish_warning',
'This post was already published. Republishing will publish it again to'
)}{' '}
- {channels} {t('republish_at', 'at')}{' '}
- {date.format('DD/MM/YYYY HH:mm')}.
+ {channels}.
{isRecurring && (
{t(
@@ -237,7 +514,10 @@ export const ManageModal: FC
= (props) => {
resolve('update')}
+ onClick={() => {
+ modal.closeById('republish-post');
+ resolve('update');
+ }}
>
{t(
'just_update_post_details',
@@ -249,7 +529,10 @@ export const ManageModal: FC = (props) => {
resolve('republish')}
+ onClick={() => {
+ modal.closeById('republish-post');
+ resolve('republish');
+ }}
>
{t('republish_the_post', 'Republish the post')}
@@ -260,8 +543,14 @@ export const ManageModal: FC = (props) => {
});
});
+ if (!whatToDo) {
+ return;
+ }
+
if (whatToDo === 'update') {
- type = 'update';
+ for (const p of republishing) {
+ saveAs[p.integration] = 'update';
+ }
}
if (whatToDo === 'republish') {
@@ -285,7 +574,14 @@ export const ManageModal: FC = (props) => {
integration: {
id: post.id,
},
- group,
+ // every channel of an existing post updates its own post
+ group:
+ existingPosts.find((p) => p.integration === post.id)?.group || group,
+ ...(channelDates[post.id]
+ ? {
+ date: channelDates[post.id].utc().format('YYYY-MM-DDTHH:mm:ss'),
+ }
+ : {}),
settings: { ...(post.settings || {}) },
value: post.values.map((value: any) => ({
...(value.id ? { id: value.id } : {}),
@@ -333,47 +629,50 @@ export const ManageModal: FC = (props) => {
return;
}
- if (type !== 'draft') {
- for (const item of checkAllValid) {
- if (item.valid === false) {
- toaster.show(
- `${capitalize(item.identifier.split('-')[0])} (${item.name}): ${
- item.settingsError ||
- t('please_fix_your_settings', 'Please fix your settings')
- }`,
- 'warning'
- );
- focus(item.id, 'fix');
- setLoading(false);
- setShowSettings(true);
- return;
- }
+ // a channel saved as a draft gets these checks once it's scheduled
+ const notDrafts = checkAllValid.filter(
+ (p: any) => saveTypeOf(p.id) !== 'draft'
+ );
- if (item.errors !== true) {
- toaster.show(
- `${capitalize(item.identifier.split('-')[0])} (${item.name}): ${
- item.errors
- }`,
- 'warning'
- );
- focus(item.id, 'preview');
- setLoading(false);
- setShowSettings(false);
- return;
- }
+ for (const item of notDrafts) {
+ if (item.valid === false) {
+ toaster.show(
+ `${capitalize(item.identifier.split('-')[0])} (${item.name}): ${
+ item.settingsError ||
+ t('please_fix_your_settings', 'Please fix your settings')
+ }`,
+ 'warning'
+ );
+ focus(item.id, 'fix');
+ setLoading(false);
+ setShowSettings(true);
+ return;
+ }
- if (item.tooLong) {
- toaster.show(
- `${item.name} (${item.identifier}) ${t(
- 'post_is_too_long',
- 'post is too long, please fix it'
- )}`,
- 'warning'
- );
- focus(item.id, 'preview');
- setLoading(false);
- return;
- }
+ if (item.errors !== true) {
+ toaster.show(
+ `${capitalize(item.identifier.split('-')[0])} (${item.name}): ${
+ item.errors
+ }`,
+ 'warning'
+ );
+ focus(item.id, 'preview');
+ setLoading(false);
+ setShowSettings(false);
+ return;
+ }
+
+ if (item.tooLong) {
+ toaster.show(
+ `${item.name} (${item.identifier}) ${t(
+ 'post_is_too_long',
+ 'post is too long, please fix it'
+ )}`,
+ 'warning'
+ );
+ focus(item.id, 'preview');
+ setLoading(false);
+ return;
}
}
}
@@ -446,23 +745,43 @@ export const ManageModal: FC = (props) => {
if (addEditSets) {
addEditSets(data);
} else {
- const response = await fetch('/posts', {
- method: 'POST',
- body: JSON.stringify(data),
- });
+ // every type of save is its own request, the drafts last, since a
+ // draft save skips the checks the others still run
+ let saved = false;
+ for (const requestType of ['update', 'schedule', 'now', 'draft']) {
+ const request = {
+ type: requestType,
+ posts: posts.filter(
+ (p: any) => saveTypeOf(p.integration.id) === requestType
+ ),
+ };
+ if (!request.posts.length) {
+ continue;
+ }
- if (!response.ok) {
- if (response.status !== 402) {
- const { message } = await response.json().catch(() => ({}));
- toaster.show(
- typeof message === 'string'
- ? message
- : t('post_save_failed', 'Could not save the post'),
- 'warning'
- );
+ const response = await fetch('/posts', {
+ method: 'POST',
+ body: JSON.stringify({ ...data, ...request }),
+ });
+
+ if (!response.ok) {
+ if (response.status !== 402) {
+ const { message } = await response.json().catch(() => ({}));
+ toaster.show(
+ typeof message === 'string'
+ ? message
+ : t('post_save_failed', 'Could not save the post'),
+ 'warning'
+ );
+ }
+ // the channels saved before the failure show in the calendar
+ if (saved) {
+ mutate();
+ }
+ setLoading(false);
+ return;
}
- setLoading(false);
- return;
+ saved = true;
}
}
@@ -485,130 +804,293 @@ export const ManageModal: FC = (props) => {
}
}
},
- [ref, repeater, tags, date, addEditSets, dummy, shortlinkPreferenceData]
+ [
+ ref,
+ repeater,
+ tags,
+ date,
+ channelDates,
+ integrations,
+ selectedIntegrations,
+ addEditSets,
+ dummy,
+ shortlinkPreferenceData,
+ existingData,
+ existingPosts,
+ channel,
+ customClose,
+ fetch,
+ modal,
+ mutate,
+ t,
+ toaster,
+ ]
+ );
+
+ const scheduleLabel = dummy
+ ? t('create_output', 'Create output')
+ : !existingData?.integration
+ ? t('add_to_calendar', 'Add to calendar')
+ : existingData?.posts?.[0]?.state === 'DRAFT'
+ ? t('schedule', 'Schedule')
+ : t('update', 'Update');
+
+ const mobileActions: MobileTopBarAction[] = addEditSets
+ ? [
+ {
+ label: t('save_set', 'Save Set'),
+ variant: 'primary',
+ onClick: schedule('draft'),
+ },
+ ]
+ : [
+ {
+ label: scheduleLabel,
+ variant: 'primary',
+ onClick: schedule('schedule'),
+ },
+ ...(!dummy
+ ? [
+ {
+ label: t('post_now', 'Post Now'),
+ variant: 'secondary' as const,
+ onClick: schedule('now'),
+ },
+ ]
+ : []),
+ {
+ label: t('save_as_draft', 'Save as Draft'),
+ variant: 'tertiary',
+ onClick: schedule('draft'),
+ },
+ ];
+
+ // the settings sheet links to the channel settings only when there are any
+ const hasChannelSettings = useMemo(() => {
+ const identifiers =
+ current === 'global'
+ ? selectedIntegrations.map((p) => p.integration.identifier)
+ : [integrations.find((p) => p.id === current)?.identifier];
+
+ return identifiers.some(
+ (identifier) =>
+ !!getProviderSettingsMeta(
+ Providers.find((p) => p.identifier === identifier)?.component
+ )?.SettingsComponent
+ );
+ }, [current, selectedIntegrations, integrations]);
+
+ // on phones, the settings, tags, repeat and delete live in a settings sheet
+ const settingsButton = !dummy && (
+ setMobileSheet('settings')}
+ className="hidden mobile:flex shrink-0 w-[32px] h-[44px] justify-end items-center cursor-pointer text-[#A3A3A3]"
+ >
+
+
+ );
+
+ // phones have no room for the preview column, it replaces the editor instead
+ const previewButton = (
+ setMobileTab(mobileTab === 'preview' ? 'edit' : 'preview')}
+ className={clsx(
+ 'hidden mobile:flex shrink-0 w-[32px] h-[44px] justify-end items-center cursor-pointer',
+ mobileTab === 'preview' ? 'text-[#FC69FF]' : 'text-[#A3A3A3]'
+ )}
+ >
+
+
);
return (
+
+ {
+ setDateDraft(pickerDate);
+ setMobileSheet('date');
+ }}
+ />
+
-
-
+
+
{t('create_post_title', 'Create Post')}
-
-
-
-
-
- {(['edit', 'preview'] as const).map((tab) => (
-
setMobileTab(tab)}
- className={clsx(
- 'flex-1 pt-[6px] pb-[5px] cursor-pointer text-center rounded-[6px]',
- mobileTab === tab && 'text-textItemFocused bg-boxFocused'
- )}
- >
- {tab === 'edit'
- ? t('edit', 'Edit')
- : t('post_preview', 'Post Preview')}
-
- ))}
-
+
-
+
-
- {!dummy && (
+
+ {/* an existing post keeps its channels */}
+ {!dummy && !existingData.integration && (
)}
+ {!!existingData.integration && (
+ <>
+
+
+
+ {previewButton}
+ {settingsButton}
+ >
+ )}
-
-
{!existingData.integration && }
-
+
+
+
+ {(!existingData.integration ||
+ !!existingData.siblings?.length) && }
+
+ {!existingData.integration && (
+ <>
+
setMobileSheet('channels')}
+ className="hidden mobile:flex shrink-0 w-[44px] h-[44px] rounded-[8px] bg-btnSimple justify-center items-center cursor-pointer"
+ >
+
+
+
+ {previewButton}
+ {settingsButton}
+ >
+ )}
+
+
{!hide && }
+ {/* on phones the settings open as a bottom sheet */}
-
+ {showSettings && (
setShowSettings(!showSettings)}
- className={clsx(
- 'bg-[#612BD3] rounded-[12px] flex items-center gap-[8px] cursor-pointer p-[12px]',
- showSettings ? '!rounded-b-none' : ''
- )}
- >
-
- {currentIntegrationText}
-
-
-
setShowSettings(false)}
+ className="hidden mobile:block fixed inset-0 z-[599] bg-popup backdrop-blur-[8px] animate-fadeIn touch-none"
+ />
+ )}
+
+
+
+ setShowSettings(false)}
/>
-
-
-
-
setShowSettings(!showSettings)}
+ className={clsx(
+ 'bg-[#612BD3] rounded-[12px] flex items-center gap-[8px] cursor-pointer p-[12px] mobile:hidden',
+ showSettings ? '!rounded-b-none' : ''
+ )}
+ >
+
+ {currentIntegrationText}
+
+
+
+
+
+
+
+ setShowSettings(false)}
/>
+
-
@@ -621,21 +1103,17 @@ export const ManageModal: FC
= (props) => {
-
-
- {!dummy && (
+
+
+ {/* keep a single tags component mounted, the tags sheet has its own */}
+ {!dummy && mobileSheet !== 'tags' && (
= (props) => {
)}
-
+
{existingData?.integration && (
= (props) => {
{t('delete_post', 'Delete Post')}
)}
-
+
{!addEditSets && (
= (props) => {
)}
{addEditSets && (
= (props) => {
)}
{!addEditSets && (
-
+
{loading && (
@@ -711,19 +1186,13 @@ export const ManageModal: FC
= (props) => {
)}
{selectedIntegrations.length === 0
? t('check_circles_above', 'Check the circles above')
- : dummy
- ? t('create_output', 'Create output')
- : !existingData?.integration
- ? t('add_to_calendar', 'Add to calendar')
- : existingData?.posts?.[0]?.state === 'DRAFT'
- ? t('schedule', 'Schedule')
- : t('update', 'Update')}
+ : scheduleLabel}
{!dummy && (
= (props) => {
e.stopPropagation();
setShowPostNow(!showPostNow);
}}
- className="flex justify-center items-center h-[20px] w-[20px] pt-[4px] mobile:pt-0 mobile:h-[44px] mobile:w-[44px] mobile:-me-[16px] mobile:border-s mobile:border-white/20 arrow-change"
+ className="flex justify-center items-center h-[20px] w-[20px] pt-[4px] arrow-change"
>
= (props) => {
selectedIntegrations.length === 0 || loading || locked
}
className={clsx(
- 'rounded-[8px] z-[300] disabled:cursor-not-allowed disabled:opacity-80 absolute bottom-[100%] -left-[12px] mobile:left-0 p-[12px] mobile:px-0 w-[206px] mobile:w-full bg-newBgColorInner',
+ 'rounded-[8px] z-[300] disabled:cursor-not-allowed disabled:opacity-80 absolute bottom-[100%] -left-[12px] p-[12px] w-[206px] bg-newBgColorInner',
showPostNow ? 'flex' : 'hidden group-hover:flex'
)}
>
@@ -764,8 +1233,131 @@ export const ManageModal: FC = (props) => {
+ {mobileSheet === 'channels' && (
+
setMobileSheet(null)}
+ button={{
+ label: t('done', 'Done'),
+ onClick: () => setMobileSheet(null),
+ disabled: selectedIntegrations.length === 0,
+ }}
+ >
+ {!dummy && (
+
+
+
+ )}
+
+
+ )}
+ {mobileSheet === 'settings' && (
+
setMobileSheet(null)}
+ >
+
+ {hasChannelSettings && (
+
}
+ label={
+ current === 'global'
+ ? t('channels_settings', 'Channel Settings')
+ : `${integrations.find((p) => p.id === current)?.name} ${t(
+ 'channel_settings',
+ 'Settings'
+ )}`
+ }
+ onClick={() => {
+ setMobileSheet(null);
+ setShowSettings(true);
+ }}
+ />
+ )}
+
}
+ label={t('add_tag', 'Add Tag')}
+ onClick={() => setMobileSheet('tags')}
+ />
+
}
+ label={t('repeat_post', 'Repeat Post')}
+ onClick={() => setMobileSheet('repeat')}
+ />
+ {existingData?.integration && (
+
+
+
+ {t('delete_post', 'Delete Post')}
+
+
+ )}
+
+
+ )}
+ {mobileSheet === 'tags' && (
+
setMobileSheet(null)}
+ button={{
+ label: t('done', 'Done'),
+ onClick: () => setMobileSheet(null),
+ }}
+ >
+ {
+ setTags(e.target.value);
+ }}
+ list={true}
+ />
+
+ )}
+ {mobileSheet === 'date' && (
+
setMobileSheet(null)}
+ button={{
+ label: t('save', 'Save'),
+ onClick: () => {
+ setPickerDate(dateDraft);
+ setMobileSheet(null);
+ },
+ }}
+ >
+
+
+ )}
+ {mobileSheet === 'repeat' && (
+
setMobileSheet(null)}
+ button={{
+ label: t('done', 'Done'),
+ onClick: () => setMobileSheet(null),
+ }}
+ >
+
+
+ )}
void;
+}
+
+const actionColors = {
+ primary: 'bg-btnPrimary text-white',
+ secondary: 'bg-[#D82D7E] text-white',
+ tertiary: 'bg-btnSimple text-btnText',
+};
+
+// the composer header on phones, a single action is triggered directly,
+// more than one opens a menu
+export const MobileTopBar: FC<{
+ onBack: () => void;
+ actions: MobileTopBarAction[];
+ disabled: boolean;
+ loading: boolean;
+ children: ReactNode;
+}> = ({ onBack, actions, disabled, loading, children }) => {
+ const t = useT();
+ const [open, setOpen] = useState(false);
+ const ref = useClickOutside(() => setOpen(false));
+ const single = actions.length === 1;
+
+ return (
+
+
+
+
+
{children}
+
+
(single ? actions[0].onClick() : setOpen(!open))}
+ className={clsx(
+ 'relative min-w-[92px] h-[34px] rounded-[6px] flex justify-center items-center gap-[8px] text-[13px] font-[600] bg-btnPrimary text-white disabled:bg-btnSimple disabled:text-textItemBlur disabled:cursor-not-allowed',
+ single ? 'px-[16px]' : 'ps-[24px] pe-[12px]'
+ )}
+ >
+ {loading && (
+
+ )}
+
+ {single ? actions[0].label : t('post', 'Post')}
+
+ {!single && (
+
+ )}
+
+ {open && (
+
+ {actions.map((action, index) => (
+ {
+ setOpen(false);
+ action.onClick();
+ }}
+ className={clsx(
+ 'h-[34px] rounded-[6px] text-[13px] font-[600]',
+ actionColors[action.variant]
+ )}
+ >
+ {action.label}
+
+ ))}
+
+ )}
+
+
+ );
+};
diff --git a/apps/frontend/src/components/new-launch/picks.socials.component.tsx b/apps/frontend/src/components/new-launch/picks.socials.component.tsx
index 116395ca91..a8404d5799 100644
--- a/apps/frontend/src/components/new-launch/picks.socials.component.tsx
+++ b/apps/frontend/src/components/new-launch/picks.socials.component.tsx
@@ -6,12 +6,63 @@ import SafeImage from '@gitroom/react/helpers/safe.image';
import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store';
import { useShallow } from 'zustand/react/shallow';
import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback';
+import { Check } from '@gitroom/frontend/components/launches/tags.component';
+import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
-export const PicksSocialsComponent: FC<{ toolTip?: boolean }> = ({
- toolTip,
-}) => {
+const PlatformBadge: FC<{ integration: Integrations }> = ({ integration }) => {
+ return integration.identifier === 'youtube' ? (
+
+ ) : (
+
+ );
+};
+
+// a channel row with a checkbox, for the phone sheets
+export const IntegrationListRow: FC<{
+ integration: Integrations;
+ selected: boolean;
+ onClick: () => void;
+}> = ({ integration, selected, onClick }) => {
+ return (
+
+
+
+ {integration.name}
+
+
{}} value={selected} />
+
+ );
+};
+
+export const PicksSocialsComponent: FC<{
+ toolTip?: boolean;
+ list?: boolean;
+}> = ({ toolTip, list }) => {
const exising = useExistingData();
const {
@@ -28,76 +79,89 @@ export const PicksSocialsComponent: FC<{ toolTip?: boolean }> = ({
}))
);
+ const availableIntegrations = integrations.filter((f) => {
+ if (exising.integration) {
+ return (
+ f.id === exising.integration ||
+ !!exising.siblings?.some((sibling) => sibling.integration === f.id)
+ );
+ }
+ return !f.inBetweenSteps && !f.disabled;
+ });
+
+ const toggleIntegration = (integration: Integrations) => () => {
+ if (exising.integration) {
+ return;
+ }
+ addOrRemoveSelectedIntegration(integration, {});
+ };
+
+ if (list) {
+ return (
+
+ {availableIntegrations.map((integration) => (
+ p.integration.id === integration.id
+ )}
+ onClick={toggleIntegration(integration)}
+ />
+ ))}
+
+ );
+ }
+
return (
- {integrations
- .filter((f) => {
- if (exising.integration) {
- return f.id === exising.integration;
- }
- return !f.inBetweenSteps && !f.disabled;
- })
- .map((integration) => (
+ {availableIntegrations.map((integration) => (
+
p.integration.id === integration.id
+ ) === -1
+ ? 'grayscale border-transparent'
+ : 'border-[#622FF6]'
+ )}
>
-
{
- if (exising.integration) {
- return;
- }
- addOrRemoveSelectedIntegration(integration, {});
- }}
+
p.integration.id === integration.id
) === -1
- ? 'grayscale border-transparent'
- : 'border-[#622FF6]'
- )}
- >
- p.integration.id === integration.id
- ) === -1
- ? 'border-transparent'
- : 'border-[#000]'
- )}
- alt={integration.identifier}
- width={42}
- height={42}
- />
- {integration.identifier === 'youtube' ? (
-
- ) : (
-
+ ? 'border-transparent'
+ : 'border-[#000]'
)}
-
+ alt={integration.identifier}
+ width={42}
+ height={42}
+ />
+
- ))}
+
+ ))}
diff --git a/apps/frontend/src/components/new-launch/providers/facebook/facebook.preview.tsx b/apps/frontend/src/components/new-launch/providers/facebook/facebook.preview.tsx
index bdd8a59a95..65e7fd6f86 100644
--- a/apps/frontend/src/components/new-launch/providers/facebook/facebook.preview.tsx
+++ b/apps/frontend/src/components/new-launch/providers/facebook/facebook.preview.tsx
@@ -153,6 +153,7 @@ export const FacebookPreview: FC<{
{background ? (
) : (
(params: {
}>;
dto?: any;
maximumCharacters?: number | ((settings: any) => number);
+ inlineImages?: boolean | ((values: any) => boolean);
}) {
const {
postComment,
@@ -75,13 +76,14 @@ export const withProvider = function
(params: {
allIntegrations,
setPostComment,
setEditor,
+ setInlineImages,
dummy,
setChars,
setComments,
setHide,
} = useLaunchStore(
useShallow((state) => ({
- date: state.date,
+ date: state.channelDates[props.id] || state.date,
tab: state.tab,
global: state.global,
dummy: state.dummy,
@@ -97,6 +99,7 @@ export const withProvider = function (params: {
setTotalChars: state.setTotalChars,
setPostComment: state.setPostComment,
setEditor: state.setEditor,
+ setInlineImages: state.setInlineImages,
setChars: state.setChars,
selectedIntegration: state.selectedIntegrations.find(
(p) => p.integration.id === props.id
@@ -125,6 +128,7 @@ export const withProvider = function (params: {
setPostComment(PostComment.ALL);
setTotalChars(0);
setEditor('normal');
+ setInlineImages(false);
}
if (current) {
@@ -178,6 +182,21 @@ export const withProvider = function (params: {
reValidateMode: 'onChange',
});
+ // the post settings can turn inline images on, like an X article
+ const values = useWatch({
+ control: form.control,
+ disabled: typeof params.inlineImages !== 'function',
+ });
+ const inlineImages =
+ typeof params.inlineImages === 'function'
+ ? params.inlineImages(values)
+ : !!params.inlineImages;
+ useEffect(() => {
+ if (current) {
+ setInlineImages(inlineImages);
+ }
+ }, [current, inlineImages, setInlineImages]);
+
useImperativeHandle(
ref,
() => ({
@@ -199,14 +218,6 @@ export const withProvider = function (params: {
selectedIntegration.integration.additionalSettings || '[]'
)
),
- fix: () => {
- setCurrent(props.id);
- setHide(true);
- },
- preview: () => {
- setCurrent(props.id);
- setHide(true);
- },
};
},
getValues: () => {
@@ -220,6 +231,17 @@ export const withProvider = function (params: {
trigger: () => {
return form.trigger();
},
+ // a save that failed on this channel brings it into view, with its
+ // settings showing what is wrong
+ fix: () => {
+ setCurrent(props.id);
+ setHide(true);
+ form.trigger();
+ },
+ preview: () => {
+ setCurrent(props.id);
+ setHide(true);
+ },
}),
[value]
);
@@ -288,7 +310,7 @@ export const withProvider = function (params: {
))}
{(SettingsComponent || !!data?.internalPlugs?.length) &&
createPortal(
-
+
{isGlobal && (
)}
diff --git a/apps/frontend/src/components/new-launch/providers/instagram/instagram.preview.tsx b/apps/frontend/src/components/new-launch/providers/instagram/instagram.preview.tsx
index b14cc199de..68e0d48ece 100644
--- a/apps/frontend/src/components/new-launch/providers/instagram/instagram.preview.tsx
+++ b/apps/frontend/src/components/new-launch/providers/instagram/instagram.preview.tsx
@@ -34,6 +34,7 @@ export const InstagramPreview: FC<{
const finalValue =
`
${integration?.name} ` +
+ `
` +
newContent
.slice(start, end)
.replace(/\[\[\[([.\s\S]*?)]]]/, (match, match1) => {
@@ -43,7 +44,7 @@ export const InstagramPreview: FC<{
newContent.slice(end).replace(/\[\[\[([.\s\S]*?)]]]/, (match, match1) => {
return `${match1} `;
}) +
- ``;
+ ` `;
return { text: finalValue, images: p.image };
});
@@ -82,6 +83,7 @@ export const InstagramPreview: FC<{
/>
)}
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
index e09cae29d5..946fd78376 100644
--- a/apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx
+++ b/apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx
@@ -37,6 +37,7 @@ const RenderRedditComponent: FC<{
case 'self':
return (
{
{integration?.name}
diff --git a/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.provider.tsx b/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.provider.tsx
index 7876038fc2..7ad52173ed 100644
--- a/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.provider.tsx
+++ b/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.provider.tsx
@@ -50,4 +50,5 @@ export default withProvider({
CustomPreviewComponent: undefined, // WordpressPreview,
dto: WordpressDto,
maximumCharacters: 100000,
+ inlineImages: true,
});
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
index 11625b4319..e98bd0c1cd 100644
--- a/apps/frontend/src/components/new-launch/providers/x/x.provider.tsx
+++ b/apps/frontend/src/components/new-launch/providers/x/x.provider.tsx
@@ -145,4 +145,5 @@ export default withProvider({
}
return 280;
},
+ inlineImages: (values) => values?.post_type === 'article',
});
diff --git a/apps/frontend/src/components/new-launch/providers/youtube/youtube.preview.tsx b/apps/frontend/src/components/new-launch/providers/youtube/youtube.preview.tsx
index c942b46821..40e093df9c 100644
--- a/apps/frontend/src/components/new-launch/providers/youtube/youtube.preview.tsx
+++ b/apps/frontend/src/components/new-launch/providers/youtube/youtube.preview.tsx
@@ -141,6 +141,7 @@ export const YoutubePreview: FC<{
diff --git a/apps/frontend/src/components/new-launch/select.current.tsx b/apps/frontend/src/components/new-launch/select.current.tsx
index e94b5f6209..de1b857dca 100644
--- a/apps/frontend/src/components/new-launch/select.current.tsx
+++ b/apps/frontend/src/components/new-launch/select.current.tsx
@@ -11,6 +11,7 @@ import { useShallow } from 'zustand/react/shallow';
import { GlobalIcon } from '@gitroom/frontend/components/ui/icons';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
+import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
import {
useDecisionModal,
useModals,
@@ -52,6 +53,7 @@ export function useHasScroll(ref: RefObject
): boolean {
export const SelectCurrent: FC = () => {
const modals = useDecisionModal();
+ const existingData = useExistingData();
const {
selectedIntegrations,
current,
@@ -94,30 +96,33 @@ export const SelectCurrent: FC = () => {
return (
<>
-
+
-
{
- setHide(true);
- setCurrent('global');
- }}
- className={clsx(
- 'cursor-pointer flex gap-[8px] rounded-[8px] w-[40px] h-[40px] justify-center items-center bg-newBgLineColor',
- current !== 'global'
- ? 'text-[#A3A3A3]'
- : 'border border-[#FC69FF] text-[#FC69FF]'
- )}
- >
-
-
+ {/* an existing post opens on its channels, without the global mode */}
+ {!existingData.integration && (
+
{
+ setHide(true);
+ setCurrent('global');
+ }}
+ className={clsx(
+ 'cursor-pointer flex gap-[8px] rounded-[8px] w-[40px] h-[40px] mobile:w-[44px] mobile:h-[44px] mobile:shrink-0 justify-center items-center bg-newBgLineColor mobile:bg-newSettings',
+ current !== 'global'
+ ? 'text-[#A3A3A3]'
+ : 'border mobile:border-[1.5px] border-[#FC69FF] text-[#FC69FF]'
+ )}
+ >
+
+
+
-
+ )}
{selectedIntegrations.map(({ integration }) => (
{
@@ -126,19 +131,24 @@ export const SelectCurrent: FC = () => {
}}
key={integration.id}
className={clsx(
- 'border cursor-pointer relative flex gap-[8px] w-[40px] h-[40px] rounded-[8px] items-center bg-newBgLineColor justify-center',
+ 'border mobile:border-[1.5px] cursor-pointer relative flex gap-[8px] w-[40px] h-[40px] mobile:w-[44px] mobile:h-[44px] mobile:shrink-0 rounded-[8px] items-center bg-newBgLineColor mobile:bg-newSettings justify-center',
current === integration.id
? 'border-[#FC69FF] text-[#FC69FF]'
: 'border-transparent'
)}
>
-
- X
-
-
+ {/* an existing post keeps its channels, each with its own content */}
+ {!existingData.integration && (
+ <>
+
+ X
+
+
+ >
+ )}
{
))}
-
+
>
);
};
diff --git a/apps/frontend/src/components/new-launch/store.ts b/apps/frontend/src/components/new-launch/store.ts
index 2ea620b826..7063507193 100644
--- a/apps/frontend/src/components/new-launch/store.ts
+++ b/apps/frontend/src/components/new-launch/store.ts
@@ -27,8 +27,11 @@ export interface SelectedIntegrations {
interface StoreState {
editor: undefined | 'none' | 'normal' | 'markdown' | 'html';
+ inlineImages: boolean;
loaded: boolean;
date: dayjs.Dayjs;
+ // the other channels of an existing post, each with its own date
+ channelDates: Record
;
postComment: PostComment;
dummy: boolean;
repeater?: number;
@@ -112,6 +115,7 @@ interface StoreState {
setTab: (tab: 0 | 1) => void;
setHide: (hide: boolean) => void;
setDate: (date: dayjs.Dayjs) => void;
+ setChannelDate: (integrationId: string, date: dayjs.Dayjs) => void;
setRepeater: (repeater: number) => void;
setTags: (tags: { label: string; value: string }[]) => void;
setIsCreateSet: (isCreateSet: boolean) => void;
@@ -129,6 +133,7 @@ interface StoreState {
setActivateExitButton?: (activateExitButton: boolean) => void;
setDummy: (dummy: boolean) => void;
setEditor: (editor: 'none' | 'normal' | 'markdown' | 'html') => void;
+ setInlineImages: (inlineImages: boolean) => void;
setLoaded?: (loaded: boolean) => void;
setChars: (id: string, chars: number) => void;
chars: Record;
@@ -137,11 +142,13 @@ interface StoreState {
const initialState = {
editor: undefined as undefined,
+ inlineImages: false,
loaded: true,
dummy: false,
comments: true,
activateExitButton: true,
date: newDayjs(),
+ channelDates: {} as Record,
postComment: PostComment.ALL,
tags: [] as { label: string; value: string }[],
totalChars: 0,
@@ -187,6 +194,7 @@ export const useLaunchStore = create()((set) => ({
? {
current: 'global',
editor: 'normal',
+ inlineImages: false,
}
: {}),
};
@@ -533,6 +541,13 @@ export const useLaunchStore = create()((set) => ({
set((state) => ({
date,
})),
+ setChannelDate: (integrationId: string, date: dayjs.Dayjs) =>
+ set((state) => ({
+ channelDates: {
+ ...state.channelDates,
+ [integrationId]: date,
+ },
+ })),
setRepeater: (repeater: number) =>
set((state) => ({
repeater,
@@ -617,6 +632,10 @@ export const useLaunchStore = create()((set) => ({
set((state) => ({
editor,
})),
+ setInlineImages: (inlineImages: boolean) =>
+ set((state) => ({
+ inlineImages,
+ })),
setLoaded: (loaded: boolean) =>
set((state) => ({
loaded,
diff --git a/apps/frontend/src/components/new-launch/u.text.tsx b/apps/frontend/src/components/new-launch/u.text.tsx
index a03d392b97..68b266fafb 100644
--- a/apps/frontend/src/components/new-launch/u.text.tsx
+++ b/apps/frontend/src/components/new-launch/u.text.tsx
@@ -84,12 +84,13 @@ export const UText: FC<{
data-tooltip-id="tooltip"
data-tooltip-content="Underline"
onClick={mark}
- className="select-none cursor-pointer rounded-[6px] w-[30px] h-[30px] bg-newColColor flex justify-center items-center"
+ className="select-none cursor-pointer rounded-[6px] mobile:rounded-[8px] w-[30px] h-[30px] mobile:w-[40px] mobile:h-[40px] bg-newColColor mobile:bg-newSettings flex justify-center items-center"
>
diff --git a/apps/frontend/src/components/new-layout/layout.component.tsx b/apps/frontend/src/components/new-layout/layout.component.tsx
index 091cd9fae3..d2502da640 100644
--- a/apps/frontend/src/components/new-layout/layout.component.tsx
+++ b/apps/frontend/src/components/new-layout/layout.component.tsx
@@ -131,12 +131,14 @@ export const LayoutComponent = ({ children }: { children: ReactNode }) => {
diff --git a/apps/frontend/src/components/onboarding/onboarding.modal.tsx b/apps/frontend/src/components/onboarding/onboarding.modal.tsx
index ae20e4836a..4bc1084a93 100644
--- a/apps/frontend/src/components/onboarding/onboarding.modal.tsx
+++ b/apps/frontend/src/components/onboarding/onboarding.modal.tsx
@@ -641,7 +641,7 @@ const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({
{t(
'agent_access_unavailable',
- 'Agent access is not available for your current plan or role. You can set it up later under Settings > Developers.'
+ 'Agent access is not available for your current plan or role. You can set it up later under Settings > Agents.'
)}
)}
@@ -672,7 +672,7 @@ const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({
{t(
'agent_settings_later',
- 'More agents and full instructions are available under Settings > Developers'
+ 'More agents and full instructions are available under Settings > Agents'
)}
{
{(integration.inBetweenSteps ||
integration.refreshNeeded) && (
-
+
!
diff --git a/apps/frontend/src/components/plugs/plugs.tsx b/apps/frontend/src/components/plugs/plugs.tsx
index e27a57df7b..1283f7a3e3 100644
--- a/apps/frontend/src/components/plugs/plugs.tsx
+++ b/apps/frontend/src/components/plugs/plugs.tsx
@@ -14,8 +14,6 @@ import { useToaster } from '@gitroom/react/toaster/toaster';
import { PlugsContext } from '@gitroom/frontend/components/plugs/plugs.context';
import { Plug } from '@gitroom/frontend/components/plugs/plug';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
-import useCookie from 'react-use-cookie';
-import { SVGLine } from '@gitroom/frontend/components/launches/launches.component';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
export const Plugs = () => {
const fetch = useFetch();
@@ -52,8 +50,6 @@ export const Plugs = () => {
fallbackData: [],
});
- const [collapseMenu, setCollapseMenu] = useCookie('collapseMenu', '0');
-
const t = useT();
const sortedIntegrations = useMemo(() => {
@@ -86,73 +82,40 @@ export const Plugs = () => {
if (isLoading || plugLoading) {
return (
-
+
);
}
- if (!sortedIntegrations.length && !isLoading) {
- return (
-
-
-
-
-
- {t(
- 'there_are_not_plugs_matching_your_channels',
- 'There are not plugs matching your channels'
- )}
-
- {t(
- 'you_have_to_add_x_linkedin_page_threads_or_bluesky',
- 'You have to add: X, LinkedIn Page, Threads or Bluesky'
- )}
-
-
router.push('/launches')}>
- {t(
- 'go_to_the_calendar_to_add_channels',
- 'Go to the calendar to add channels'
- )}
-
-
- );
- }
return (
- <>
-
-
-
-
- {t('channels')}
-
-
setCollapseMenu(collapseMenu === '1' ? '0' : '1')}
- className="group-[.sidebar]:rotate-[180deg] group-[.sidebar]:mx-auto text-btnText bg-btnSimple rounded-[6px] w-[24px] h-[24px] flex items-center justify-center cursor-pointer select-none"
- >
-
-
-
-
+
+
+
{t('plugs', 'Plugs')}
+
+ {!sortedIntegrations.length ? (
+
+
+ {t(
+ 'there_are_not_plugs_matching_your_channels',
+ 'There are not plugs matching your channels'
+ )}
+
+ {t(
+ 'you_have_to_add_x_linkedin_page_threads_or_bluesky',
+ 'You have to add: X, LinkedIn Page, Threads or Bluesky'
+ )}
-
+ router.push('/launches')}>
+ {t(
+ 'go_to_the_calendar_to_add_channels',
+ 'Go to the calendar to add channels'
+ )}
+
+
+ ) : (
+ <>
+
{sortedIntegrations.map((integration, index) => (
{
setCurrent(index);
}}
className={clsx(
- 'flex gap-[8px] items-center justify-center group/profile hover:bg-boxHover rounded-e-[8px] mobile:shrink-0',
- currentIntegration.id !== integration.id &&
- 'opacity-20 hover:opacity-100 cursor-pointer'
+ 'flex gap-[8px] items-center px-[12px] py-[8px] rounded-[8px] hover:bg-boxHover cursor-pointer',
+ currentIntegration.id === integration.id
+ ? 'bg-boxHover'
+ : 'opacity-40 hover:opacity-100'
)}
>
{(integration.inBetweenSteps ||
integration.refreshNeeded) && (
-
@@ -221,13 +182,11 @@ export const Plugs = () => {
))}
-
-
-
- >
+
+
+
+ >
+ )}
+
);
};
diff --git a/apps/frontend/src/components/public-api/mcp.client.icons.tsx b/apps/frontend/src/components/public-api/mcp.client.icons.tsx
index 412cfa0c0d..bc44435345 100644
--- a/apps/frontend/src/components/public-api/mcp.client.icons.tsx
+++ b/apps/frontend/src/components/public-api/mcp.client.icons.tsx
@@ -2,7 +2,7 @@
import { FC } from 'react';
-// Logos for the MCP clients shown in Settings > Developers and in onboarding.
+// Logos for the MCP clients shown in Settings > Agents and in onboarding.
// Monochrome marks use currentColor so they follow the button text color,
// brand marks (Claude, VS Code, Gemini) keep their colors.
const icons: Record
> = {
diff --git a/apps/frontend/src/components/public-api/public.component.tsx b/apps/frontend/src/components/public-api/public.component.tsx
index f9009ce23f..262c864f1b 100644
--- a/apps/frontend/src/components/public-api/public.component.tsx
+++ b/apps/frontend/src/components/public-api/public.component.tsx
@@ -1,7 +1,7 @@
'use client';
-import { useState, useCallback, useMemo } from 'react';
-import useSWR, { useSWRConfig } from 'swr';
+import { useState, useCallback } from 'react';
+import { useSWRConfig } from 'swr';
import { useUser } from '../layout/user.context';
import copy from 'copy-to-clipboard';
import { useToaster } from '@gitroom/react/toaster/toaster';
@@ -10,7 +10,6 @@ import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { joinBrandUrl } from '@gitroom/helpers/utils/brand.config';
import { useDecisionModal } from '@gitroom/frontend/components/layout/new-modal';
-import { DeveloperComponent } from '@gitroom/frontend/components/developer/developer.component';
import { McpClientIcon } from '@gitroom/frontend/components/public-api/mcp.client.icons';
import clsx from 'clsx';
@@ -837,50 +836,11 @@ const PublicApiContent = () => {
export const PublicComponent = () => {
const t = useT();
- const fetch = useFetch();
- const user = useUser();
- const [subTab, setSubTab] = useState<'api' | 'developer'>('api');
- const loadOrganizations = useCallback(async () => {
- return await (await fetch('/user/organizations')).json();
- }, []);
- const { data: organizations } = useSWR('organizations', loadOrganizations, {
- revalidateIfStale: false,
- revalidateOnFocus: false,
- refreshWhenOffline: false,
- refreshWhenHidden: false,
- revalidateOnReconnect: false,
- });
- const currentOrg = useMemo(() => {
- return organizations?.find((org: any) => org?.id === user?.orgId);
- }, [organizations, user?.orgId]);
return (
-
- {t('developers', 'Developers')}
- {currentOrg?.name ? ` - ${currentOrg.name}` : ''}
-
-
- {(['api', 'developer'] as const).map((tab) => (
- setSubTab(tab)}
- >
- {tab === 'api'
- ? t('access', 'Access')
- : t('apps', 'Apps')}
-
- ))}
-
- {subTab === 'api' &&
}
- {subTab === 'developer' &&
}
+
{t('agents', 'Agents')}
+
);
};
diff --git a/apps/frontend/src/components/settings/apps.component.tsx b/apps/frontend/src/components/settings/apps.component.tsx
new file mode 100644
index 0000000000..9252f44a44
--- /dev/null
+++ b/apps/frontend/src/components/settings/apps.component.tsx
@@ -0,0 +1,44 @@
+'use client';
+
+import { FC, useState } from 'react';
+import clsx from 'clsx';
+import { useT } from '@gitroom/react/translation/get.transation.service.client';
+import { DeveloperComponent } from '@gitroom/frontend/components/developer/developer.component';
+import { ApprovedAppsComponent } from '@gitroom/frontend/components/approved-apps/approved-apps.component';
+
+export const AppsComponent: FC<{ canManageApps: boolean }> = ({
+ canManageApps,
+}) => {
+ const t = useT();
+ const [subTab, setSubTab] = useState<'apps' | 'approved_apps'>('apps');
+ const current = canManageApps ? subTab : 'approved_apps';
+
+ return (
+
+
{t('apps', 'Apps')}
+ {canManageApps && (
+
+ {(['apps', 'approved_apps'] as const).map((tab) => (
+ setSubTab(tab)}
+ >
+ {tab === 'apps'
+ ? t('apps', 'Apps')
+ : t('approved_apps', 'Approved Apps')}
+
+ ))}
+
+ )}
+ {current === 'apps' &&
}
+ {current === 'approved_apps' &&
}
+
+ );
+};
diff --git a/apps/frontend/src/components/settings/authentication.component.tsx b/apps/frontend/src/components/settings/authentication.component.tsx
new file mode 100644
index 0000000000..7fba24da1b
--- /dev/null
+++ b/apps/frontend/src/components/settings/authentication.component.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import React from 'react';
+import { useT } from '@gitroom/react/translation/get.transation.service.client';
+import { useUser } from '@gitroom/frontend/components/layout/user.context';
+
+const AuthenticationComponent = () => {
+ const t = useT();
+ const user = useUser();
+
+ const methods: Record = {
+ LOCAL: {
+ label: t('auth_method_local', 'Email and password'),
+ showEmail: true,
+ },
+ GITHUB: { label: t('auth_method_github', 'GitHub'), showEmail: true },
+ GOOGLE: { label: t('auth_method_google', 'Google'), showEmail: true },
+ APPLE: { label: t('auth_method_apple', 'Apple'), showEmail: true },
+ FARCASTER: {
+ label: t('auth_method_farcaster', 'Farcaster'),
+ showEmail: false,
+ },
+ WALLET: { label: t('auth_method_wallet', 'Wallet'), showEmail: false },
+ GENERIC: {
+ label: t('auth_method_generic', 'Single sign-on'),
+ showEmail: false,
+ },
+ };
+
+ const method = user?.providerName && methods[user.providerName];
+
+ if (!method) {
+ return null;
+ }
+
+ return (
+
+
{t('authentication', 'Authentication')}
+
+
{method.label}
+ {method.showEmail && (
+
{user.email}
+ )}
+
+
+ );
+};
+
+export default AuthenticationComponent;
diff --git a/apps/frontend/src/components/settings/global.settings.tsx b/apps/frontend/src/components/settings/global.settings.tsx
index ddb4fa6799..f45bafd6d0 100644
--- a/apps/frontend/src/components/settings/global.settings.tsx
+++ b/apps/frontend/src/components/settings/global.settings.tsx
@@ -3,6 +3,7 @@
import React from 'react';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import dynamic from 'next/dynamic';
+import AuthenticationComponent from '@gitroom/frontend/components/settings/authentication.component';
import EmailNotificationsComponent from '@gitroom/frontend/components/settings/email-notifications.component';
import ShortlinkPreferenceComponent from '@gitroom/frontend/components/settings/shortlink-preference.component';
import DeleteAccountComponent from '@gitroom/frontend/components/settings/delete-account.component';
@@ -19,6 +20,7 @@ export const GlobalSettings = () => {
return (
{t('global_settings', 'Global Settings')}
+
diff --git a/apps/frontend/src/components/settings/signatures.component.tsx b/apps/frontend/src/components/settings/signatures.component.tsx
index 6ed08fdb79..cf10632e61 100644
--- a/apps/frontend/src/components/settings/signatures.component.tsx
+++ b/apps/frontend/src/components/settings/signatures.component.tsx
@@ -1,4 +1,4 @@
-import React, { FC, Fragment, useCallback } from 'react';
+import React, { FC, useCallback } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { Button } from '@gitroom/react/form/button';
@@ -67,7 +67,7 @@ export const SignaturesComponent: FC<{
'You can add signatures to your account to be used in your posts.'
)}
-
+
{!!data?.length && (
-
{t('content', 'Content')}
-
{t('auto_add', 'Auto Add?')}
+
{t('content', 'Content')}
+
+ {t('auto_add', 'Auto Add?')}
+
{!!appendSignature && (
-
{t('actions', 'Actions')}
+
+ {t('actions', 'Actions')}
+
)}
-
{t('edit', 'Edit')}
-
{t('delete', 'Delete')}
+
{t('edit', 'Edit')}
+
+ {t('delete', 'Delete')}
+
{data?.map((p: any) => (
-
-
-
+ // a row of the table, a card on phones
+
+
+
{p.content.slice(0, 15) + '...'}
-
-
+
+
+
+ {t('auto_add', 'Auto Add?')}{' '}
+
{p.autoAdd ? 'Yes' : 'No'}
{!!appendSignature && (
-
-
appendSignature(p.content)}>
+
+ appendSignature(p.content)}
+ >
{t('use_signature', 'Use Signature')}
)}
-
-
-
+
-
-
-
+
+
+
{t('delete', 'Delete')}
-
+
))}
)}
0 && 'my-[16px]')}
+ className={clsx(
+ (data?.length || 0) > 0 && 'my-[16px]',
+ 'mobile:w-full'
+ )}
>
{t('add_a_signature', 'Add a signature')}
diff --git a/apps/frontend/src/components/settings/teams.component.tsx b/apps/frontend/src/components/settings/teams.component.tsx
index 070ed9b298..3bd48a26b9 100644
--- a/apps/frontend/src/components/settings/teams.component.tsx
+++ b/apps/frontend/src/components/settings/teams.component.tsx
@@ -48,14 +48,23 @@ export const AddMember = () => {
control: form.control,
name: 'sendEmail',
});
+ const t = useT();
const submit = useCallback(
async (values: { email: string; role: string; sendEmail: boolean }) => {
- const { url } = await (
- await fetch('/settings/team', {
- method: 'POST',
- body: JSON.stringify(values),
- })
- ).json();
+ const response = await fetch('/settings/team', {
+ method: 'POST',
+ body: JSON.stringify(values),
+ });
+ if (!response.ok) {
+ const { message } = await response.json().catch(() => ({} as any));
+ toast.show(
+ (Array.isArray(message) ? message[0] : message) ||
+ t('could_not_send_invitation', 'Could not send the invitation'),
+ 'warning'
+ );
+ return;
+ }
+ const { url } = await response.json();
if (values.sendEmail) {
modals.closeAll();
toast.show(t('invitation_link_sent', 'Invitation link sent'));
@@ -65,11 +74,9 @@ export const AddMember = () => {
modals.closeAll();
toast.show(t('link_copied_to_clipboard', 'Link copied to clipboard'));
},
- []
+ [fetch, modals, toast, t]
);
- const t = useT();
-
return (