diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 43328fe0..660d8f68 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,25 +19,26 @@ jobs: id: yarn-cache run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v1 + - uses: actions/cache@v3 with: path: ${{ steps.yarn-cache.outputs.dir }} key: ${{ runner.os }}-yarn-v1-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-yarn- - name: yarn install - uses: borales/actions-yarn@v2.0.0 + uses: borales/actions-yarn@v4.0.0 with: cmd: install - name: npm run build:ci run: npm run build:ci env: REACT_APP_API_HOST: 'https://v2.velog.io/' - REACT_APP_GRAPHQL_HOST: 'https://v2cdn.velog.io/' + REACT_APP_GRAPHQL_HOST: 'https://v2.velog.io/' REACT_APP_GRAPHQL_HOST_NOCDN: 'https://v2.velog.io/' PUBLIC_URL: 'https://static.velog.io/' REACT_APP_REDIS_HOST: ${{ secrets.REDIS_HOST }} REACT_APP_CLIENT_V3_HOST: 'https://velog.io/' + REACT_APP_WHITELIST_IPS: ${{ secrets.REACT_APP_WHITELIST_IPS }} CI: false - name: upload env: diff --git a/.gitignore b/.gitignore index b91ca42f..0516f767 100755 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ jspm_packages # Serverless directories .serverless .webpack + +# ignore setting +.idea +.vscode diff --git a/package.json b/package.json index 6f371341..eb77c765 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@types/jest": "^24.0.0", "@types/koa": "^2.11.0", "@types/koa-bodyparser": "^4.3.0", + "@types/koa-compress": "^4.0.6", "@types/koa-static": "^4.0.1", "@types/koa__router": "^8.0.2", "@types/loadable__component": "^5.10.0", @@ -96,6 +97,7 @@ "koa": "^2.11.0", "koa-better-http-proxy": "^0.2.4", "koa-bodyparser": "^4.2.1", + "koa-compress": "^5.1.1", "koa-static": "^5.0.0", "mini-css-extract-plugin": "0.8.0", "node-fetch": "^2.6.0", @@ -161,7 +163,7 @@ "workbox-webpack-plugin": "4.3.1" }, "scripts": { - "start": "node scripts/start.js", + "start": "node scripts/start.js --max-http-header-size=1024", "build": "node scripts/build.js", "build:ci": "node scripts/build.js && node scripts/build.server.js", "build:server": "node scripts/build.server.js", @@ -248,7 +250,7 @@ "@loadable/babel-plugin" ] }, - "proxy": "https://v2cdn.velog.io/", + "proxy": "https://v2.velog.io/", "devDependencies": { "@loadable/webpack-plugin": "^5.15.1" } diff --git a/src/components/common/AdFeed.tsx b/src/components/common/AdFeed.tsx index 18259136..98f3fe11 100644 --- a/src/components/common/AdFeed.tsx +++ b/src/components/common/AdFeed.tsx @@ -64,9 +64,9 @@ function AdFeed({ forPost, index }: { forPost?: boolean; index: number }) { className="adsbygoogle" style={{ display: 'block' }} data-ad-format="fluid" - data-ad-layout-key="-6c+ce+2v-x+66" - data-ad-client="ca-pub-9161852896103498" - data-ad-slot="9446928451" + data-ad-layout-key="-6u+e5+1a-3q+77" + data-ad-client="ca-pub-5574866530496701" + data-ad-slot="2841722540" > ); @@ -87,18 +87,18 @@ function AdFeed({ forPost, index }: { forPost?: boolean; index: number }) { className="adsbygoogle" style={{ display: 'block' }} data-ad-format="fluid" - data-ad-layout-key="-6c+ce+2v-x+66" - data-ad-client="ca-pub-9161852896103498" - data-ad-slot="2793890198" + data-ad-layout-key="-6u+e5+1a-3q+77" + data-ad-client="ca-pub-5574866530496701" + data-ad-slot="8480422066" > ) : ( )} {/* {isMobile ? ( diff --git a/src/components/common/MarkdownRender.tsx b/src/components/common/MarkdownRender.tsx index ebd9b363..febe9ecc 100644 --- a/src/components/common/MarkdownRender.tsx +++ b/src/components/common/MarkdownRender.tsx @@ -170,7 +170,7 @@ function filter(html: string) { ], allowedAttributes: { a: ['href', 'name', 'target'], - img: ['src'], + img: ['src', 'alt', 'width', 'height'], iframe: ['src', 'allow', 'allowfullscreen', 'scrolling', 'class'], '*': ['class', 'id', 'aria-hidden'], span: ['style'], diff --git a/src/components/common/UserProfile.tsx b/src/components/common/UserProfile.tsx index 51258293..ea1b4545 100644 --- a/src/components/common/UserProfile.tsx +++ b/src/components/common/UserProfile.tsx @@ -176,6 +176,8 @@ const UserProfile: React.FC = ({ const velogUrl = `/@${username}/posts`; + const getSocialId = (link: string) => link.split('/').reverse()[0]; + return (
@@ -201,7 +203,7 @@ const UserProfile: React.FC = ({ {github && ( = ({ )} {twitter && ( = ({ )} {facebook && ( ([]); + + const ref = useRef(null); + const initializedRef = useRef(false); + + useEffect(() => { + getJobs(category || 'general').then((jobs) => { + const shuffled = jobs.sort(() => Math.random() - 0.5); + const sliced = shuffled.slice(0, 3); + setData(sliced); + }); + }, [category]); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !initializedRef.current) { + setIsObserved(true); + } + }); + }, + { + rootMargin: '300px', + threshold: 0, + }, + ); + if (!ref.current) return; + observer.observe(ref.current); + return () => { + observer.disconnect(); + }; + }, []); + + const onClick = () => { + gtag('event', 'job_position_click'); + }; + + useEffect(() => { + if (!isObserved) { + return; + } + gtag('event', 'job_position_view'); + }, [isObserved]); + + if (!data) + return ( + +
+
+ ); + + return ( + +
+ +

관련 채용 정보

+ + {data.map((jobPosition) => ( + +
+ + + + + + + + + {jobPosition.name} + + {jobPosition.summary} + + + ))} + + + + ); +} + +const Block = styled(VelogResponsive)` + ${media.small} { + h4 { + padding-left: 1rem; + padding-right: 1rem; + } + } +`; +const Container = styled.div` + display: flex; + gap: 1rem; + a { + display: block; + color: inherit; + &:hover { + text-decoration: none; + color: inherit; + } + } + ${media.small} { + padding-left: 0.5rem; + padding-right: 0.5rem; + gap: 0.5rem; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 1rem; + } +`; + +const Card = styled.div` + width: 33.33%; + ${media.small} { + flex-shrink: 0; + width: 60vw; + } +`; + +const Thumbnail = styled.img` + width: 100%; + aspect-ratio: 400 / 292; + object-fit: cover; + border-radius: 4px; +`; + +const Company = styled.div` + display: flex; + gap: 0.5rem; + img { + display: block; + width: 16px; + height: 16px; + } + font-size: 10px; + align-items: center; + color: ${themedPalette.text2}; + ${ellipsis}; + margin-bottom: 0.5rem; +`; + +const JobTitle = styled.a` + font-size: 14px; + font-weight: 600; + line-height: 1.25; +`; + +const JobDescription = styled.div` + margin-top: 8px; + color: ${themedPalette.text2}; + font-size: 12px; + line-height: 1.5; +`; + +export default JobPositions; diff --git a/src/components/post/PostCommentsList.tsx b/src/components/post/PostCommentsList.tsx index c3a4c953..b7016728 100644 --- a/src/components/post/PostCommentsList.tsx +++ b/src/components/post/PostCommentsList.tsx @@ -20,15 +20,17 @@ const PostCommentsList: React.FC = ({ }) => { return ( - {comments.map((comment) => ( - - ))} + {comments + .filter((comment) => !!comment.user?.profile) + .map((comment) => ( + + ))} ); }; diff --git a/src/components/post/PostHead.tsx b/src/components/post/PostHead.tsx index 143f2b94..9776aa31 100644 --- a/src/components/post/PostHead.tsx +++ b/src/components/post/PostHead.tsx @@ -176,6 +176,7 @@ const PostHead: React.FC = ({ toggleAskRemove(); onRemove(); }; + return (
diff --git a/src/components/register/RegisterForm.tsx b/src/components/register/RegisterForm.tsx index 1ffaefcf..8fff7bf5 100644 --- a/src/components/register/RegisterForm.tsx +++ b/src/components/register/RegisterForm.tsx @@ -103,8 +103,8 @@ const RegisterForm: React.FC = ({ @@ -123,7 +123,7 @@ const RegisterForm: React.FC = ({ name="username" onChange={onChange} label="사용자 ID" - placeholder="새 사용자 ID를 입력하세요" + placeholder="사용자 ID를 입력하세요." value={form.username} size={22} /> diff --git a/src/components/write/TagInput.tsx b/src/components/write/TagInput.tsx index 7739aa77..a4b11b69 100644 --- a/src/components/write/TagInput.tsx +++ b/src/components/write/TagInput.tsx @@ -6,6 +6,7 @@ import transitions from '../../lib/styles/transitions'; import { mediaQuery } from '../../lib/styles/media'; import { useTransition, animated } from 'react-spring'; import OutsideClickHandler from 'react-outside-click-handler'; +import { isEmptyOrWhitespace } from '../../lib/utils'; export interface TagInputProps { ref?: React.RefObject; @@ -27,7 +28,6 @@ const TagInput: React.FC = ({ onChange, tags: initialTags }) => { const ignore = useRef(false); useEffect(() => { - if (tags.length === 0) return; onChange(tags); }, [tags, onChange]); @@ -48,9 +48,8 @@ const TagInput: React.FC = ({ onChange, tags: initialTags }) => { (tag: string) => { ignore.current = true; setValue(''); - if (tag === '' || tags.includes(tag)) return; - let processed = tag; - processed = tag.trim(); + if (isEmptyOrWhitespace(tag) || tags.includes(tag)) return; + let processed = tag.trim().slice(0, 255); if (processed.indexOf(' #') > 0) { const tempTags: string[] = []; const regex = /#(\S+)/g; diff --git a/src/components/write/WriteMarkdownEditor.tsx b/src/components/write/WriteMarkdownEditor.tsx index 46e45543..eba9ff47 100644 --- a/src/components/write/WriteMarkdownEditor.tsx +++ b/src/components/write/WriteMarkdownEditor.tsx @@ -21,6 +21,7 @@ require('codemirror/mode/markdown/markdown'); require('codemirror/mode/javascript/javascript'); require('codemirror/mode/jsx/jsx'); require('codemirror/addon/display/placeholder'); +require('codemirror/mode/dockerfile/dockerfile'); export interface MarkdownEditorProps { onChangeMarkdown: (markdown: string) => void; @@ -1121,7 +1122,7 @@ const checker = { }, codesandbox: (text: string) => { const regex = - /^$/s; + /^$/; const result = regex.exec(text); if (!result) return null; return result[1]; @@ -1129,7 +1130,6 @@ const checker = { codepen: (text: string) => { const regex = /^ - {isDisplayAd ? ( - - ) : ( - - )} + ); } diff --git a/src/containers/post/PostViewer.tsx b/src/containers/post/PostViewer.tsx index 5406f8f1..d991cc98 100644 --- a/src/containers/post/PostViewer.tsx +++ b/src/containers/post/PostViewer.tsx @@ -46,6 +46,7 @@ import gtag from '../../lib/gtag'; import FollowButton from '../../components/common/FollowButton'; import { BANNER_ADS } from '../../lib/graphql/ad'; import PostBanner from '../../components/post/PostBanner'; +import JobPositions from '../../components/post/JobPositions'; const UserProfileWrapper = styled(VelogResponsive)` margin-top: 16rem; @@ -75,6 +76,7 @@ const PostViewer: React.FC = ({ }) => { const setShowFooter = useSetShowFooter(); const [showRecommends, setShowRecommends] = useState(false); + useEffect(() => { window.scrollTo(0, 0); }, [username, urlSlug]); @@ -101,6 +103,7 @@ const PostViewer: React.FC = ({ ); const prefetched = useRef(false); + const [isRemoveLoading, setIsRemoveLoading] = useState(false); const [removePost] = useMutation(REMOVE_POST); const [postView] = useMutation(POST_VIEW); const [likePost, { loading: loadingLike }] = useMutation(LIKE_POST); @@ -220,7 +223,7 @@ const PostViewer: React.FC = ({ const isOwnPost = post.user.id === userId; const isVeryOld = Date.now() - new Date(post.released_at).getTime() > - 1000 * 60 * 60 * 24 * 30; + 1000 * 60 * 60 * 24 * 10; if (isOwnPost) return false; if (!isVeryOld) return false; @@ -250,17 +253,81 @@ const PostViewer: React.FC = ({ } }, [customAd, shouldShowBanner, shouldShowFooterBanner]); + // const category = useMemo(() => { + // const frontendKeywords = [ + // '프런트엔드', + // '리액트', + // 'vue', + // 'react', + // 'next', + // '프론트엔드', + // ]; + // const backendKeywords = ['백엔드', '서버', '데이터베이스', 'db']; + // const aiKeywords = ['인공지능', '머신러닝', '딥러닝', 'nlp', 'llm']; + // const mobileKeywords = [ + // '안드로이드', + // 'ios', + // 'react native', + // '플러터', + // 'flutter', + // 'swift', + // 'xcode', + // ]; + // const pythonKeywords = ['파이썬', 'python']; + // const nodeKeywords = ['노드', 'node', 'express', 'koa', 'nest']; + + // if (!data?.post) return null; + // const { post } = data; + // const merged = post.title + // .concat(post.tags.join(',')) + // .concat(post.body) + // .toLowerCase(); + // if ( + // aiKeywords.some((keyword) => { + // const value = merged.includes(keyword); + // if (value) { + // // console.log(merged); + // // console.log(keyword); + // } + // return value; + // }) + // ) + // return 'ai'; + // if (frontendKeywords.some((keyword) => merged.includes(keyword))) + // return 'frontend'; + // if (mobileKeywords.some((keyword) => merged.includes(keyword))) + // return 'mobile'; + // if (pythonKeywords.some((keyword) => merged.includes(keyword))) + // return 'python'; + // if (backendKeywords.some((keyword) => merged.includes(keyword))) + // return 'backend'; + // if (nodeKeywords.some((keyword) => merged.includes(keyword))) return 'node'; + // return null; + // }, [data]); + const onRemove = async () => { if (!data || !data.post) return; + setIsRemoveLoading(true); try { await removePost({ variables: { id: data.post.id, }, }); - history.push('/'); - await client.resetStore(); - } catch (e) {} + + toast.success('게시글이 성공적으로 삭제되었습니다.', { + autoClose: 800, + onClose: () => { + const redirect = `${process.env + .REACT_APP_CLIENT_V3_HOST!}/@${username}/posts`; + window.location.href = redirect; + }, + }); + } catch (e) { + toast.error('게시글 삭제 실패'); + console.error('Post deletion failed:', e); + setIsRemoveLoading(false); + } }; if (error) { @@ -377,7 +444,7 @@ const PostViewer: React.FC = ({ } }; - if (!data || !data.post) return ; + if (!data || !data.post || isRemoveLoading) return ; const { post } = data; @@ -454,7 +521,9 @@ const PostViewer: React.FC = ({ /> } /> - {shouldShowBanner ? : null} + {shouldShowBanner ? ( + + ) : null} = ({ {shouldShowBanner && isContentLongEnough ? ( - + ) : null} {shouldShowFooterBanner ? ( @@ -485,6 +554,10 @@ const PostViewer: React.FC = ({ postId={post.id} ownPost={post.user.id === userId} /> + {/* {(shouldShowBanner || shouldShowFooterBanner) && !customAd ? ( + + ) : null} */} + {showRecommends ? ( ) : null} diff --git a/src/containers/register/RegisterFormContainer.tsx b/src/containers/register/RegisterFormContainer.tsx index ddde94ab..d58a3f3d 100644 --- a/src/containers/register/RegisterFormContainer.tsx +++ b/src/containers/register/RegisterFormContainer.tsx @@ -16,6 +16,8 @@ import { withRouter, RouteComponentProps } from 'react-router-dom'; import qs from 'qs'; import { useApolloClient } from '@apollo/react-hooks'; import { GET_CURRENT_USER } from '../../lib/graphql/user'; +import { isEmpty, trim } from 'ramda'; +import { isEmptyOrWhitespace } from '../../lib/utils'; interface RegisterFormContainerProps extends RouteComponentProps<{}> {} @@ -65,18 +67,23 @@ const RegisterFormContainer: React.FC = ({ const onSubmit = async (form: RegisterFormType) => { setError(null); // validate + const validation = { displayName: (text: string) => { - if (text === '') { - return '이름을 입력해주세요.'; + if (isEmptyOrWhitespace(text)) { + return '프로필 이름을 입력해주세요.'; } - if (text.length > 45) { + if (text.trim().length > 45) { return '이름은 최대 45자까지 입력 할 수 있습니다.'; } }, username: (text: string) => { + if (isEmptyOrWhitespace(text)) { + return '사용자 ID를 입력해주세요.'; + } + if (!/^[a-z0-9-_]{3,16}$/.test(text)) { - return '아이디는 3~16자의 알파벳 소문자,숫자,혹은 - _ 으로 이루어져야 합니다.'; + return '사용자 ID는 3~16자의 알파벳 소문자,숫자,혹은 - _ 으로 이루어져야 합니다.'; } }, shortBio: (text: string) => { diff --git a/src/containers/velog/SeriesPosts.tsx b/src/containers/velog/SeriesPosts.tsx index 4ad34970..ba736ee9 100644 --- a/src/containers/velog/SeriesPosts.tsx +++ b/src/containers/velog/SeriesPosts.tsx @@ -40,6 +40,7 @@ const SeriesPosts: React.FC = ({ username, urlSlug }) => { const user = useUser(); const isOwnSeries = user && user.username === username; const [askRemove, setAskRemove] = useState(false); + const [isSkip, setIsSkip] = useState(false); const [removeSeries] = useMutation(REMOVE_SERIES); const { data } = useQuery(GET_SERIES, { variables: { @@ -47,6 +48,7 @@ const SeriesPosts: React.FC = ({ username, urlSlug }) => { url_slug: urlSlug, }, fetchPolicy: 'cache-and-network', + skip: isSkip, }); const client = useApolloClient(); @@ -54,20 +56,35 @@ const SeriesPosts: React.FC = ({ username, urlSlug }) => { const onAskRemove = () => setAskRemove(true); const onConfirmRemove = async () => { try { + setIsSkip(true); + setAskRemove(false); + + if (!data?.series?.id) { + throw new Error('Series ID is not available'); + } + await removeSeries({ variables: { - id: data?.series?.id, + id: data.series.id, }, }); + await client.resetStore(); - toast.success('시리즈가 삭제되었습니다.'); - // history.replace(`/@${username}/series/`); - window.location.href = `${process.env - .REACT_APP_CLIENT_V3_HOST!}/@${username}/series`; + + toast.success('시리즈가 성공적으로 삭제되었습니다.', { + autoClose: 800, + onClose: () => { + const redirect = `${process.env + .REACT_APP_CLIENT_V3_HOST!}/@${username}/series`; + window.location.href = redirect; + }, + }); } catch (e) { toast.error('시리즈 삭제 실패'); + console.error('Series deletion failed:', e); } }; + const onCancelRemove = () => { setAskRemove(false); }; diff --git a/src/containers/write/MarkdownEditorContainer.tsx b/src/containers/write/MarkdownEditorContainer.tsx index 40e7e74f..1dcf19fc 100644 --- a/src/containers/write/MarkdownEditorContainer.tsx +++ b/src/containers/write/MarkdownEditorContainer.tsx @@ -59,9 +59,11 @@ const MarkdownEditorContainer: React.FC = () => { initialBody, initialTitle, tags, + isPrivate, + selectedSeries, } = useSelector((state: RootState) => state.write); const uncachedClient = useUncachedApolloClient(); - const [writePost, { loading: writePostLoading }] = + const [writePost, { loading: isWritePostLoading }] = useMutation(WRITE_POST, { client: uncachedClient, }); @@ -70,12 +72,10 @@ const MarkdownEditorContainer: React.FC = () => { const titleRef = useRef(title); const [createPostHistory] = useMutation(CREATE_POST_HISTORY); - const [editPost, { loading: editPostLoading }] = useMutation( - EDIT_POST, - { + const [editPost, { loading: isEditPostLoading }] = + useMutation(EDIT_POST, { client: uncachedClient, - }, - ); + }); const [lastSavedData, setLastSavedData] = useState({ title: initialTitle, @@ -152,17 +152,17 @@ const MarkdownEditorContainer: React.FC = () => { const onTempSave = useCallback( async (notify?: boolean) => { - if (writePostLoading || editPostLoading) return; - if (!title || !markdown) { - toast.error('제목 또는 내용이 비어있습니다.'); - return; - } - const notifySuccess = () => { if (!notify) return; toast.success('포스트가 임시저장되었습니다.'); }; + if (isWritePostLoading || isEditPostLoading) return; + if (!title || !markdown) { + toast.error('제목 또는 내용이 비어있습니다.'); + return; + } + if (!postId) { const response = await writePost({ variables: { @@ -188,25 +188,24 @@ const MarkdownEditorContainer: React.FC = () => { } // tempsaving unreleased post: - if (isTemp) { + if (postId) { await editPost({ variables: { id: postId, title, body: markdown, is_markdown: true, - is_temp: true, - is_private: false, + is_temp: isTemp, + is_private: isPrivate, url_slug: escapeForUrl(title), thumbnail: null, meta: {}, - series_id: null, + series_id: selectedSeries?.id || null, tags, token: null, }, }); notifySuccess(); - return; } // tempsaving released post: @@ -236,15 +235,17 @@ const MarkdownEditorContainer: React.FC = () => { dispatch, editPost, history, - isTemp, lastSavedData, markdown, postId, tags, title, writePost, - writePostLoading, - editPostLoading, + isWritePostLoading, + isEditPostLoading, + isTemp, + isPrivate, + selectedSeries, ], ); @@ -282,6 +283,7 @@ const MarkdownEditorContainer: React.FC = () => { dispatch(setWritePostId(id)); history.replace(`/write?id=${id}`); } + if (!id) return; const url = URL.createObjectURL(file); @@ -312,7 +314,7 @@ const MarkdownEditorContainer: React.FC = () => { const timeoutId = setTimeout(() => { if (!postId && !title && markdown.length < 30) return; onTempSave(true); - }, 10 * 1000); + }, 1000 * 10); return () => { clearTimeout(timeoutId); diff --git a/src/index.server.ts b/src/index.server.ts index 37cbb20d..a6f4b617 100644 --- a/src/index.server.ts +++ b/src/index.server.ts @@ -4,12 +4,33 @@ import serve from 'koa-static'; import proxy from 'koa-better-http-proxy'; import Router from '@koa/router'; import ssrMiddleware from './server/ssrMiddleware'; +import rateLimitMiddleware from './server/rateLimitMiddleware'; +import compress from 'koa-compress'; +import { constants } from 'zlib'; console.log(process.env.REACT_APP_SSR); const app = new Koa(); const router = new Router(); +app.proxy = true; + +app.use( + compress({ + filter(contentType) { + return /text/i.test(contentType); + }, + threshold: 2048, + gzip: { + flush: constants.Z_SYNC_FLUSH, + }, + deflate: { + flush: constants.Z_SYNC_FLUSH, + }, + br: false, // disable brotli + }), +); + app.use( serve(path.resolve('./build'), { index: false, @@ -22,10 +43,11 @@ const proxyMiddleware = proxy( ); app.use(router.routes()).use(router.allowedMethods()); +app.use(rateLimitMiddleware); app.use(ssrMiddleware); app.use(proxyMiddleware); router.get('/ads.txt', (ctx) => { - ctx.body = `google.com, pub-9161852896103498, DIRECT, f08c47fec0942fa0`; + ctx.body = `google.com, pub-5574866530496701, DIRECT, f08c47fec0942fa0`; }); // router.post('/graphql', proxyMiddleware); diff --git a/src/lib/api/apiClient.ts b/src/lib/api/apiClient.ts index 891fe291..3ca121e5 100644 --- a/src/lib/api/apiClient.ts +++ b/src/lib/api/apiClient.ts @@ -4,6 +4,7 @@ const host = process.env.NODE_ENV === 'development' ? '/' : process.env.REACT_APP_API_HOST || '/'; + const apiClient = axios.create({ baseURL: host, withCredentials: true, diff --git a/src/lib/api/jobs.ts b/src/lib/api/jobs.ts new file mode 100644 index 00000000..9329a863 --- /dev/null +++ b/src/lib/api/jobs.ts @@ -0,0 +1,21 @@ +import axios from 'axios'; + +const secondaryApiClient = axios.create({ + baseURL: 'https://cdn.velev.io/', +}); + +export async function getJobs(category: string) { + const response = await secondaryApiClient.get(`/jobs/${category}`); + return response.data; +} + +export type Job = { + id: number; + name: string; + companyName: string; + companyLogo: string; + thumbnail: string; + url: string; + jobId: number; + summary: string; +}; diff --git a/src/lib/graphql/ad.ts b/src/lib/graphql/ad.ts index 483be812..63c39656 100644 --- a/src/lib/graphql/ad.ts +++ b/src/lib/graphql/ad.ts @@ -8,6 +8,15 @@ export type Ad = { url: string; }; +export type JobPosition = { + id: string; + name: string; + companyName: string; + companyLogo: string; + thumbnail: string; + url: string; +}; + export const BANNER_ADS = gql` query BannerAds($writerUsername: String!) { bannerAds(writer_username: $writerUsername) { @@ -19,3 +28,16 @@ export const BANNER_ADS = gql` } } `; + +export const JOB_POSITIONS = gql` + query JobPositions($category: String) { + jobPositions(category: $category) { + id + name + companyName + companyLogo + thumbnail + url + } + } +`; diff --git a/src/lib/remark/prismPlugin.js b/src/lib/remark/prismPlugin.js index 1eee6d74..2007f8ec 100644 --- a/src/lib/remark/prismPlugin.js +++ b/src/lib/remark/prismPlugin.js @@ -28,6 +28,7 @@ import 'prismjs/components/prism-ruby.min'; import 'prismjs/components/prism-rust.min'; import 'prismjs/components/prism-yaml.min'; import 'prismjs/components/prism-dart'; +import 'prismjs/components/prism-docker'; import { ssrEnabled } from '../utils'; @@ -73,5 +74,5 @@ export default function attacher({ include, exclude } = {}) { // 'language-' + lang, // ]; } - return ast => visit(ast, 'code', visitor); + return (ast) => visit(ast, 'code', visitor); } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 4f46ab36..4f98462f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,7 @@ import distanceInWordsToNow from 'date-fns/formatDistanceToNow'; import format from 'date-fns/format'; import koLocale from 'date-fns/locale/ko'; +import { isEmpty, trim } from 'ramda'; export const formatDate = (date: string): string => { const d = new Date(date); @@ -115,3 +116,10 @@ export const createFallbackTitle = (username: string | null) => { }; export const ssrEnabled = process.env.REACT_APP_SSR === 'enabled'; + +export const isEmptyOrWhitespace = (str: string) => { + if (typeof str !== 'string') { + return isEmpty(str); + } + return isEmpty(trim(str.replace(/\s/g, ''))); +}; diff --git a/src/server/CacheManager.ts b/src/server/CacheManager.ts index 145c54ff..054692e5 100644 --- a/src/server/CacheManager.ts +++ b/src/server/CacheManager.ts @@ -1,7 +1,7 @@ import Redis from 'ioredis'; import checkCacheRule from './checkCacheRule'; -const redis = new Redis({ +export const redis = new Redis({ host: process.env.REACT_APP_REDIS_HOST || 'localhost', }); @@ -18,9 +18,10 @@ export default class CacheManager { return null; } } - set(url: string, html: string) { + async set(url: string, html: string) { const rule = checkCacheRule(url); if (!rule) return; - return redis.set(createCacheKey(url), html); + await redis.set(createCacheKey(url), html); + await redis.expire(createCacheKey(url), 300); } } diff --git a/src/server/Html.tsx b/src/server/Html.tsx index 5fa9641b..52b25f69 100644 --- a/src/server/Html.tsx +++ b/src/server/Html.tsx @@ -74,7 +74,7 @@ function Html({ > */}