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);