diff --git a/public/index.html b/public/index.html
index 9530b7b3..ccd9abc7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -35,9 +35,10 @@
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
-
gtag('config', 'G-8D0MD2S4PK');
+
+
React App
diff --git a/src/components/base/HeaderLogo.tsx b/src/components/base/HeaderLogo.tsx
index 2bb27f47..7e7e29ed 100644
--- a/src/components/base/HeaderLogo.tsx
+++ b/src/components/base/HeaderLogo.tsx
@@ -37,7 +37,7 @@ const HeaderLogo: React.FC = ({
- {userLogo.title || createFallbackTitle(username)}
+ {userLogo.title || createFallbackTitle(username)}
);
@@ -69,7 +69,7 @@ const HeaderLogoBlock = styled.div`
.user-logo {
display: block;
- max-width: calc(100vw - 200px);
+ max-width: calc(100vw - 250px);
${ellipsis};
}
`;
diff --git a/src/components/write/PublishActionButtons.tsx b/src/components/write/PublishActionButtons.tsx
index 0301178f..40d8d4a6 100644
--- a/src/components/write/PublishActionButtons.tsx
+++ b/src/components/write/PublishActionButtons.tsx
@@ -6,6 +6,7 @@ import media from '../../lib/styles/media';
const PublishActionButtonsBlock = styled.div`
display: flex;
justify-content: flex-end;
+ margin-top: 0.5rem;
${media.custom(767)} {
margin-top: 2rem;
}
@@ -15,12 +16,14 @@ export interface PublishActionButtonsProps {
onCancel: () => void;
onPublish: () => void;
edit: boolean;
+ isLoading: boolean;
}
const PublishActionButtons: React.FC = ({
onCancel,
onPublish,
edit,
+ isLoading,
}) => {
return (
@@ -32,7 +35,12 @@ const PublishActionButtons: React.FC = ({
>
취소
-
diff --git a/src/components/write/PublishSeriesCreate.tsx b/src/components/write/PublishSeriesCreate.tsx
index 3f65ff79..516edfbe 100644
--- a/src/components/write/PublishSeriesCreate.tsx
+++ b/src/components/write/PublishSeriesCreate.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, FormEvent } from 'react';
+import React, { useState, useEffect, FormEvent, useRef } from 'react';
import styled, { css, keyframes } from 'styled-components';
import { themedPalette } from '../../lib/styles/themes';
import OutsideClickHandler from 'react-outside-click-handler';
@@ -110,8 +110,8 @@ const PublishSeriesCreate: React.FC = ({
urlSlug: '',
});
const [editing, setEditing] = useState(false);
-
const [defaultUrlSlug, setDefaultUrlSlug] = useState('');
+ const hideTimeoutId = useRef(null);
useEffect(() => {
let timeoutId: ReturnType | null = null;
@@ -137,15 +137,33 @@ const PublishSeriesCreate: React.FC = ({
setEditing(true);
}, [form.urlSlug]);
+ useEffect(() => {
+ return () => {
+ if (hideTimeoutId.current) {
+ clearTimeout(hideTimeoutId.current);
+ }
+ };
+ }, [hideTimeoutId]);
+
const onHide = () => {
setDisappear(true);
- setTimeout(() => {
+ const timeout = setTimeout(() => {
setOpen(false);
setDisappear(false);
setShowOpenBlock(false);
}, 125);
+ const timeoutId = timeout;
+ hideTimeoutId.current = timeoutId;
};
+ useEffect(() => {
+ return () => {
+ if (hideTimeoutId.current) {
+ clearTimeout(hideTimeoutId.current);
+ }
+ };
+ }, []);
+
const submit = (e: FormEvent) => {
e.preventDefault();
if (form.name.trim() === '') {
diff --git a/src/containers/write/ActiveEditor.tsx b/src/containers/write/ActiveEditor.tsx
index fa1c9b59..080f9cf0 100644
--- a/src/containers/write/ActiveEditor.tsx
+++ b/src/containers/write/ActiveEditor.tsx
@@ -132,6 +132,7 @@ const ActiveEditor: React.FC = () => {
}, [dispatch, lastPostHistory, post]);
if (
+ id &&
!newPost &&
((!readPostForEdit.loading && post === null) ||
(post && post.user.id !== userId))
diff --git a/src/containers/write/MarkdownEditorContainer.tsx b/src/containers/write/MarkdownEditorContainer.tsx
index bae00814..40e7e74f 100644
--- a/src/containers/write/MarkdownEditorContainer.tsx
+++ b/src/containers/write/MarkdownEditorContainer.tsx
@@ -61,17 +61,21 @@ const MarkdownEditorContainer: React.FC = () => {
tags,
} = useSelector((state: RootState) => state.write);
const uncachedClient = useUncachedApolloClient();
- const [writePost] = useMutation(WRITE_POST, {
- client: uncachedClient,
- });
+ const [writePost, { loading: writePostLoading }] =
+ useMutation(WRITE_POST, {
+ client: uncachedClient,
+ });
const bodyRef = useRef(initialBody);
const titleRef = useRef(title);
const [createPostHistory] =
useMutation(CREATE_POST_HISTORY);
- const [editPost] = useMutation(EDIT_POST, {
- client: uncachedClient,
- });
+ const [editPost, { loading: editPostLoading }] = useMutation(
+ EDIT_POST,
+ {
+ client: uncachedClient,
+ },
+ );
const [lastSavedData, setLastSavedData] = useState({
title: initialTitle,
@@ -148,6 +152,7 @@ const MarkdownEditorContainer: React.FC = () => {
const onTempSave = useCallback(
async (notify?: boolean) => {
+ if (writePostLoading || editPostLoading) return;
if (!title || !markdown) {
toast.error('제목 또는 내용이 비어있습니다.');
return;
@@ -171,14 +176,17 @@ const MarkdownEditorContainer: React.FC = () => {
thumbnail: null,
meta: {},
series_id: null,
+ token: null,
},
});
- if (!response || !response.data) return;
+
+ if (!response.data?.writePost) return;
const { id } = response.data.writePost;
dispatch(setWritePostId(id));
history.replace(`/write?id=${id}`);
notifySuccess();
}
+
// tempsaving unreleased post:
if (isTemp) {
await editPost({
@@ -194,6 +202,7 @@ const MarkdownEditorContainer: React.FC = () => {
meta: {},
series_id: null,
tags,
+ token: null,
},
});
notifySuccess();
@@ -205,19 +214,22 @@ const MarkdownEditorContainer: React.FC = () => {
if (shallowEqual(lastSavedData, { title, body: markdown })) {
return;
}
- await createPostHistory({
- variables: {
- post_id: postId,
- title,
- body: markdown,
- is_markdown: true,
- },
- });
+
+ if (postId) {
+ await createPostHistory({
+ variables: {
+ post_id: postId,
+ title,
+ body: markdown,
+ is_markdown: true,
+ },
+ });
+ }
+
setLastSavedData({
title,
body: markdown,
});
- notifySuccess();
},
[
createPostHistory,
@@ -231,6 +243,8 @@ const MarkdownEditorContainer: React.FC = () => {
tags,
title,
writePost,
+ writePostLoading,
+ editPostLoading,
],
);
@@ -259,9 +273,11 @@ const MarkdownEditorContainer: React.FC = () => {
thumbnail: null,
meta: {},
series_id: null,
+ token: null,
},
});
- if (!response || !response.data) return;
+
+ if (!response.data?.writePost) return;
id = response.data.writePost.id;
dispatch(setWritePostId(id));
history.replace(`/write?id=${id}`);
diff --git a/src/containers/write/PublishActionButtonsContainer.tsx b/src/containers/write/PublishActionButtonsContainer.tsx
index 4b59cfaa..ec1721c3 100644
--- a/src/containers/write/PublishActionButtonsContainer.tsx
+++ b/src/containers/write/PublishActionButtonsContainer.tsx
@@ -17,6 +17,7 @@ import { setHeadingId } from '../../lib/heading';
import { useHistory } from 'react-router';
import { toast } from 'react-toastify';
import { useUncachedApolloClient } from '../../lib/graphql/UncachedApolloContext';
+import useTurnstile from '../../lib/hooks/useTurnstile';
type PublishActionButtonsContainerProps = {};
@@ -25,6 +26,10 @@ const PublishActionButtonsContainer: React.FC<
> = () => {
const history = useHistory();
const client = useApolloClient();
+ const user = useSelector((state: RootState) => state.core.user);
+
+ const isTurnstileEnabled = !!user && !user.is_trusted;
+ const { isLoading, token } = useTurnstile(isTurnstileEnabled);
const options = useSelector((state: RootState) =>
pick(
@@ -54,12 +59,16 @@ const PublishActionButtonsContainer: React.FC<
const uncachedClient = useUncachedApolloClient();
- const [writePost] = useMutation(WRITE_POST, {
- client: uncachedClient,
- });
- const [editPost] = useMutation(EDIT_POST, {
- client: uncachedClient,
- });
+ const [writePost, { loading: writePostLoading }] =
+ useMutation(WRITE_POST, {
+ client: uncachedClient,
+ });
+ const [editPost, { loading: editPostLoading }] = useMutation(
+ EDIT_POST,
+ {
+ client: uncachedClient,
+ },
+ );
const variables = {
title: options.title,
@@ -77,37 +86,70 @@ const PublishActionButtonsContainer: React.FC<
short_description: options.description,
},
series_id: safe(() => options.selectedSeries!.id),
+ token,
};
const onPublish = async () => {
+ if (writePostLoading) {
+ toast.info('포스트 작성 중입니다.');
+ return;
+ }
+
if (options.title.trim() === '') {
toast.error('제목이 비어있습니다.');
return;
}
+
try {
const response = await writePost({
variables: variables,
});
- if (!response || !response.data) return;
+
+ if (!response.data?.writePost) {
+ toast.error('포스트 작성 실패');
+ return;
+ }
+
const { user, url_slug } = response.data.writePost;
await client.resetStore();
history.push(`/@${user.username}/${url_slug}`);
- } catch (e) {
+ } catch (error) {
+ console.log('write post failed', error);
toast.error('포스트 작성 실패');
}
};
const onEdit = async () => {
- const response = await editPost({
- variables: {
- id: options.postId,
- ...variables,
- },
- });
- if (!response || !response.data) return;
- const { user, url_slug } = response.data.editPost;
- await client.resetStore();
- history.push(`/@${user.username}/${url_slug}`);
+ if (editPostLoading) {
+ toast.info('포스트 수정 중입니다.');
+ return;
+ }
+
+ if (options.title.trim() === '') {
+ toast.error('제목이 비어있습니다.');
+ return;
+ }
+
+ try {
+ const response = await editPost({
+ variables: {
+ id: options.postId,
+ ...variables,
+ },
+ });
+
+ if (!response.data?.editPost) {
+ toast.error('포스트 수정 실패');
+ return;
+ }
+
+ const { user, url_slug } = response.data.editPost;
+ await client.resetStore();
+ history.push(`/@${user.username}/${url_slug}`);
+ } catch (error) {
+ console.log('edit post failed', error);
+ toast.error('포스트 수정 실패');
+ }
};
return (
@@ -115,6 +157,7 @@ const PublishActionButtonsContainer: React.FC<
onCancel={onCancel}
onPublish={options.postId ? onEdit : onPublish}
edit={!!options.postId && !options.isTemp}
+ isLoading={isLoading}
/>
);
};
diff --git a/src/containers/write/PublishCaptcha.tsx b/src/containers/write/PublishCaptcha.tsx
new file mode 100644
index 00000000..fee6d8c1
--- /dev/null
+++ b/src/containers/write/PublishCaptcha.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import styled from 'styled-components';
+import Spinner from '../../components/common/SpinnerBlock';
+import useTurnstile from '../../lib/hooks/useTurnstile';
+
+const PublishCapchaBlock = styled.section`
+ margin-top: 1.5rem;
+
+ & > #cf-turnstile {
+ iframe {
+ width: 100% !important;
+ }
+ }
+`;
+
+const SpinnerBlock = styled(PublishCapchaBlock)`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ & > div {
+ width: 50px;
+ height: 50px;
+ }
+`;
+
+interface PublishCaptchaProps {}
+
+const PublishCaptcha: React.FC = () => {
+ const { isReady } = useTurnstile();
+
+ if (!isReady) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default PublishCaptcha;
diff --git a/src/containers/write/PublishCaptchaContainer.tsx b/src/containers/write/PublishCaptchaContainer.tsx
new file mode 100644
index 00000000..99175ca8
--- /dev/null
+++ b/src/containers/write/PublishCaptchaContainer.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { RootState } from '../../modules';
+import PublishCaptcha from './PublishCaptcha';
+
+export interface PublishCaptchaContainerProps {}
+
+const PublishCaptchaContainer: React.FC = () => {
+ const user = useSelector((state: RootState) => state.core.user);
+
+ if (!user || user.is_trusted) return null;
+ return ;
+};
+
+export default PublishCaptchaContainer;
diff --git a/src/containers/write/PublishPreviewContainer.tsx b/src/containers/write/PublishPreviewContainer.tsx
index 5c3f976e..dced3516 100644
--- a/src/containers/write/PublishPreviewContainer.tsx
+++ b/src/containers/write/PublishPreviewContainer.tsx
@@ -47,9 +47,10 @@ const PublishPreviewContainer: React.FC = ({
[changeDescription],
);
const uncachedClient = useUncachedApolloClient();
- const [writePost] = useMutation(WRITE_POST, {
- client: uncachedClient,
- });
+ const [writePost, { loading: writePostLoading }] =
+ useMutation(WRITE_POST, {
+ client: uncachedClient,
+ });
const [upload, file] = useUpload();
const { upload: cfUpload, image } = useCFUpload();
@@ -91,6 +92,7 @@ const PublishPreviewContainer: React.FC = ({
thumbnail: null,
meta: {},
series_id: null,
+ token: null,
},
});
@@ -102,6 +104,7 @@ const PublishPreviewContainer: React.FC = ({
const uploadWithPostId = useCallback(
async (file: File) => {
+ if (!file) return;
const id = await getValidPostId();
if (!id) return;
cfUpload(file, { type: 'post', refId: id });
@@ -113,7 +116,7 @@ const PublishPreviewContainer: React.FC = ({
if (!file) return;
uploadWithPostId(file);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [file]);
+ }, [file, writePostLoading]);
useEffect(() => {
if (!image) return;
diff --git a/src/containers/write/PublishSettings.tsx b/src/containers/write/PublishSettings.tsx
index a6748d8d..33ad94c1 100644
--- a/src/containers/write/PublishSettings.tsx
+++ b/src/containers/write/PublishSettings.tsx
@@ -3,16 +3,18 @@ import PublishPrivacySettingContainer from './PublishPrivacySettingContainer';
import PublishURLSettingContainer from './PublishURLSettingContainer';
import PublishSeriesSectionContainer from './PublishSeriesSectionContainer';
import PublishActionButtonsContainer from './PublishActionButtonsContainer';
+import PublishCaptchaContainer from './PublishCaptchaContainer';
export interface PublishSettingsProps {}
-const PublishSettings: React.FC = props => {
+const PublishSettings: React.FC = (props) => {
return (
<>
>
diff --git a/src/index.tsx b/src/index.tsx
index 9a65dfaf..f505febe 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -17,10 +17,13 @@ import * as Sentry from '@sentry/browser';
import { HelmetProvider } from 'react-helmet-async';
import darkMode from './modules/darkMode';
import { UncachedApolloProvider } from './lib/graphql/UncachedApolloContext';
+import { ssrEnabled } from './lib/utils';
-Sentry.init({
- dsn: 'https://99d0ac3ca0f64b4d8709e385e7692893@sentry.io/1886813',
-});
+if (process.env.NODE_ENV === 'production') {
+ Sentry.init({
+ dsn: 'https://99d0ac3ca0f64b4d8709e385e7692893@sentry.io/1886813',
+ });
+}
const store = createStore(
rootReducer,
@@ -45,8 +48,18 @@ const loadTheme = () => {
document.body.dataset.theme = theme;
};
+const loadTurnstile = () => {
+ if (ssrEnabled) return;
+ (window as any).onAppReady = function () {
+ (window as any).isTurnstileReady = true;
+ const event = new CustomEvent('isTurnstileReadyChange');
+ window.dispatchEvent(event);
+ };
+};
+
loadUser();
loadTheme();
+loadTurnstile();
if (process.env.NODE_ENV === 'production') {
loadableReady(() => {
diff --git a/src/lib/api/files.ts b/src/lib/api/files.ts
index 1698d8e5..b5e1a9f7 100644
--- a/src/lib/api/files.ts
+++ b/src/lib/api/files.ts
@@ -35,9 +35,7 @@ export async function uploadImage(
headers: {
'Content-Type': 'multipart/form-data',
},
- onUploadProgress(e) {
- console.log(e);
- },
+ onUploadProgress(event) {},
},
);
diff --git a/src/lib/graphql/post.ts b/src/lib/graphql/post.ts
index 1d1b1909..25b36d78 100644
--- a/src/lib/graphql/post.ts
+++ b/src/lib/graphql/post.ts
@@ -528,6 +528,7 @@ export const WRITE_POST = gql`
$thumbnail: String
$meta: JSON
$series_id: ID
+ $token: String
) {
writePost(
title: $title
@@ -540,6 +541,7 @@ export const WRITE_POST = gql`
thumbnail: $thumbnail
meta: $meta
series_id: $series_id
+ token: $token
) {
id
user {
@@ -575,6 +577,7 @@ export const EDIT_POST = gql`
$thumbnail: String
$meta: JSON
$series_id: ID
+ $token: String
) {
editPost(
id: $id
@@ -588,74 +591,27 @@ export const EDIT_POST = gql`
thumbnail: $thumbnail
meta: $meta
series_id: $series_id
+ token: $token
) {
id
- title
- released_at
- updated_at
- tags
- body
- short_description
- is_markdown
- is_private
- is_temp
- thumbnail
- comments_count
- url_slug
user {
id
username
- profile {
- id
- display_name
- thumbnail
- short_bio
- }
- velog_config {
- title
- }
- }
- comments {
- id
- user {
- id
- username
- profile {
- id
- thumbnail
- display_name
- }
- }
- text
- replies_count
- level
- created_at
- level
- deleted
- }
- series {
- id
- name
- url_slug
- series_posts {
- id
- post {
- id
- title
- url_slug
- user {
- id
- username
- }
- }
- }
}
+ url_slug
}
}
`;
export type EditPostResult = {
- editPost: SinglePost;
+ editPost: {
+ id: string;
+ user: {
+ id: string;
+ username: string;
+ };
+ url_slug: string;
+ };
};
export const WRITE_COMMENT = gql`
diff --git a/src/lib/graphql/user.ts b/src/lib/graphql/user.ts
index e11aad84..ede52c0b 100644
--- a/src/lib/graphql/user.ts
+++ b/src/lib/graphql/user.ts
@@ -29,6 +29,7 @@ export type User = {
profile: UserProfile;
velogConfig: VelogConfig | null;
is_followed: boolean;
+ is_trusted: boolean;
};
export const GET_CURRENT_USER = gql`
@@ -37,6 +38,7 @@ export const GET_CURRENT_USER = gql`
id
username
email
+ is_trusted
profile {
id
thumbnail
@@ -64,6 +66,7 @@ export type CurrentUser = {
display_name: string;
};
email: string;
+ is_trusted: boolean;
};
export const GET_USER_PROFILE = gql`
diff --git a/src/lib/hooks/useTurnstile.tsx b/src/lib/hooks/useTurnstile.tsx
new file mode 100644
index 00000000..213a8ac3
--- /dev/null
+++ b/src/lib/hooks/useTurnstile.tsx
@@ -0,0 +1,74 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { RootState } from '../../modules';
+import { sleep } from '../utils';
+
+const SITE_KEY = '0x4AAAAAAARRng9sFKpsJomI';
+
+const useTurnstile = (isEnabled = true) => {
+ const theme = useSelector((state: RootState) => state.darkMode.theme);
+ const isTurnstileReady = !!(window as any).isTurnstileReady;
+ const [isLoading, setIsLoading] = useState(false);
+ const [isReady, setIsReady] = useState(isTurnstileReady);
+ const [isError, setError] = useState(false);
+ const [token, setToken] = useState(null);
+ const retryCountRef = useRef(0);
+
+ const waitTurnstileContainer = async () => {
+ const container = document.getElementById('cf-turnstile');
+ while (!container) {
+ if (container) return true;
+ await sleep(500);
+ }
+ return true;
+ };
+
+ const checkBot = useCallback(async () => {
+ setIsLoading(true);
+ const turnstile = (window as any).turnstile;
+ await waitTurnstileContainer();
+ turnstile.render('#cf-turnstile', {
+ sitekey: SITE_KEY,
+ theme: theme === 'dark' ? 'dark' : 'light',
+ callback: (token: string) => {
+ setIsLoading(false);
+ setToken(token);
+ },
+ 'error-callback': () => {
+ retryCountRef.current += 1;
+ setError(true);
+ if (retryCountRef.current < 5) {
+ setTimeout(() => {
+ checkBot();
+ }, 1000);
+ }
+ },
+ });
+ }, [theme]);
+
+ useEffect(() => {
+ const checkTurnstileReady = () => {
+ const isTurnstileReady = (window as any).isTurnstileReady;
+ setIsReady(isTurnstileReady);
+ };
+ window.addEventListener('isTurnstileReadyChange', checkTurnstileReady);
+ return () => {
+ window.removeEventListener('isTurnstileReadyChange', checkTurnstileReady);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!isEnabled) return;
+ checkBot();
+ }, [checkBot, isEnabled]);
+
+ return {
+ isError,
+ isReady,
+ isLoading,
+ token,
+ isEnabled,
+ };
+};
+
+export default useTurnstile;
diff --git a/src/lib/hooks/useUpload.tsx b/src/lib/hooks/useUpload.tsx
index a8698b39..d803d502 100644
--- a/src/lib/hooks/useUpload.tsx
+++ b/src/lib/hooks/useUpload.tsx
@@ -11,8 +11,6 @@ const useUpload = () => {
input.type = 'file';
input.onchange = () => {
clearTimeout(timeout);
- console.log('onchange');
- console.log(input.files);
if (!input.files) return reject();
const file = input.files[0];
setFile(file);