closeModal()}
81 | />
82 | >
83 | )}
84 | >
85 | );
86 | };
87 |
88 | export default memo(Modal);
89 |
--------------------------------------------------------------------------------
/src/components/navigation/post/brunch-list.tsx:
--------------------------------------------------------------------------------
1 | import styles from './list.module.scss';
2 |
3 | type Props = {
4 | url: string;
5 | dateTime: string;
6 | title: string;
7 | };
8 |
9 | const BrunchList: React.FC
= ({ url, dateTime, title }) => {
10 | // replaceAll('-', '.') 대신 정규식을 써서 replace(/-/g, '.')로 해야 빌드 에러 안 남
11 | const displayDateTime = dateTime.replace(/-/g, '.');
12 |
13 | return (
14 |
15 |
16 |
17 | {title}
18 |
19 |
20 | );
21 | };
22 |
23 | export default BrunchList;
24 |
--------------------------------------------------------------------------------
/src/components/navigation/post/list.module.scss:
--------------------------------------------------------------------------------
1 | @use '../../../styles/motion.module';
2 | @use '../../../styles/text-styles.module';
3 |
4 | .post__list {
5 | border-bottom: var(--g6) 1px solid;
6 | display: flex;
7 | height: 100%;
8 | cursor: pointer;
9 | @include motion.scale__hover;
10 |
11 | a {
12 | padding: 96px 0;
13 | width: 100%;
14 |
15 | @media screen and (max-width: text-styles.$text__second__max__width) {
16 | padding: 48px 0;
17 | }
18 | }
19 |
20 | time {
21 | color: var(--g4);
22 | margin-left: 2px; // 시각 보정
23 | @include text-styles.body4;
24 | }
25 |
26 | h1 {
27 | margin-top: 12px;
28 | @include text-styles.title2(500);
29 |
30 | @media screen and (max-width: text-styles.$text__second__max__width) {
31 | margin-top: 8px;
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/navigation/post/list.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { checkPublishedDate } from '../../../lib/utils/get-date';
3 | import { TStatus } from '../../../redux-toolkit/model/post-data-model';
4 | import styles from './list.module.scss';
5 |
6 | type Props = {
7 | category: string;
8 | order: string;
9 | title: string;
10 | dateTime: string;
11 | status: TStatus;
12 | };
13 |
14 | const List: React.FC = ({
15 | category,
16 | order,
17 | title,
18 | dateTime,
19 | status,
20 | }) => {
21 | const { seoDate, displayDate } = checkPublishedDate(status, dateTime);
22 |
23 | return (
24 | <>
25 |
26 |
27 |
28 |
29 | {title}
30 |
31 |
32 |
33 | >
34 | );
35 | };
36 |
37 | export default List;
38 |
--------------------------------------------------------------------------------
/src/components/navigation/post/post-list.module.scss:
--------------------------------------------------------------------------------
1 | @use '../../../styles/common';
2 | @use '../../../styles/motion.module';
3 | @use '../../../styles/text-styles.module';
4 |
5 | .post__list__main__layout {
6 | margin: 0 auto 240px;
7 | max-width: common.$list__max__width;
8 |
9 | @media screen and (max-width: common.$footer__media__max__width) {
10 | margin: 0 auto 180px;
11 | }
12 |
13 | @media screen and (max-width: common.$post__list__media__max__width) {
14 | padding: common.$main__layout__padding;
15 | }
16 | }
17 |
18 | .category__filter__nav {
19 | margin: 96px auto 0;
20 | max-width: common.$list__max__width;
21 | display: flex;
22 | gap: 12px;
23 |
24 | @media screen and (max-width: common.$post__list__media__max__width) {
25 | padding: common.$main__layout__padding;
26 | }
27 |
28 | @media screen and (max-width: text-styles.$text__second__max__width) {
29 | margin: 24px auto 0;
30 | }
31 |
32 | a {
33 | padding: 8px 12px;
34 | border: 1px solid var(--g6);
35 | @include motion.opacity__p__color__hover;
36 |
37 | @media screen and (max-width: text-styles.$text__second__max__width) {
38 | padding: 6px 10px;
39 | }
40 | }
41 | }
42 |
43 | .category__filter__selected {
44 | color: var(--p2);
45 | border: 1px solid var(--p1) !important;
46 | font-weight: 500;
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/navigation/post/post-list.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import Link from 'next/link';
3 | import { useRouter } from 'next/router';
4 | import { FC } from 'react';
5 | import { brunchShareDesignList } from '../../../data/brunch-list';
6 | import BrunchList from './brunch-list';
7 | import styles from './post-list.module.scss';
8 | import List from './list';
9 | import { TListData } from '../../../pages';
10 |
11 | type Props = {
12 | designPostListData: TListData;
13 | devPostListData: TListData;
14 | brandPostListData: TListData;
15 | allPostsListData: TListData;
16 | };
17 |
18 | const PostList: FC = ({
19 | designPostListData,
20 | devPostListData,
21 | brandPostListData,
22 | allPostsListData,
23 | }) => {
24 | const router = useRouter();
25 | const pathname = router.pathname;
26 | const asPath = router.asPath;
27 | const postList =
28 | asPath === '/design'
29 | ? designPostListData
30 | : asPath === '/dev'
31 | ? devPostListData
32 | : asPath === '/brand'
33 | ? brandPostListData
34 | : allPostsListData;
35 |
36 | const showBrunchShareDesignList: boolean =
37 | pathname === '/' || asPath === '/design';
38 |
39 | return (
40 | <>
41 |
42 |
43 |
66 |
67 | >
68 | );
69 | };
70 |
71 | export default PostList;
72 |
73 | type CategoryFilterProps = {
74 | pathname: string;
75 | asPath: string;
76 | };
77 |
78 | const CategoryFilter: FC = ({ pathname, asPath }) => {
79 | const categoryFilterList = [
80 | { href: '/', label: '모두' },
81 | { href: '/design', label: '디자인' },
82 | { href: '/dev', label: '개발' },
83 | { href: '/brand', label: '브랜드' },
84 | ];
85 | const listClassname = (href: string) => {
86 | return classNames(
87 | 'body3__400',
88 | (asPath === href || pathname === href) &&
89 | styles.category__filter__selected
90 | );
91 | };
92 |
93 | return (
94 |
101 | );
102 | };
103 |
--------------------------------------------------------------------------------
/src/components/navigation/table-of-contents/table-of-contents.module.scss:
--------------------------------------------------------------------------------
1 | @use '../../../styles/motion.module';
2 | @use '../../../styles/text-styles.module';
3 |
4 | .aside {
5 | margin: 48px 0;
6 | padding-bottom: 36px;
7 | border-top: var(--g2) 4px solid;
8 | border-bottom: var(--g6) 1px solid;
9 |
10 | @media screen and (max-width: text-styles.$text__first__max__width) {
11 | display: none;
12 | }
13 |
14 | a {
15 | @include motion.scale__hover;
16 | }
17 | }
18 |
19 | .heading2 {
20 | @include text-styles.body1(500);
21 | margin: 42px 4px 8px;
22 | }
23 |
24 | .heading3 {
25 | @include text-styles.body3;
26 | margin: 8px 4px 0;
27 | color: var(--g3);
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/navigation/table-of-contents/table-of-contents.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { FC, memo } from 'react';
3 | import styles from './table-of-contents.module.scss';
4 |
5 | export type TTableOfContentsData = {
6 | blockType: 'Heading2' | 'Heading3';
7 | blockId: string;
8 | html: string;
9 | };
10 |
11 | type Props = {
12 | tableOfContentsData?: TTableOfContentsData[];
13 | };
14 |
15 | const TableOfContents: FC = ({ tableOfContentsData }) => {
16 | return (
17 |
49 | );
50 | };
51 |
52 | export default memo(TableOfContents);
53 | // https://www.emgoto.com/react-table-of-contents/
54 |
--------------------------------------------------------------------------------
/src/components/post/article/article.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 | import {
3 | IParagraphData,
4 | TStatus,
5 | } from '../../../redux-toolkit/model/post-data-model';
6 | import TableOfContents, {
7 | TTableOfContentsData,
8 | } from '../../navigation/table-of-contents/table-of-contents';
9 | import Message from './message/message';
10 | import WYSIWYG from './paragraph/wysiwyg';
11 | import TitleWYSIWYG from './title/title-wysiwyg';
12 |
13 | type Props = {
14 | contentEditable: boolean;
15 | articleTitleWysiwygData: {
16 | title: string;
17 | dateTime: string;
18 | status: TStatus;
19 | };
20 | wysiwygDataArray: IParagraphData[];
21 | tableOfContentsData?: TTableOfContentsData[];
22 | };
23 |
24 | const Article: FC = ({
25 | contentEditable,
26 | articleTitleWysiwygData,
27 | wysiwygDataArray,
28 | tableOfContentsData,
29 | }) => {
30 | const isTableOfContentsData: boolean =
31 | tableOfContentsData === undefined
32 | ? false
33 | : tableOfContentsData.length !== 0;
34 |
35 | // console.log('isTableOfContentsData', isTableOfContentsData);
36 |
37 | return (
38 |
39 |
45 | {isTableOfContentsData && (
46 |
47 | )}
48 |
49 |
53 |
54 | );
55 | };
56 |
57 | export default Article;
58 |
--------------------------------------------------------------------------------
/src/components/post/article/message/message.module.scss:
--------------------------------------------------------------------------------
1 | @use '../../../../styles/text-styles.module';
2 |
3 | .message {
4 | margin-top: 96px;
5 | display: flex;
6 | align-items: center;
7 |
8 | @media screen and (max-width: text-styles.$text__first__max__width) {
9 | margin-top: 72px;
10 | }
11 |
12 | @media screen and (max-width: text-styles.$text__second__max__width) {
13 | margin-top: 56px;
14 | }
15 |
16 | span {
17 | width: 4px;
18 | height: 21px;
19 | background-color: var(--g2);
20 | margin-right: 8px;
21 |
22 | @media screen and (max-width: text-styles.$text__first__max__width) {
23 | width: 3px;
24 | height: 20px;
25 | }
26 |
27 | @media screen and (max-width: text-styles.$text__second__max__width) {
28 | width: 3px;
29 | height: 19px;
30 | margin-right: 7px;
31 | }
32 | }
33 |
34 | p {
35 | @include text-styles.body1(500);
36 | color: var(--g2);
37 | margin-top: 1px; // 시각 보정
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/post/article/message/message.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { FC } from 'react';
3 | import styles from './message.module.scss';
4 |
5 | const Message: FC = () => {
6 | const router = useRouter();
7 | const query = router.query;
8 | const field = query.category === 'dev' ? '개발' : '디자인';
9 |
10 | console.log(query.category);
11 |
12 | return (
13 |
14 |
15 |
16 | {query.category === 'brand'
17 | ? '작은 앱 프로젝트의 성장 과정을 공유합니다.'
18 | : `${field} 경험을 공유합니다.`}
19 |
20 |
21 | );
22 | };
23 |
24 | export default Message;
25 |
--------------------------------------------------------------------------------
/src/components/post/article/paragraph/wysiwyg.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 | import { IParagraphData } from '../../../../redux-toolkit/model/post-data-model';
3 | import EditableElementSwitch from '../../../block-wysiwyg/editable-element-switch';
4 |
5 | type Props = {
6 | contentEditable: boolean;
7 | wysiwygDataArray: IParagraphData[];
8 | };
9 |
10 | const WYSIWYG: FC = ({ contentEditable, wysiwygDataArray }) => {
11 | return (
12 |
13 | {wysiwygDataArray.map((data, idx) => (
14 |
23 | ))}
24 |
25 | );
26 | };
27 |
28 | export default WYSIWYG;
29 |
--------------------------------------------------------------------------------
/src/components/post/article/title/profile.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import Link from 'next/link';
3 | import { useRouter } from 'next/router';
4 | import { FC } from 'react';
5 | import styles from './title-wysiwyg.module.scss';
6 |
7 | const Profile: FC = () => {
8 | // const router = useRouter();
9 | // const query = router.query;
10 | // const career =
11 | // query.category === 'design' ? '프로덕트 디자이너' : '프론트엔드 개발자';
12 |
13 | return (
14 |
15 |
16 |
17 |
23 | 김경환 · 프로덕트 디자이너
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default Profile;
31 |
--------------------------------------------------------------------------------
/src/components/post/article/title/title-wysiwyg.module.scss:
--------------------------------------------------------------------------------
1 | @use '../../../../styles/common';
2 | @use '../../../../styles/text-styles.module';
3 | @use '../../../../styles/motion.module';
4 |
5 | .article__title__header {
6 | time {
7 | color: var(--g4);
8 | margin-left: 2px; // 시각 보정
9 | }
10 |
11 | h1 {
12 | margin-top: 6px;
13 | }
14 | }
15 |
16 | .profile__a {
17 | @include text-styles.body4(500);
18 | margin-top: 24px;
19 | margin-left: 1px; // 시각 보정
20 | display: inline-flex;
21 | align-items: center;
22 | position: relative; // NextJs Image 경고 제거
23 |
24 | @media screen and (max-width: text-styles.$text__first__max__width) {
25 | margin-top: 20px;
26 | }
27 |
28 | @media screen and (max-width: text-styles.$text__second__max__width) {
29 | margin-top: 16px;
30 | }
31 |
32 | // NextJs Image
33 | span:nth-child(1) {
34 | width: 36px !important;
35 | height: 36px !important;
36 | position: relative !important;
37 | border-radius: 50%;
38 |
39 | @media screen and (max-width: text-styles.$text__first__max__width) {
40 | width: 33px !important;
41 | height: 33px !important;
42 | }
43 |
44 | @media screen and (max-width: text-styles.$text__second__max__width) {
45 | width: 30px !important;
46 | height: 30px !important;
47 | }
48 | }
49 |
50 | p {
51 | @include text-styles.body3;
52 | color: var(--g2);
53 | }
54 |
55 | p:nth-child(2) {
56 | margin-left: 12px;
57 |
58 | @media screen and (max-width: text-styles.$text__first__max__width) {
59 | margin-left: 11px;
60 | }
61 |
62 | @media screen and (max-width: text-styles.$text__second__max__width) {
63 | margin-left: 10px;
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/post/article/title/title-wysiwyg.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useEffect, useRef } from 'react';
2 | import { checkPublishedDate } from '../../../../lib/utils/get-date';
3 | import { TStatus } from '../../../../redux-toolkit/model/post-data-model';
4 | import { setArticleTitleData } from '../../../../redux-toolkit/slices/post-slice';
5 | import {
6 | setTempArticleDateTimeData,
7 | setTempArticleTitleData,
8 | } from '../../../../redux-toolkit/slices/temp-post-slice';
9 | import { useAppDispatch } from '../../../../redux-toolkit/store';
10 | import EditableTextBlock from '../../../block-wysiwyg/editable-element/text/editable-text-block';
11 | import styles from './title-wysiwyg.module.scss';
12 | import Profile from './profile';
13 | import SelectCategory from '../../../menu/select-category';
14 |
15 | type Props = {
16 | contentEditable: boolean;
17 | title: string;
18 | dateTime: string;
19 | status: TStatus;
20 | };
21 |
22 | const TitleWYSIWYG: FC = ({
23 | contentEditable,
24 | title,
25 | dateTime,
26 | status,
27 | }) => {
28 | const { seoDate, displayDate } = checkPublishedDate(status, dateTime);
29 | const dispatch = useAppDispatch();
30 |
31 | useEffect(() => {
32 | dispatch(setTempArticleDateTimeData({ seoDate }));
33 | }, [seoDate]);
34 |
35 | const setTempPostHtmlData = (inputHtml: string) => {
36 | dispatch(setTempArticleTitleData({ inputHtml }));
37 | };
38 |
39 | const setPostHtmlData = (inputHtml: string) => {
40 | dispatch(setArticleTitleData({ inputHtml }));
41 | };
42 |
43 | const ref = useRef(null);
44 |
45 | return (
46 |
47 | {contentEditable && }
48 |
49 |
58 |
59 |
60 | );
61 | };
62 |
63 | export default TitleWYSIWYG;
64 |
--------------------------------------------------------------------------------
/src/components/post/author/author.module.scss:
--------------------------------------------------------------------------------
1 | @use '../../../styles/common';
2 | @use '../../../styles/text-styles.module';
3 | @use '../../../styles/motion.module';
4 |
5 | .author__section {
6 | @include common.article__section__title__divider;
7 | }
8 |
9 | .author__img {
10 | position: relative; // NextJs Image 경고 제거
11 | display: flex;
12 | align-items: center;
13 | margin-top: 36px;
14 |
15 | @media screen and (max-width: text-styles.$text__first__max__width) {
16 | margin-top: 32px;
17 | }
18 |
19 | @media screen and (max-width: text-styles.$text__second__max__width) {
20 | margin-top: 24px;
21 | }
22 |
23 | div {
24 | margin-left: 24px;
25 |
26 | @media screen and (max-width: text-styles.$text__first__max__width) {
27 | margin-left: 22px;
28 | }
29 |
30 | @media screen and (max-width: text-styles.$text__second__max__width) {
31 | margin-left: 20px;
32 | }
33 | }
34 |
35 | h4 {
36 | @include text-styles.body1(500);
37 | }
38 |
39 | p {
40 | @include text-styles.body3(400);
41 | margin-top: 8px;
42 | color: var(--g2);
43 | }
44 |
45 | // NextJs Image
46 | span {
47 | min-width: 108px;
48 | width: 108px !important;
49 | height: 108px !important;
50 | position: relative !important;
51 | border-radius: 50%;
52 | // object-fit: contain;
53 |
54 | @media screen and (max-width: text-styles.$text__first__max__width) {
55 | min-width: 96px;
56 | width: 96px !important;
57 | height: 96px !important;
58 | }
59 |
60 | @media screen and (max-width: text-styles.$text__second__max__width) {
61 | min-width: 88px;
62 | width: 88px !important;
63 | height: 88px !important;
64 | }
65 | }
66 | }
67 |
68 | .link {
69 | @include motion.scale__hover;
70 |
71 | display: flex;
72 | justify-content: space-between;
73 | align-items: center;
74 | height: 100%;
75 | border: 1px solid var(--g6);
76 |
77 | padding: 40px 36px;
78 | margin: 28px 0;
79 |
80 | @media screen and (max-width: text-styles.$text__first__max__width) {
81 | padding: 36px 32px;
82 | }
83 |
84 | @media screen and (max-width: text-styles.$text__second__max__width) {
85 | padding: 28px 20px;
86 | margin: 16px 0;
87 | }
88 |
89 | p {
90 | @include text-styles.body1(500);
91 | margin-right: 48px;
92 | }
93 |
94 | svg {
95 | width: 100%;
96 | max-width: 24px;
97 | height: 24px;
98 |
99 | @media screen and (max-width: text-styles.$text__first__max__width) {
100 | height: 22px;
101 | }
102 |
103 | @media screen and (max-width: text-styles.$text__second__max__width) {
104 | height: 20px;
105 | }
106 | }
107 | }
108 |
109 | .left {
110 | display: flex;
111 | align-items: center;
112 | min-width: 94%;
113 |
114 | .logo {
115 | display: flex;
116 | align-items: center;
117 | }
118 |
119 | p {
120 | margin-left: 16px;
121 |
122 | @media screen and (max-width: text-styles.$text__first__max__width) {
123 | margin-left: 12px;
124 | }
125 |
126 | @media screen and (max-width: text-styles.$text__second__max__width) {
127 | margin-left: 8px;
128 | }
129 | }
130 |
131 | svg {
132 | width: 100%;
133 | min-width: 54px;
134 | max-width: 54px;
135 | height: 54px;
136 | border-radius: 12px;
137 |
138 | @media screen and (max-width: text-styles.$text__first__max__width) {
139 | min-width: 48px;
140 | height: 48px;
141 | border-radius: 10px;
142 | }
143 |
144 | @media screen and (max-width: text-styles.$text__second__max__width) {
145 | min-width: 40px;
146 | height: 40px;
147 | border-radius: 8px;
148 | }
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/src/components/post/author/author.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import Link from 'next/link';
3 | import { FC } from 'react';
4 | import IconNewTap24 from '../../../svg/icon-new-tap-24';
5 | import IconPlantFamilyLogo24 from '../../../svg/icon-plant-family-logo-24';
6 | import IconReminderLogo24 from '../../../svg/icon-reminder-logo-24';
7 | import IconTodayToDoLogo24 from '../../../svg/icon-today-todo-logo-24';
8 | import IconYoonSeulLogo24 from '../../../svg/icon-yoonseul-logo-24';
9 | import styles from './author.module.scss';
10 | import { REMINDER_APPSTORE_LINK } from '../../../constants/contants';
11 |
12 | const Author: FC = () => {
13 | const description: string =
14 | "많은 기능과 서비스를 갖추고 있는 슈퍼 앱들 사이에서 단순함을 가장 큰 목표로 하는 '작은 앱'이 사람들에게 어떠한 의미로 다가갈지 연구하는 '작은 앱 프로젝트'를 진행하고 있습니다.";
15 |
16 | return (
17 |
57 | );
58 | };
59 |
60 | export default Author;
61 |
62 | type Props = {
63 | link: string;
64 | icon: JSX.Element;
65 | name: string;
66 | };
67 |
68 | export const AuthorAppLink: FC = ({ link, icon, name }) => {
69 | return (
70 |
71 |
72 |
{icon}
73 |
{name}
74 |
75 |
76 |
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/src/components/post/post.module.scss:
--------------------------------------------------------------------------------
1 | @use '../../styles/common';
2 | @use '../../styles/text-styles.module';
3 |
4 | .post__main {
5 | max-width: common.$post__max__width;
6 | margin: 144px auto 240px;
7 | // overflow-x: hidden; // 갤럭시 가로 스크롤 방지 > 링크 블럭 호버 시 문제 생김!
8 |
9 | @media screen and (max-width: common.$post__media__max__width) {
10 | padding: common.$main__layout__padding;
11 | }
12 |
13 | @media screen and (max-width: text-styles.$text__first__max__width) {
14 | margin: 72px auto 144px;
15 | }
16 |
17 | @media screen and (max-width: text-styles.$text__second__max__width) {
18 | margin: 36px auto 96px;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/post/post.tsx:
--------------------------------------------------------------------------------
1 | import { FC, memo } from 'react';
2 | import LinkWYSIWYG from './reference/link-wysiwyg';
3 | import Article from '../../components/post/article/article';
4 | import { IPostData } from '../../redux-toolkit/model/post-data-model';
5 | import styles from './post.module.scss';
6 | import Response from './response/response';
7 | import { useRouter } from 'next/router';
8 | import Share from './share/share';
9 | import Author from './author/author';
10 | import { TTableOfContentsData } from '../navigation/table-of-contents/table-of-contents';
11 | import Subscription from './subscription/subscription';
12 |
13 | type Props = {
14 | contentEditable: boolean;
15 | postData: IPostData;
16 | tableOfContentsData?: TTableOfContentsData[];
17 | };
18 |
19 | const Post: FC = ({
20 | contentEditable,
21 | postData,
22 | tableOfContentsData,
23 | }) => {
24 | const articleTitleWysiwygData = {
25 | title: postData.title,
26 | dateTime: postData.dateTime,
27 | status: postData.status,
28 | };
29 |
30 | const router = useRouter();
31 | const pathname = router.pathname;
32 | const isPublishedPost = pathname === '/[category]/[order]';
33 | const query = router.query;
34 |
35 | return (
36 |
37 |
43 | {/* 3개의 박스가 정렬돼 있고, 호버하면 커지는 버튼 */}
44 | {/* */}
45 | {isPublishedPost && }
46 |
47 |
48 |
49 | {/* {query.category == 'dev' && } */}
50 |
51 |
52 | {query.category !== 'story' && (
53 |
57 | )}
58 |
59 | );
60 | };
61 |
62 | export default memo(Post);
63 |
--------------------------------------------------------------------------------
/src/components/post/reference/link-wysiwyg.module.scss:
--------------------------------------------------------------------------------
1 | @use '../../../styles/common';
2 |
3 | .reference__section {
4 | @include common.article__section__title__divider;
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/post/reference/link-wysiwyg.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 | import { ILinkData } from '../../../redux-toolkit/model/link-data-model';
3 | import EditableElementSwitch from '../../block-wysiwyg/editable-element-switch';
4 | import styles from './link-wysiwyg.module.scss';
5 |
6 | type Props = {
7 | contentEditable: boolean;
8 | linkWysiwygDataArray: ILinkData[];
9 | };
10 |
11 | const LinkWYSIWYG: FC = ({ contentEditable, linkWysiwygDataArray }) => {
12 | // 데이터는 2가지: 제목(클라이언트에서 한줄 다 차면 ...으로 표시), 링크
13 |
14 | return (
15 | <>
16 |
17 | 참고 자료
18 |
19 | {linkWysiwygDataArray.map((data, idx) => (
20 |
29 | ))}
30 |
31 |
32 | >
33 | );
34 | };
35 |
36 | // props는 첫 렌더링 이후 변하지 않으므로 memo 이용하면 렌더링 성능 극대화
37 | export default LinkWYSIWYG;
38 |
--------------------------------------------------------------------------------
/src/components/post/response/response-list/response-list.module.scss:
--------------------------------------------------------------------------------
1 | @use '../response.module';
2 | @use '../../../../styles/text-styles.module';
3 |
4 | .ul {
5 | li {
6 | margin-top: 72px;
7 |
8 | @media screen and (max-width: text-styles.$text__first__max__width) {
9 | margin-top: 64px;
10 | }
11 |
12 | @media screen and (max-width: text-styles.$text__second__max__width) {
13 | margin-top: 48px;
14 | }
15 | }
16 | }
17 |
18 | .response__text {
19 | @include text-styles.body1;
20 | color: var(--g3);
21 | margin-top: 16px;
22 | }
23 |
24 | .anonymous__profile__left__div {
25 | @include response.anonymous__profile__left;
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/post/response/response-list/response-list.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { FC, useEffect, useState } from 'react';
3 | import {
4 | getResponseDataFromRealtimeDB,
5 | TResponseData,
6 | } from '../../../../service/firebase/realtime-db';
7 | import styles from './response-list.module.scss';
8 |
9 | const ResponseList: FC = () => {
10 | const [responseList, setResponseList] = useState([]);
11 | const router = useRouter();
12 | const asPath = `/${router.query.category}/${router.query.order}`; // For TableOfContents
13 |
14 | useEffect(() => {
15 | const unSubscribeOnValueRealtimeDB = getResponseDataFromRealtimeDB(
16 | asPath,
17 | setResponseList
18 | );
19 |
20 | return () => {
21 | unSubscribeOnValueRealtimeDB();
22 | };
23 | }, []);
24 |
25 | return (
26 | <>
27 |
28 | {responseList.map((list, idx) => (
29 | -
30 |
31 |
36 |
{list.date}
37 |
38 | {list.responseText}
39 |
40 | ))}
41 |
42 | >
43 | );
44 | };
45 |
46 | export default ResponseList;
47 |
--------------------------------------------------------------------------------
/src/components/post/response/response.module.scss:
--------------------------------------------------------------------------------
1 | @use '../../../styles/common';
2 | @use '../../../styles/text-styles.module';
3 |
4 | .response__section {
5 | @include common.article__section__title__divider;
6 | }
7 |
8 | @mixin anonymous__profile__left {
9 | display: flex;
10 | align-items: center;
11 |
12 | span {
13 | border-radius: 50%;
14 | width: 32px;
15 | height: 32px;
16 | margin-right: 9px;
17 |
18 | @media screen and (max-width: text-styles.$text__first__max__width) {
19 | width: 30px;
20 | height: 30px;
21 | margin-right: 8px;
22 | }
23 |
24 | @media screen and (max-width: text-styles.$text__second__max__width) {
25 | width: 30px;
26 | height: 30px;
27 | margin-right: 8px;
28 | }
29 | }
30 |
31 | p {
32 | @include text-styles.body2(500);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/post/response/response.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 | import ResponseList from './response-list/response-list';
3 | import styles from './response.module.scss';
4 | import WriteResponse from './write-response/write-response';
5 |
6 | const Response: FC = () => {
7 | return (
8 | <>
9 |
14 | >
15 | );
16 | };
17 |
18 | export default Response;
19 |
--------------------------------------------------------------------------------
/src/components/post/response/write-response/write-response.module.scss:
--------------------------------------------------------------------------------
1 | @use '../../../../styles/text-styles.module';
2 | @use '../../../../styles/motion.module';
3 | @use '../response.module';
4 |
5 | .write__response {
6 | margin-top: 72px;
7 |
8 | @media screen and (max-width: text-styles.$text__first__max__width) {
9 | margin-top: 64px;
10 | }
11 |
12 | @media screen and (max-width: text-styles.$text__second__max__width) {
13 | margin-top: 48px;
14 | }
15 |
16 | textarea {
17 | @include text-styles.body1;
18 | margin-top: 24px;
19 | color: var(--g1);
20 | border-radius: 0; // 초기화
21 |
22 | -webkit-appearance: none; // remove iOS upper inner shadow
23 | resize: none; // 늘이고 줄이는 기능 없애기
24 | line-height: 1.7; // line-height 있어야 글씨 쓰기 시작할 때 네모가 바뀌지 않음.
25 |
26 | overflow: hidden;
27 | width: 100%;
28 | background-color: transparent;
29 | border: 1px solid var(--g6) !important; // 안드로이드 삼성 인터넷에서 작동 안 해서 !important
30 | padding: 32px;
31 |
32 | @media screen and (max-width: text-styles.$text__first__max__width) {
33 | margin-top: 20px;
34 | padding: 24px;
35 | }
36 |
37 | @media screen and (max-width: text-styles.$text__second__max__width) {
38 | margin-top: 16px;
39 | padding: 20px;
40 | }
41 | }
42 |
43 | textarea::placeholder {
44 | color: var(--g5);
45 | }
46 | }
47 |
48 | .anonymous__profile {
49 | display: flex;
50 | justify-content: space-between;
51 | align-items: center;
52 |
53 | button {
54 | @include text-styles.body2(500);
55 | @include motion.scale__hover;
56 | padding-right: 2px; // 시각 보정 + 터치 영역
57 | }
58 | }
59 |
60 | .anonymous__profile__left__div {
61 | @include response.anonymous__profile__left;
62 | }
63 |
64 | .response__submit__button {
65 | cursor: not-allowed;
66 | @include text-styles.body1;
67 | width: 100%;
68 | padding: 18px 0;
69 | margin-top: 16px;
70 |
71 | display: flex;
72 | justify-content: center;
73 | align-items: center;
74 |
75 | color: var(--g5);
76 | border: 1px solid var(--g6);
77 | transition: color 0.4s ease-in-out, border 0.4s ease-in-out;
78 |
79 | @media screen and (max-width: text-styles.$text__first__max__width) {
80 | padding: 16px 0;
81 | margin-top: 12px;
82 | }
83 |
84 | @media screen and (max-width: text-styles.$text__second__max__width) {
85 | padding: 14px 0;
86 | margin-top: 8px;
87 | }
88 | }
89 |
90 | .active__response__submit__button {
91 | cursor: pointer;
92 | @include motion.scale__hover;
93 | transition: color 0.4s ease-in-out, border 0.4s ease-in-out;
94 | font-weight: 500;
95 | color: var(--g1);
96 | border: 1px solid var(--g1);
97 | }
98 |
--------------------------------------------------------------------------------
/src/components/post/share/share.module.scss:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dalgudot/blog/4e7d00683ad5040e6271df650f91a8f248865097/src/components/post/share/share.module.scss
--------------------------------------------------------------------------------
/src/components/post/share/share.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 | import IconFacebook24 from '../../../svg/icon-facebook-24';
3 | import IconLinkedIn24 from '../../../svg/icon-linkedin-24';
4 | import IconTwitter24 from '../../../svg/icon-twitter-24';
5 | import styles from './share.module.scss';
6 |
7 | const Share: FC = () => {
8 | return (
9 |
14 | );
15 | };
16 |
17 | export default Share;
18 |
--------------------------------------------------------------------------------
/src/components/post/sponsor/sponsor.module.scss:
--------------------------------------------------------------------------------
1 | @use '../../../styles/common';
2 | @use '../../../styles/text-styles.module';
3 |
4 | .sponsor__section {
5 | @include common.article__section__title__divider;
6 | }
7 |
8 | .description {
9 | @include text-styles.body1(400);
10 |
11 | margin-top: 24px;
12 |
13 | @media screen and (max-width: text-styles.$text__first__max__width) {
14 | margin-top: 24px;
15 | }
16 |
17 | @media screen and (max-width: text-styles.$text__second__max__width) {
18 | margin-top: 16px;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/post/sponsor/sponsor.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 | import styles from './sponsor.module.scss';
3 | import li__styles from '../../block-wysiwyg/editable-element/link/editable-link-block.module.scss';
4 | import classNames from 'classnames';
5 | import IconNewTap24 from '../../../svg/icon-new-tap-24';
6 |
7 | const Sponsor: FC = () => {
8 | const description: string =
9 | '글이 도움이 되셨다면 아래 github 후원 페이지에서 후원을 부탁드립니다 :)';
10 |
11 | return (
12 |
32 | );
33 | };
34 |
35 | export default Sponsor;
36 |
--------------------------------------------------------------------------------
/src/components/post/subscription/subscription.module.scss:
--------------------------------------------------------------------------------
1 | @use '../../../styles/common';
2 | @use '../../../styles/text-styles.module';
3 |
4 | .subscription__section {
5 | @include common.article__section__title__divider;
6 | }
7 |
8 | .description {
9 | @include text-styles.body1(400);
10 |
11 | margin-top: 24px;
12 |
13 | @media screen and (max-width: text-styles.$text__first__max__width) {
14 | margin-top: 24px;
15 | }
16 |
17 | @media screen and (max-width: text-styles.$text__second__max__width) {
18 | margin-top: 16px;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/post/subscription/subscription.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 | import styles from './subscription.module.scss';
3 | import li__styles from '../../block-wysiwyg/editable-element/link/editable-link-block.module.scss';
4 | import classNames from 'classnames';
5 | import IconNewTap24 from '../../../svg/icon-new-tap-24';
6 |
7 | const Subscription: FC = () => {
8 | const description: string = '브런치에 디자인 경험을 공유하고 있습니다 :)';
9 |
10 | return (
11 |
31 | );
32 | };
33 |
34 | export default Subscription;
35 |
--------------------------------------------------------------------------------
/src/constants/contants.ts:
--------------------------------------------------------------------------------
1 | export const REMINDER_APPSTORE_LINK: string =
2 | 'https://apps.apple.com/kr/app/reminder-to-do-list/id6444939279';
3 |
--------------------------------------------------------------------------------
/src/data/brunch-list.ts:
--------------------------------------------------------------------------------
1 | export const brunchShareDesignList = [
2 | {
3 | url: 'https://share-design.dalgu.app/article/ui-ux-design/2',
4 | dateTime: '2021-04-18',
5 | title: '모션 디자인은 UX에 어떤 영향을 미칠까?',
6 | },
7 | {
8 | url: 'https://share-design.dalgu.app/article/ui-ux-design/1',
9 | dateTime: '2021-03-28',
10 | title: '언어 전환 토글 버튼의 UI/UX 디자인은?',
11 | },
12 | {
13 | url: 'https://brunch.co.kr/@dalgudot/124',
14 | dateTime: '2021-03-28',
15 | title: 'UI/UX 디자인 웹 포트폴리오 제작기',
16 | },
17 | {
18 | url: 'https://brunch.co.kr/@dalgudot/122',
19 | dateTime: '2020-05-30',
20 | title: '구매율 올리는 상세 페이지 UI/UX 디자인은?',
21 | },
22 | {
23 | url: 'https://brunch.co.kr/@dalgudot/121',
24 | dateTime: '2020-05-10',
25 | title: 'UI/UX 디자인 포트폴리오 제작기',
26 | },
27 | {
28 | url: 'https://brunch.co.kr/@dalgudot/110',
29 | dateTime: '2020-03-22',
30 | title: 'UI 디자인을 위한 UX 원칙 10가지',
31 | },
32 | {
33 | url: 'https://brunch.co.kr/@dalgudot/108',
34 | dateTime: '2020-02-02',
35 | title: '데이터 기반 UI/UX 디자인은?',
36 | },
37 | {
38 | url: 'https://brunch.co.kr/@dalgudot/93',
39 | dateTime: '2019-12-15',
40 | title: '전환율을 높이는 UI/UX 디자인은?',
41 | },
42 | {
43 | url: 'https://brunch.co.kr/@dalgudot/101',
44 | dateTime: '2019-09-16',
45 | title: '[실무편] 회원가입을 쉽게 만드는 UI/UX 디자인은?',
46 | },
47 | {
48 | url: 'https://brunch.co.kr/@dalgudot/98',
49 | dateTime: '2019-07-29',
50 | title: 'UI/UX 디자인 가이드_ 내비게이션 바',
51 | },
52 | {
53 | url: 'https://brunch.co.kr/@dalgudot/94',
54 | dateTime: '2019-05-20',
55 | title: 'UI 디자인을 위한 UX 원칙 5가지',
56 | },
57 | {
58 | url: 'https://brunch.co.kr/@dalgudot/74',
59 | dateTime: '2019-02-21',
60 | title: '회원가입을 쉽게 만드는 UI/UX 디자인은?',
61 | },
62 | ];
63 |
--------------------------------------------------------------------------------
/src/lib/hooks/useAfterInitialMount.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 |
3 | export const useAfterInitialMount = () => {
4 | const afterInitialMount = useRef(false);
5 | useEffect(() => {
6 | afterInitialMount.current = true;
7 | }, []);
8 |
9 | return afterInitialMount.current;
10 | };
11 |
--------------------------------------------------------------------------------
/src/lib/hooks/useCanvas.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, RefObject } from 'react';
2 |
3 | export const useCanvas = (
4 | canvasWidth: number,
5 | canvasHeight: number,
6 | animate: (ctx: CanvasRenderingContext2D) => void
7 | ) => {
8 | const canvasRef: RefObject =
9 | useRef(null);
10 |
11 | useEffect(() => {
12 | const canvas = canvasRef.current;
13 | const ctx = canvas?.getContext('2d');
14 |
15 | const setCanvas = () => {
16 | const devicePixelRatio = window.devicePixelRatio ?? 1;
17 |
18 | if (canvas && ctx) {
19 | canvas.style.width = canvasWidth + 'px';
20 | canvas.style.height = canvasHeight + 'px';
21 |
22 | canvas.width = canvasWidth * devicePixelRatio;
23 | canvas.height = canvasHeight * devicePixelRatio;
24 |
25 | ctx.scale(devicePixelRatio, devicePixelRatio);
26 | }
27 | };
28 | setCanvas();
29 |
30 | let requestId: number;
31 | const requestAnimation = () => {
32 | requestId = window.requestAnimationFrame(requestAnimation);
33 |
34 | if (ctx) {
35 | animate(ctx);
36 | }
37 | };
38 | requestAnimation();
39 |
40 | return () => {
41 | window.cancelAnimationFrame(requestId);
42 | };
43 | }, [canvasWidth, canvasHeight]);
44 |
45 | return canvasRef;
46 | };
47 |
--------------------------------------------------------------------------------
/src/lib/hooks/useClientWidthHeight.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, RefObject } from 'react';
2 |
3 | export const useClientWidthHeight = (ref: RefObject) => {
4 | const [width, setWidth] = useState(0);
5 | const [height, setHeight] = useState(0);
6 |
7 | useEffect(() => {
8 | const setClientWidthHeight = () => {
9 | if (ref.current) {
10 | setWidth(ref.current.clientWidth);
11 | setHeight(ref.current.clientHeight);
12 | }
13 | };
14 | setClientWidthHeight();
15 |
16 | window.addEventListener('resize', setClientWidthHeight);
17 |
18 | return () => {
19 | window.removeEventListener('resize', setClientWidthHeight);
20 | };
21 | }, []);
22 |
23 | const clientRects = { width, height };
24 |
25 | return clientRects;
26 | };
27 |
--------------------------------------------------------------------------------
/src/lib/hooks/useEditable.ts:
--------------------------------------------------------------------------------
1 | import { focusContentEditableTextToEnd } from './../utils/focus-content-editable-text-to-end';
2 | import { useEffect, useRef } from 'react';
3 | import { IParagraphData } from '../../redux-toolkit/model/post-data-model';
4 |
5 | export const useEditable = (
6 | html: string,
7 | addBlockFocusUseEffectDependency: IParagraphData,
8 | removeCurrentBlockFocusUseEffectDependency: IParagraphData
9 | ) => {
10 | // 블록 안에 ``을 추가하거나 블록을 지울 때의 focusing은 여기가 아닌 에서 관리해야 함.
11 | const ref = useRef(null);
12 |
13 | // 새로 생성된 블럭의 커서 위치, 다음 블럭이 지워졌을 때 focus()
14 | useEffect(() => {
15 | ref.current && focusContentEditableTextToEnd(ref.current);
16 | // *** html은 붙여넣기, add inline block 등
17 | // addBlockFocusUseEffectDependency = datas[currentIndex]은 `` 등 요소로 리렌더될 때 커서 위치 선정
18 | // removeCurrentBlockFocusUseEffectDependency = datas[currentIndex + 1]은 다음 블럭 지워진 걸 감지하는 의존성 배열 요소. 여기서 받아온 datas는 초기화 및 블럭의 생성과 삭제만 담당하는 클라이언트 데이터(post), 따라서 삭제된 시점을 정확히 알 수 있음.
19 | }, [
20 | // html,
21 | addBlockFocusUseEffectDependency,
22 | removeCurrentBlockFocusUseEffectDependency,
23 | ]);
24 |
25 | return ref;
26 | };
27 |
--------------------------------------------------------------------------------
/src/lib/hooks/useGetClientPostData.ts:
--------------------------------------------------------------------------------
1 | import { RootState, useAppSelector } from '../../redux-toolkit/store';
2 |
3 | export const useGetClientPostData = () => {
4 | // 초기화 및 map() 상태 관리(새로운 블럭 그리는 일 등)
5 | const { post } = useAppSelector((state: RootState) => state.post);
6 |
7 | return { post };
8 | };
9 |
--------------------------------------------------------------------------------
/src/lib/hooks/useGetClientTempPostData.ts:
--------------------------------------------------------------------------------
1 | import { RootState, useAppSelector } from '../../redux-toolkit/store';
2 |
3 | export const useGetClientTempPostData = () => {
4 | // 데이터 저장 위해(contentEditable 요소가 매번 렌더링될 때마다 생기는 문제 방지)
5 | const { tempPost } = useAppSelector((state: RootState) => state.tempPost);
6 |
7 | return { tempPost };
8 | };
9 |
--------------------------------------------------------------------------------
/src/lib/hooks/useInitializeClientData.ts:
--------------------------------------------------------------------------------
1 | import { postInitialData } from './../../redux-toolkit/model/post-data-model';
2 | import { setPostData } from '../../redux-toolkit/slices/post-slice';
3 | import { setTempPostData } from '../../redux-toolkit/slices/temp-post-slice';
4 | import { useAppDispatch } from '../../redux-toolkit/store';
5 |
6 | export const useInitializeClientData = () => {
7 | const dispatch = useAppDispatch();
8 |
9 | const initializeClientData = () => {
10 | dispatch(setPostData(postInitialData));
11 | dispatch(setTempPostData(postInitialData));
12 | };
13 |
14 | return initializeClientData;
15 | };
16 |
--------------------------------------------------------------------------------
/src/lib/hooks/useIsAdmin.ts:
--------------------------------------------------------------------------------
1 | import { RootState } from './../../redux-toolkit/store';
2 | import { useEffect, useState } from 'react';
3 | import { useAppDispatch, useAppSelector } from '../../redux-toolkit/store';
4 | import { setUid } from '../../redux-toolkit/slices/user-slice';
5 | import {
6 | Authentication,
7 | IAuthentication,
8 | } from '../../service/firebase/authentication';
9 | import { User } from 'firebase/auth';
10 |
11 | export const useIsAdmin = () => {
12 | const auth: IAuthentication = new Authentication();
13 | const [isAdmin, setIsAdmin] = useState(false);
14 | const { uid } = useAppSelector((state: RootState) => state.user);
15 | const dispatch = useAppDispatch();
16 |
17 | // https://firebase.google.com/docs/auth/web/manage-users?hl=ko
18 | useEffect(() => {
19 | const onUserChanged = (user: User) => {
20 | dispatch(setUid(user?.uid));
21 | };
22 | auth.onAuthChange(onUserChanged); // 한 세션(탭)에서 새로고침 시 로그인 유지
23 |
24 | uid === process.env.NEXT_PUBLIC_ADMIN_UID
25 | ? setIsAdmin(true)
26 | : setIsAdmin(false);
27 | }, [uid]);
28 |
29 | return { isAdmin };
30 | };
31 |
--------------------------------------------------------------------------------
/src/lib/hooks/useModal.ts:
--------------------------------------------------------------------------------
1 | import { atom, useRecoilState } from 'recoil';
2 |
3 | type modalType = 'Mobile GNB';
4 |
5 | export const modalState = atom<{
6 | type: modalType;
7 | open: boolean;
8 | activeAnimation: boolean;
9 | }>({
10 | key: 'modal',
11 | default: { type: 'Mobile GNB', open: false, activeAnimation: false },
12 | });
13 |
14 | export const useModal = (type: modalType) => {
15 | const [isModal, setIsModal] = useRecoilState(modalState);
16 | const openModal = () => {
17 | isModal.open === false &&
18 | setIsModal({ type, open: true, activeAnimation: true });
19 | };
20 |
21 | return {
22 | openModal,
23 | };
24 | };
25 |
--------------------------------------------------------------------------------
/src/lib/hooks/usePreventRightClick.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | export const usePreventRightClick = () => {
4 | useEffect(() => {
5 | // 마우스 오른쪽 클릭(oncontextmenu), 왼쪽 마우스 이미지 드래그(ondragstart), 왼쪽 마우스 문자 드래그(onselectstart), 키보드 단축키 복사(onkeydown) 막기
6 | // document.onkeydown은 확대 축소를 못하므로 제외
7 |
8 | // document.ondragstart =
9 | // document.onselectstart =
10 | document.oncontextmenu = () => {
11 | return false;
12 | };
13 | }, []);
14 | };
15 |
--------------------------------------------------------------------------------
/src/lib/hooks/useSetCaretPosition__afterAddInlineCode.ts:
--------------------------------------------------------------------------------
1 | import { MutableRefObject, useEffect, useState } from 'react';
2 |
3 | export const useSetCaretPosition__afterAddInlineCode = (
4 | eachBlockRef: MutableRefObject
5 | ) => {
6 | const [changeCaretPosition, setCaretPosition] = useState(
7 | undefined
8 | );
9 |
10 | useEffect(() => {
11 | // onInput 안에서 inlineCodeBlock이 업데이트되기 때문에,
12 | // onInput에 대한 렌더링이 완전히 끝난 후 여기서 업데이트!
13 | if (changeCaretPosition !== undefined) {
14 | const selection = window.getSelection();
15 | const targetNode =
16 | eachBlockRef.current.childNodes.length === 2
17 | ? eachBlockRef.current.childNodes[changeCaretPosition + 1] // 어떤 노드도 없는 경우에만 length가 2
18 | : eachBlockRef.current.childNodes[changeCaretPosition + 2]; // 코드 블럭 생기면 2개의 노드가 추가로 생기기 때문
19 |
20 | const newRange = document.createRange();
21 | newRange.setStart(targetNode, 1); // 코드 블럭 한 칸 뒤쪽 위치
22 |
23 | selection && selection.removeAllRanges();
24 | selection && selection.addRange(newRange);
25 |
26 | setCaretPosition(undefined); // 초기화
27 | }
28 | }, [changeCaretPosition]);
29 |
30 | return setCaretPosition;
31 | };
32 |
--------------------------------------------------------------------------------
/src/lib/hooks/useUpdateVisitors.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { updateTotalVisitors } from '../../service/firebase/realtime-db';
3 |
4 | export const useUpdateVisitors = () => {
5 | useEffect(() => {
6 | const unSubscribeOnValueRealtimeDB = updateTotalVisitors(); // 방문자 수 업데이트할지 안 할지도 결정
7 |
8 | return () => {
9 | unSubscribeOnValueRealtimeDB && unSubscribeOnValueRealtimeDB();
10 | };
11 | }, []);
12 | };
13 |
--------------------------------------------------------------------------------
/src/lib/hooks/useWindowInnerWidthHeight.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | export const useWindowInnerWidthHeight = () => {
4 | const [width, setWidth] = useState(0);
5 | const [height, setHeight] = useState(0);
6 |
7 | useEffect(() => {
8 | const setWindowInnerWidthHeight = () => {
9 | setWidth(window.innerWidth);
10 | setHeight(window.innerHeight);
11 | };
12 | setWindowInnerWidthHeight();
13 |
14 | window.addEventListener('resize', setWindowInnerWidthHeight);
15 |
16 | return () => {
17 | window.removeEventListener('resize', setWindowInnerWidthHeight);
18 | };
19 | }, []);
20 |
21 | const windowInnerWidthHeight = { width, height };
22 |
23 | return windowInnerWidthHeight;
24 | };
25 |
--------------------------------------------------------------------------------
/src/lib/utils/Math.ts:
--------------------------------------------------------------------------------
1 | // https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Math
2 |
3 | export const PI2 = 2 * Math.PI;
4 | export const EULER_NUMBER = Math.E;
5 |
--------------------------------------------------------------------------------
/src/lib/utils/data.ts:
--------------------------------------------------------------------------------
1 | export const objectToArray = (object: object) => {
2 | return Object.values(object);
3 | };
4 |
--------------------------------------------------------------------------------
/src/lib/utils/editable-block/add-inline-code.ts:
--------------------------------------------------------------------------------
1 | import { MutableRefObject } from 'react';
2 | import { getNewHtml, getNodeArray } from './node';
3 |
4 | export const frontTag = '';
5 | export const backTag = '
\u00A0';
6 |
7 | export const addInlineCode = (
8 | eachBlockRef: MutableRefObject,
9 | updateDataWithInlineBlock: (inputHtml: string) => void
10 | ) => {
11 | const eachBlockChildNodes: NodeListOf =
12 | eachBlockRef.current.childNodes;
13 | const eachBlockChildNodesLength: number = eachBlockChildNodes.length;
14 | const nodeArray = getNodeArray(eachBlockChildNodes);
15 |
16 | const getNewNodesWithInlineCodeHtml = () => {
17 | let twoBacktickNodeIndex: number | null = null;
18 |
19 | for (let i = 0; i < eachBlockChildNodesLength; i++) {
20 | if (eachBlockChildNodes[i].nodeName === '#text') {
21 | const textContent = eachBlockChildNodes[i].textContent;
22 | const isContinuousBacktick: boolean =
23 | textContent?.includes('``') ?? false;
24 | const numberOfBacktick: number = textContent?.match(/`/g)?.length ?? 0;
25 |
26 | if (numberOfBacktick === 2) {
27 | //
28 | if (isContinuousBacktick) {
29 | twoBacktickNodeIndex = i;
30 | // 2개 연속(``)이면 빈 inline Code Block 생성
31 | nodeArray[i].textContent = textContent?.replace(
32 | '``',
33 | `${frontTag}\u00A0${backTag}`
34 | );
35 | }
36 | //
37 | else {
38 | twoBacktickNodeIndex = i;
39 | // 첫 번째 `는 로 두 번째 `는
로!
40 | nodeArray[i].textContent = textContent
41 | ?.replace(/&/g, '&')
42 | .replace(//g, '>')
44 | .replace('`', frontTag)
45 | .replace('`', backTag);
46 | }
47 | }
48 | }
49 | }
50 |
51 | return twoBacktickNodeIndex;
52 | };
53 |
54 | const twoBacktickNodeIndex = getNewNodesWithInlineCodeHtml();
55 |
56 | if (twoBacktickNodeIndex !== null) {
57 | const newHtml = getNewHtml(nodeArray);
58 | updateDataWithInlineBlock(newHtml);
59 |
60 | return twoBacktickNodeIndex; // null이면 코드 변환이 되지 않음.
61 | }
62 | };
63 |
--------------------------------------------------------------------------------
/src/lib/utils/editable-block/add-spacing-after-inline-code.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch, KeyboardEvent, SetStateAction } from 'react';
2 | import {
3 | getNewHtml,
4 | getNodeArray,
5 | getSelectionEndIndex,
6 | TMyNode,
7 | } from './node';
8 |
9 | export const addSpacing__afterInlineCode = (
10 | childeNodes: NodeListOf,
11 | e: KeyboardEvent,
12 | setData__withForcedReactRendering: (newHtml: string) => void,
13 | setIndexAddedSpacing: Dispatch>
14 | ) => {
15 | const selection: Selection | null = window.getSelection();
16 | const range = selection?.getRangeAt(0);
17 | const endContainr: Node | undefined = range?.endContainer; // 커서, 셀렉션인 경우 모두 대비 가능
18 | const endOffset = range?.endOffset;
19 | const selectionEndIndex = getSelectionEndIndex(childeNodes, selection);
20 | const nextIndex = selectionEndIndex + 1;
21 |
22 | const isCodeNode: boolean = endContainr?.parentNode?.nodeName === 'CODE';
23 | const isEndText: boolean = endContainr?.textContent?.length === endOffset;
24 | const isEmptyNextNode: boolean = childeNodes[nextIndex] === undefined;
25 |
26 | if (isCodeNode && isEndText && isEmptyNextNode) {
27 | e.preventDefault();
28 | const nodeArray: TMyNode[] = getNodeArray(childeNodes);
29 | const spacing = '\u00A0';
30 |
31 | const currentHtml = getNewHtml(nodeArray);
32 | const addSpacing__afterCurrentHtml = `${currentHtml}${spacing}`;
33 |
34 | setData__withForcedReactRendering(addSpacing__afterCurrentHtml);
35 |
36 | setIndexAddedSpacing(nextIndex);
37 | }
38 | };
39 |
--------------------------------------------------------------------------------
/src/lib/utils/editable-block/move-caret-between-inline-code-and-spacing.ts:
--------------------------------------------------------------------------------
1 | import { KeyboardEvent } from 'react';
2 | import { getSelectionStartIndex } from './node';
3 |
4 | export const moveCaret__betweenInlineCodeAndSpacing = (
5 | childeNodes: NodeListOf,
6 | e: KeyboardEvent
7 | ) => {
8 | // 이 경우 커서만 이동할 뿐 서버에 저장될 데이터는 전후로 동일함.
9 | // 즉 커서만 이동할 뿐 데이터는 동기화된 상태
10 | const selection: Selection | null = window.getSelection();
11 | const range = selection?.getRangeAt(0);
12 | const startOffset = range?.startOffset;
13 | const collapsed = range?.collapsed;
14 | const selectionStartIndex = getSelectionStartIndex(childeNodes, selection);
15 |
16 | const isRightBesideOfCODE = startOffset === 1;
17 |
18 | if (
19 | collapsed &&
20 | selectionStartIndex !== 0 &&
21 | // isRightBesideOfCODE &&
22 | // 내부 블럭 오른쪽 빈 칸은 지울 수 없도록 한다
23 | childeNodes[selectionStartIndex].textContent ===
24 | ('\u00A0' || ' ' || ' ') &&
25 | childeNodes[selectionStartIndex - 1].nodeName === 'CODE'
26 | ) {
27 | e.preventDefault();
28 | const targetNode = childeNodes[selectionStartIndex - 1].childNodes[0];
29 | const newCaretPosition = targetNode.textContent?.length;
30 |
31 | if (newCaretPosition !== undefined) {
32 | const newRange = document.createRange();
33 | newRange.setStart(targetNode, newCaretPosition); // 코드 블럭 한 칸 뒤쪽 위치
34 |
35 | selection && selection.removeAllRanges();
36 | selection && selection.addRange(newRange);
37 | }
38 | }
39 | };
40 |
--------------------------------------------------------------------------------
/src/lib/utils/focus-content-editable-text-to-end.ts:
--------------------------------------------------------------------------------
1 | export const focusContentEditableTextToEnd = (element: HTMLElement) => {
2 | if (element.innerText.length === 0) {
3 | element.focus();
4 | } else {
5 | const selection = window.getSelection();
6 | const newRange = document.createRange();
7 | newRange.selectNodeContents(element);
8 | newRange.collapse(false);
9 | selection?.removeAllRanges();
10 | selection?.addRange(newRange);
11 | }
12 | };
13 |
14 | export const replaceCaret = (element: HTMLElement) => {
15 | if (element.innerText.length === 0) {
16 | element.focus();
17 | return;
18 | }
19 |
20 | // Place the caret at the end of the element
21 | const target = document.createTextNode('');
22 | element.appendChild(target);
23 | // do not move caret if element was not focused
24 | const isTargetFocused = document.activeElement === element;
25 | if (target !== null && target.nodeValue !== null && isTargetFocused) {
26 | const selection = window.getSelection();
27 |
28 | if (selection !== null) {
29 | const range = document.createRange();
30 | range.setStart(target, target.nodeValue.length);
31 | range.collapse(true);
32 | selection.removeAllRanges();
33 | selection.addRange(range);
34 | }
35 |
36 | if (element instanceof HTMLElement) element.focus();
37 | }
38 | };
39 |
--------------------------------------------------------------------------------
/src/lib/utils/get-caret-coordinates.ts:
--------------------------------------------------------------------------------
1 | export const getCaretCoordinates = () => {
2 | let x = 0,
3 | y = 0;
4 | const isSupported = typeof window.getSelection !== 'undefined';
5 | if (isSupported) {
6 | const selection = window.getSelection();
7 | if (selection?.rangeCount !== 0) {
8 | const range = selection?.getRangeAt(0).cloneRange();
9 | range?.collapse(true);
10 | const rect = range?.getClientRects()[0];
11 | if (rect) {
12 | x = rect.left;
13 | y = rect.top;
14 | }
15 | }
16 | }
17 | return { x, y };
18 | };
19 |
--------------------------------------------------------------------------------
/src/lib/utils/get-caret-index.ts:
--------------------------------------------------------------------------------
1 | export const getCaretIndex = (element: HTMLElement) => {
2 | let position;
3 | const isSupported = typeof window.getSelection !== 'undefined';
4 |
5 | if (isSupported) {
6 | const selection = window.getSelection();
7 |
8 | if (selection?.rangeCount !== 0) {
9 | const range = selection?.getRangeAt(0);
10 | const preCaretRange = range?.cloneRange();
11 | preCaretRange?.selectNodeContents(element);
12 | range && preCaretRange?.setEnd(range.endContainer, range.endOffset);
13 | position = preCaretRange?.toString().length;
14 | }
15 | }
16 | return position;
17 | };
18 |
--------------------------------------------------------------------------------
/src/lib/utils/get-date.ts:
--------------------------------------------------------------------------------
1 | import { TStatus } from './../../redux-toolkit/model/post-data-model';
2 |
3 | export const getDate = () => {
4 | const today = new Date();
5 | const year = String(today.getFullYear());
6 | // 한 자리 숫자일 경우 앞에 0을 붙여줘 순서대로 데이터가 나올 수 있도록
7 | const month =
8 | String(today.getMonth() + 1).length === 1
9 | ? '0' + String(today.getMonth() + 1)
10 | : String(today.getMonth() + 1);
11 | const date =
12 | String(today.getDate()).length === 1
13 | ? '0' + String(today.getDate())
14 | : String(today.getDate());
15 |
16 | const dateForSEO: string = `${year}-${month}-${date}`;
17 | const dateForDisplay: string = `${year}.${month}.${date}`;
18 |
19 | return { today, year, month, date, dateForSEO, dateForDisplay };
20 | };
21 |
22 | export const checkPublishedDate = (status: TStatus, dateTime: string) => {
23 | // status에 따라 날짜를 갱신할지 하지 않을지 결정
24 | // published 상태일 때는 갱신하지 않음
25 | const { dateForSEO, dateForDisplay } = getDate();
26 | const isStatusPublished = status === 'published';
27 | const displayDateTime = dateTime.replace(/-/g, '.');
28 | const seoDate: string = isStatusPublished ? dateTime : dateForSEO;
29 | const displayDate: string = isStatusPublished
30 | ? displayDateTime
31 | : dateForDisplay;
32 |
33 | return { seoDate, displayDate };
34 | };
35 |
--------------------------------------------------------------------------------
/src/lib/utils/gradientGenerator.ts:
--------------------------------------------------------------------------------
1 | export const gradientGenerator = () => {
2 | const hexValues = [
3 | '0',
4 | '1',
5 | '2',
6 | '3',
7 | '4',
8 | '5',
9 | '6',
10 | '7',
11 | '8',
12 | '9',
13 | 'a',
14 | 'b',
15 | 'c',
16 | 'd',
17 | 'e',
18 | ];
19 |
20 | const populate = (a: string) => {
21 | for (let i = 0; i < 6; i++) {
22 | const x = Math.round(Math.random() * 14);
23 | const y = hexValues[x];
24 | a += y;
25 | }
26 | return a;
27 | };
28 |
29 | const newColor1 = populate('#');
30 | const newColor2 = populate('#');
31 | const angle = Math.round(Math.random() * 360);
32 | const gradient = `${angle}deg, ${newColor1}, ${newColor2}`;
33 |
34 | return gradient;
35 | };
36 |
--------------------------------------------------------------------------------
/src/lib/utils/id.ts:
--------------------------------------------------------------------------------
1 | export const uuid = () => {
2 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
3 | const r = (Math.random() * 16) | 0,
4 | v = c == 'x' ? r : (r & 0x3) | 0x8;
5 | return v.toString(16);
6 | });
7 | };
8 |
9 | // 전역 고유 식별자
10 | export const guid = () => {
11 | const s4 = () => {
12 | return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
13 | };
14 | return (
15 | s4() +
16 | s4() +
17 | '-' +
18 | s4() +
19 | '-' +
20 | s4() +
21 | '-' +
22 | s4() +
23 | '-' +
24 | s4() +
25 | s4() +
26 | s4()
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/src/pages/[category]/index.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage } from 'next';
2 | import { TListData } from '..';
3 | import PostList from '../../components/navigation/post/post-list';
4 | import { useUpdateVisitors } from '../../lib/hooks/useUpdateVisitors';
5 | import {
6 | brandCollectionRefName,
7 | designCollectionRefName,
8 | devCollectionRefName,
9 | getEachAllCollectionDataArray,
10 | } from '../../service/firebase/firestore';
11 |
12 | type Props = {
13 | designPostListData: TListData;
14 | devPostListData: TListData;
15 | brandPostListData: TListData;
16 | allPostsListData: TListData;
17 | };
18 |
19 | const Category: NextPage = ({
20 | designPostListData,
21 | devPostListData,
22 | brandPostListData,
23 | allPostsListData,
24 | }) => {
25 | useUpdateVisitors();
26 |
27 | return (
28 |
34 | );
35 | };
36 |
37 | export default Category;
38 |
39 | export const getStaticProps = async () => {
40 | const designPost = await getEachAllCollectionDataArray(
41 | designCollectionRefName
42 | );
43 | const devPost = await getEachAllCollectionDataArray(devCollectionRefName);
44 | const brandPost = await getEachAllCollectionDataArray(brandCollectionRefName);
45 | const allPosts = designPost.concat(devPost).concat(brandPost);
46 |
47 | const designPostListData = designPost.map((post) => ({
48 | category: post.category,
49 | order: post.order,
50 | title: post.title,
51 | dateTime: post.dateTime,
52 | status: post.status,
53 | }));
54 |
55 | const devPostListData = devPost.map((post) => ({
56 | category: post.category,
57 | order: post.order,
58 | title: post.title,
59 | dateTime: post.dateTime,
60 | status: post.status,
61 | }));
62 |
63 | const brandPostListData = brandPost.map((post) => ({
64 | category: post.category,
65 | order: post.order,
66 | title: post.title,
67 | dateTime: post.dateTime,
68 | status: post.status,
69 | }));
70 |
71 | const allPostsListData = allPosts.map((post) => ({
72 | category: post.category,
73 | order: post.order,
74 | title: post.title,
75 | dateTime: post.dateTime,
76 | status: post.status,
77 | }));
78 |
79 | return {
80 | props: {
81 | designPostListData,
82 | devPostListData,
83 | brandPostListData,
84 | allPostsListData,
85 | },
86 | };
87 | };
88 |
89 | export const getStaticPaths = async () => {
90 | const paths = [
91 | { params: { category: 'design' } },
92 | { params: { category: 'dev' } },
93 | { params: { category: 'brand' } },
94 | ];
95 |
96 | return { paths, fallback: false };
97 | };
98 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import '../styles/fonts.css';
2 | import '../styles/colors.css';
3 | import '../styles/common.scss';
4 | import '../styles/globals.css';
5 | import type { AppProps } from 'next/app';
6 | import { ToastProvider } from '@dalgu/react-toast';
7 | import { Provider } from 'react-redux';
8 | import store from '../redux-toolkit/store';
9 | import { ThemeProvider } from 'next-themes';
10 | import Header from '../components/header/header';
11 | import HeadForSEO from '../SEO/headForSEO';
12 | import { indexInfo } from '../SEO/index/index-info';
13 | import { useRouter } from 'next/router';
14 | import Footer from '../components/footer/footer';
15 | import React from 'react';
16 | import { RecoilRoot } from 'recoil';
17 | import Modal from '../components/modal/modal';
18 | import { usePreventRightClick } from '../lib/hooks/usePreventRightClick';
19 | import BottomFloatingButton from '../components/bottom-floating-button/BottomFloatingButton';
20 |
21 | const BlogApp = ({ Component, pageProps }: AppProps) => {
22 | const router = useRouter();
23 | const isPost = router.pathname === '/[category]/[order]';
24 | const isFooter =
25 | router.pathname === '/' ||
26 | router.pathname === '/[category]' ||
27 | router.pathname === '/ux-collection' ||
28 | router.pathname === '/story' ||
29 | router.pathname === '/contact';
30 |
31 | usePreventRightClick();
32 |
33 | return (
34 | <>
35 | {!isPost && }
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | {isFooter && }
45 |
46 |
47 |
48 |
49 | >
50 | );
51 | };
52 |
53 | export default BlogApp;
54 |
--------------------------------------------------------------------------------
/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, { Head, Html, Main, NextScript } from 'next/document';
2 |
3 | export default class MyDocument extends Document {
4 | render() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | );
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/pages/contact.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage } from 'next';
2 | import Profile from '../components/contact/profile';
3 | import Email from '../components/contact/email';
4 | import Career from '../components/contact/career';
5 | import styles from '../components/contact/contact.module.scss';
6 | import { useUpdateVisitors } from '../lib/hooks/useUpdateVisitors';
7 |
8 | const Contact: NextPage = () => {
9 | useUpdateVisitors();
10 |
11 | return (
12 |
13 |
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | export default Contact;
21 |
--------------------------------------------------------------------------------
/src/pages/draft/list.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage } from 'next';
2 | import { useEffect, useState } from 'react';
3 | import List from '../../components/navigation/post/list';
4 | import { useIsAdmin } from '../../lib/hooks/useIsAdmin';
5 | import { IPostData } from '../../redux-toolkit/model/post-data-model';
6 | import {
7 | draftCollectionRefName,
8 | getEachAllCollectionDataArray,
9 | } from '../../service/firebase/firestore';
10 | import styles from '../../components/navigation/post/post-list.module.scss';
11 |
12 | const Draft: NextPage = () => {
13 | const { isAdmin } = useIsAdmin();
14 | const [draftList, setDraftList] = useState([]);
15 |
16 | useEffect(() => {
17 | getEachAllCollectionDataArray(draftCollectionRefName) //
18 | .then((list) => {
19 | setDraftList(list);
20 | });
21 | }, []);
22 |
23 | // console.log(draftList);
24 |
25 | return (
26 | <>
27 | {isAdmin && (
28 |
29 |
43 |
44 | )}
45 | >
46 | );
47 | };
48 |
49 | export default Draft;
50 |
--------------------------------------------------------------------------------
/src/pages/draft/new.tsx:
--------------------------------------------------------------------------------
1 | import { useMounted } from '@dalgu/react-utility-hooks';
2 | import { NextPage } from 'next';
3 | import { useRouter } from 'next/router';
4 | import { useEffect } from 'react';
5 | import Post from '../../components/post/post';
6 | import { useGetClientPostData } from '../../lib/hooks/useGetClientPostData';
7 | import { useGetClientTempPostData } from '../../lib/hooks/useGetClientTempPostData';
8 | import { useInitializeClientData } from '../../lib/hooks/useInitializeClientData';
9 | import { useIsAdmin } from '../../lib/hooks/useIsAdmin';
10 | import {
11 | draftCollectionRefName,
12 | getEachAllCollectionDataArray,
13 | saveDataToFireStoreDB,
14 | } from '../../service/firebase/firestore';
15 |
16 | const NewDraft: NextPage = () => {
17 | const { isAdmin } = useIsAdmin();
18 | const mounted = useMounted();
19 | const router = useRouter();
20 | const { post } = useGetClientPostData();
21 | const { tempPost } = useGetClientTempPostData();
22 | const initializeClientData = useInitializeClientData();
23 |
24 | useEffect(() => {
25 | initializeClientData();
26 |
27 | return () => {
28 | initializeClientData();
29 | };
30 | }, []);
31 |
32 | const saveNewDraftToFireStoreDB = async () => {
33 | const draftList = await getEachAllCollectionDataArray(
34 | draftCollectionRefName
35 | );
36 | const maxValueOfOrder = Math.max(
37 | ...draftList.map((list) => Number(list.order)),
38 | 0
39 | );
40 | const newPathOrder = maxValueOfOrder !== NaN ? maxValueOfOrder + 1 : 1;
41 | const dbDocument = `${newPathOrder}`;
42 | await saveDataToFireStoreDB(draftCollectionRefName, dbDocument, tempPost);
43 |
44 | router.push('/draft/[order]', `/draft/${newPathOrder}`);
45 | };
46 |
47 | // console.log('tempPost', tempPost);
48 | return (
49 | <>
50 | {isAdmin && mounted && (
51 | <>
52 |
53 |
59 | >
60 | )}
61 | >
62 | );
63 | };
64 |
65 | export default NewDraft;
66 |
--------------------------------------------------------------------------------
/src/pages/examples/canvas/stage-lighting-wave/index.tsx:
--------------------------------------------------------------------------------
1 | import type { NextPage } from 'next';
2 | import { RefObject, useRef } from 'react';
3 | import StageLightingWaveAnimation from '../../../../canvas/stage-lighting-wave-animation/stage-lighting-wave-animation';
4 | import { useClientWidthHeight } from '../../../../lib/hooks/useClientWidthHeight';
5 |
6 | const Example: NextPage = () => {
7 | const mainRef: RefObject = useRef(null);
8 |
9 | const clientRect = useClientWidthHeight(mainRef);
10 | const canvasWidth = clientRect.width;
11 | const canvasHeight = clientRect.height;
12 |
13 | return (
14 |
15 |
19 |
20 | );
21 | };
22 |
23 | export default Example;
24 | ``;
25 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage } from 'next';
2 | import PostList from '../components/navigation/post/post-list';
3 | import { useUpdateVisitors } from '../lib/hooks/useUpdateVisitors';
4 | import { TStatus } from '../redux-toolkit/model/post-data-model';
5 | import {
6 | brandCollectionRefName,
7 | designCollectionRefName,
8 | devCollectionRefName,
9 | getEachAllCollectionDataArray,
10 | } from '../service/firebase/firestore';
11 |
12 | export type TListData = {
13 | category: string;
14 | order: string;
15 | title: string;
16 | dateTime: string;
17 | status: TStatus;
18 | }[];
19 |
20 | type Props = {
21 | designPostListData: TListData;
22 | devPostListData: TListData;
23 | brandPostListData: TListData;
24 | allPostsListData: TListData;
25 | };
26 |
27 | const Index: NextPage = ({
28 | designPostListData,
29 | devPostListData,
30 | brandPostListData,
31 | allPostsListData,
32 | }) => {
33 | useUpdateVisitors();
34 |
35 | return (
36 |
42 | );
43 | };
44 |
45 | export default Index;
46 |
47 | export const getStaticProps = async () => {
48 | const designPost = await getEachAllCollectionDataArray(
49 | designCollectionRefName
50 | );
51 | const devPost = await getEachAllCollectionDataArray(devCollectionRefName);
52 | const brandPost = await getEachAllCollectionDataArray(brandCollectionRefName);
53 | const allPosts = designPost.concat(devPost).concat(brandPost);
54 |
55 | const designPostListData = designPost.map((post) => ({
56 | category: post.category,
57 | order: post.order,
58 | title: post.title,
59 | dateTime: post.dateTime,
60 | status: post.status,
61 | }));
62 |
63 | const devPostListData = devPost.map((post) => ({
64 | category: post.category,
65 | order: post.order,
66 | title: post.title,
67 | dateTime: post.dateTime,
68 | status: post.status,
69 | }));
70 |
71 | const brandPostListData = brandPost.map((post) => ({
72 | category: post.category,
73 | order: post.order,
74 | title: post.title,
75 | dateTime: post.dateTime,
76 | status: post.status,
77 | }));
78 |
79 | const allPostsListData = allPosts
80 | .map((post) => ({
81 | category: post.category,
82 | order: post.order,
83 | title: post.title,
84 | dateTime: post.dateTime,
85 | status: post.status,
86 | }))
87 | .sort((a, b) => +new Date(b.dateTime) - +new Date(a.dateTime));
88 | // Dev와 Design 모든 리스트 보여줄 때 정렬하기.(컬렉션이 다르므로 Firestore에서 할 수 없음)
89 | // https://dkmqflx.github.io/frontend/2021/04/21/javascript-sortbydate/
90 | // 단항 연산자 (Unary operator)인 +를 new 앞에 추가
91 | // http://ccambo.github.io/Dev/Typescript/1.typescript-problem-solving-and-tips/
92 |
93 | return {
94 | props: {
95 | designPostListData,
96 | devPostListData,
97 | brandPostListData,
98 | allPostsListData,
99 | },
100 | };
101 | };
102 |
--------------------------------------------------------------------------------
/src/pages/login/index.tsx:
--------------------------------------------------------------------------------
1 | import { useToast } from '@dalgu/react-toast';
2 | import type { NextPage } from 'next';
3 | import { useRouter } from 'next/router';
4 | import { useIsAdmin } from '../../lib/hooks/useIsAdmin';
5 | import { setUid } from '../../redux-toolkit/slices/user-slice';
6 | import { useAppDispatch } from '../../redux-toolkit/store';
7 | import {
8 | Authentication,
9 | IAuthentication,
10 | TproviderName,
11 | } from '../../service/firebase/authentication';
12 | import s from './login.module.scss';
13 |
14 | const Login: NextPage = () => {
15 | const { isAdmin } = useIsAdmin();
16 | const auth: IAuthentication = new Authentication();
17 | const dispatch = useAppDispatch();
18 | const { showToast } = useToast();
19 | const router = useRouter();
20 |
21 | const onLogIn = (providerName: TproviderName) => {
22 | auth //
23 | .logIn(providerName)
24 | .then((data) => {
25 | dispatch(setUid(data.user.uid));
26 | if (data.user.uid !== process.env.NEXT_PUBLIC_ADMIN_UID) {
27 | onLogOut();
28 | showToast('관리자가 아니므로 로그아웃되었습니다.');
29 | } else {
30 | router.push('/');
31 | }
32 | })
33 | .catch((error) => {
34 | throw new Error(error);
35 | });
36 | };
37 |
38 | const onLogOut = () => {
39 | auth //
40 | .logOut()
41 | .then(() => {
42 | setUid(null);
43 | dispatch(setUid(null));
44 | })
45 | .catch((error) => {
46 | throw new Error(error);
47 | });
48 | };
49 |
50 | return (
51 |
52 |
59 |
60 | );
61 | };
62 |
63 | export default Login;
64 |
--------------------------------------------------------------------------------
/src/pages/login/login.module.scss:
--------------------------------------------------------------------------------
1 | .alignment__center {
2 | display: flex;
3 | justify-content: center;
4 | margin-top: 12vh;
5 | }
6 |
--------------------------------------------------------------------------------
/src/pages/story.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage } from 'next';
2 | import { TListData } from '.';
3 | import { useUpdateVisitors } from '../lib/hooks/useUpdateVisitors';
4 | import {
5 | getEachAllCollectionDataArray,
6 | storyCollectionRefName,
7 | } from '../service/firebase/firestore';
8 | import styles from '../components/navigation/post/post-list.module.scss';
9 | import List from '../components/navigation/post/list';
10 |
11 | type Props = {
12 | storyPostListData: TListData;
13 | };
14 |
15 | const Story: NextPage = ({ storyPostListData }) => {
16 | useUpdateVisitors();
17 |
18 | return (
19 | <>
20 |
21 |
35 |
36 | >
37 | );
38 | };
39 |
40 | export default Story;
41 |
42 | export const getStaticProps = async () => {
43 | const storyPost = await getEachAllCollectionDataArray(storyCollectionRefName);
44 |
45 | const storyPostListData = storyPost.map((post) => ({
46 | category: post.category,
47 | order: post.order,
48 | title: post.title,
49 | dateTime: post.dateTime,
50 | status: post.status,
51 | }));
52 |
53 | return {
54 | props: {
55 | storyPostListData,
56 | },
57 | };
58 | };
59 |
--------------------------------------------------------------------------------
/src/redux-toolkit/model/code-data-model.ts:
--------------------------------------------------------------------------------
1 | import { uuid } from '../../lib/utils/id';
2 |
3 | export interface ICodeDataModel {
4 | createNewCodeData: () => ICodeData;
5 | }
6 |
7 | export class CodeDataModel implements ICodeDataModel {
8 | private blockId: string;
9 | private blockType: 'Code';
10 | private html: string;
11 | private url: string;
12 | private codeLanguage: TCodeLanguage;
13 |
14 | constructor() {
15 | this.blockId = uuid();
16 | this.blockType = 'Code';
17 | this.html = '';
18 | this.url = '';
19 | this.codeLanguage = 'tsx';
20 | }
21 |
22 | // for Data Serealization
23 | createNewCodeData(): ICodeData {
24 | return {
25 | blockId: this.blockId,
26 | blockType: this.blockType,
27 | html: this.html,
28 | url: this.url,
29 | codeLanguage: this.codeLanguage,
30 | };
31 | }
32 | }
33 |
34 | export interface ICodeData {
35 | blockId: string;
36 | blockType: 'Code';
37 | html: string;
38 | url: string;
39 | codeLanguage: TCodeLanguage;
40 | }
41 |
42 | export type TCodeLanguage =
43 | | 'typescript'
44 | | 'tsx'
45 | | 'swift'
46 | | 'css'
47 | | 'sass'
48 | | 'scss'
49 | | 'javascript'
50 | | 'jsx';
51 |
--------------------------------------------------------------------------------
/src/redux-toolkit/model/image-data-model.ts:
--------------------------------------------------------------------------------
1 | import { uuid } from '../../lib/utils/id';
2 | import { TCodeLanguage } from './code-data-model';
3 |
4 | export interface IImageDataModel {
5 | createNewImageData: () => IImageData;
6 | }
7 |
8 | export class ImageDataModel implements IImageDataModel {
9 | private blockId: string;
10 | private blockType: 'Image';
11 | private html: string;
12 | private url: string;
13 | private codeLanguage: TCodeLanguage;
14 |
15 | constructor() {
16 | this.blockId = uuid();
17 | this.blockType = 'Image';
18 | this.html = '';
19 | this.url = '';
20 | this.codeLanguage = 'tsx';
21 | }
22 |
23 | // for Data Serealization
24 | createNewImageData(): IImageData {
25 | return {
26 | blockId: this.blockId,
27 | blockType: this.blockType,
28 | html: this.html,
29 | url: this.url,
30 | codeLanguage: this.codeLanguage,
31 | };
32 | }
33 | }
34 |
35 | export interface IImageData {
36 | blockId: string;
37 | blockType: 'Image';
38 | html: string;
39 | url: string;
40 | codeLanguage: TCodeLanguage;
41 | }
42 |
--------------------------------------------------------------------------------
/src/redux-toolkit/model/link-data-model.ts:
--------------------------------------------------------------------------------
1 | import { uuid } from '../../lib/utils/id';
2 | import { TCodeLanguage } from './code-data-model';
3 |
4 | export interface ILinkDataModel {
5 | createNewLinkData: () => ILinkData;
6 | }
7 |
8 | export class LinkDataModel implements ILinkDataModel {
9 | private blockId: string;
10 | private blockType: 'Link';
11 | private html: string;
12 | private url: string;
13 | private codeLanguage: TCodeLanguage;
14 |
15 | constructor() {
16 | this.blockId = uuid();
17 | this.blockType = 'Link';
18 | this.html = '';
19 | this.url = '';
20 | this.codeLanguage = 'tsx';
21 | }
22 |
23 | // for Data Serealization
24 | createNewLinkData(): ILinkData {
25 | return {
26 | blockId: this.blockId,
27 | blockType: this.blockType,
28 | html: this.html,
29 | url: this.url,
30 | codeLanguage: this.codeLanguage,
31 | };
32 | }
33 | }
34 |
35 | export interface ILinkData {
36 | blockId: string;
37 | blockType: 'Link';
38 | html: string;
39 | url: string;
40 | codeLanguage: TCodeLanguage;
41 | }
42 |
--------------------------------------------------------------------------------
/src/redux-toolkit/model/post-data-model.ts:
--------------------------------------------------------------------------------
1 | import { ICodeData } from './code-data-model';
2 | import { designCollectionRefName } from '../../service/firebase/firestore';
3 | import {
4 | ITextData,
5 | ITextDataModel,
6 | TBlockTextType,
7 | TextDataModel,
8 | } from './text-data-model';
9 | import { ILinkData, ILinkDataModel, LinkDataModel } from './link-data-model';
10 | import { IImageData } from './image-data-model';
11 |
12 | export type TBlockType = TBlockTextType | 'Image' | 'Code' | 'Link';
13 |
14 | const refData: ILinkDataModel = new LinkDataModel();
15 | const paragraphData: ITextDataModel = new TextDataModel();
16 |
17 | export const postInitialData: IPostData = {
18 | category: designCollectionRefName,
19 | order: '',
20 | series: '',
21 | dateTime: '',
22 | title: '',
23 | tagDataArray: [],
24 | wysiwygDataArray: [paragraphData.createNewTextData()],
25 | linkWysiwygDataArray: [refData.createNewLinkData()],
26 | status: 'draft',
27 | };
28 |
29 | export interface IPostData {
30 | category: string;
31 | order: string;
32 | series: string;
33 | dateTime: string;
34 | tagDataArray: [];
35 | title: string;
36 | wysiwygDataArray: IParagraphData[];
37 | linkWysiwygDataArray: ILinkData[];
38 | status: TStatus;
39 | }
40 |
41 | export type IParagraphData = ITextData | ILinkData | IImageData | ICodeData;
42 | export type TStatus = 'draft' | 'published' | 'unPublished';
43 |
--------------------------------------------------------------------------------
/src/redux-toolkit/model/text-data-model.ts:
--------------------------------------------------------------------------------
1 | import { uuid } from '../../lib/utils/id';
2 | import { TCodeLanguage } from './code-data-model';
3 |
4 | export type TBlockTextType = 'Heading1' | 'Heading2' | 'Heading3' | 'Paragraph';
5 |
6 | export interface ITextDataModel {
7 | createNewTextData: () => ITextData;
8 | }
9 |
10 | export class TextDataModel implements ITextDataModel {
11 | private blockId: string;
12 | private blockType: TBlockTextType;
13 | private html: string;
14 | private url: string;
15 | private codeLanguage: TCodeLanguage;
16 |
17 | constructor() {
18 | this.blockId = uuid();
19 | this.blockType = 'Paragraph';
20 | this.html = '';
21 | this.url = '';
22 | this.codeLanguage = 'tsx';
23 | }
24 |
25 | // for Data Serealization
26 | createNewTextData(): ITextData {
27 | return {
28 | blockId: this.blockId,
29 | blockType: this.blockType,
30 | html: this.html,
31 | url: this.url,
32 | codeLanguage: this.codeLanguage,
33 | };
34 | }
35 | }
36 |
37 | export interface ITextData {
38 | blockId: string;
39 | blockType: TBlockTextType;
40 | html: string;
41 | url: string;
42 | codeLanguage: TCodeLanguage;
43 | }
44 |
--------------------------------------------------------------------------------
/src/redux-toolkit/slices/post-slice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 | import { ILinkData, LinkDataModel } from '../model/link-data-model';
3 | import { IPostData, postInitialData } from '../model/post-data-model';
4 | import { ITextData, TextDataModel } from '../model/text-data-model';
5 |
6 | const initialState: { post: IPostData } = {
7 | post: postInitialData,
8 | };
9 |
10 | export const postSlice = createSlice({
11 | name: 'post',
12 | initialState,
13 | reducers: {
14 | setPostData: (state, action: PayloadAction) => {
15 | state.post = action.payload;
16 | },
17 |
18 | setCurrentBlockHtml: (
19 | state,
20 | action: PayloadAction<{ inputHtml: string; currentIndex: number }>
21 | ) => {
22 | state.post.wysiwygDataArray[action.payload.currentIndex].html =
23 | action.payload.inputHtml;
24 | },
25 |
26 | setPostCategory: (state, action: PayloadAction) => {
27 | state.post.category = action.payload;
28 | },
29 |
30 | setArticleTitleData: (
31 | state,
32 | action: PayloadAction<{ inputHtml: string }>
33 | ) => {
34 | state.post.title = action.payload.inputHtml;
35 | },
36 |
37 | setBlockTypeData: (
38 | state,
39 | action: PayloadAction<{ newBlockType: any; currentIndex: number }>
40 | ) => {
41 | state.post.wysiwygDataArray[action.payload.currentIndex].blockType =
42 | action.payload.newBlockType;
43 | },
44 |
45 | // 기본 wysiwyg의 초기값은 p 블럭
46 | addNewBlock: (
47 | state,
48 | action: PayloadAction<{
49 | currentIndex: number;
50 | isEnd: boolean;
51 | }>
52 | ) => {
53 | const newTextBlock: ITextData = new TextDataModel().createNewTextData();
54 | if (action.payload.isEnd) {
55 | state.post.wysiwygDataArray.push(newTextBlock);
56 | } else {
57 | state.post.wysiwygDataArray.splice(
58 | action.payload.currentIndex + 1,
59 | 0,
60 | newTextBlock
61 | );
62 | }
63 | },
64 |
65 | removeCurrentBlock: (
66 | state,
67 | action: PayloadAction<{ currentIndex: number }>
68 | ) => {
69 | state.post.wysiwygDataArray.splice(action.payload.currentIndex, 1);
70 | },
71 |
72 | addNewLinkBlock: (
73 | state,
74 | action: PayloadAction<{
75 | currentIndex: number;
76 | isEnd: boolean;
77 | }>
78 | ) => {
79 | const newLinkBlock: ILinkData = new LinkDataModel().createNewLinkData();
80 | if (action.payload.isEnd) {
81 | state.post.linkWysiwygDataArray.push(newLinkBlock);
82 | } else {
83 | state.post.linkWysiwygDataArray.splice(
84 | action.payload.currentIndex + 1,
85 | 0,
86 | newLinkBlock
87 | );
88 | }
89 | },
90 |
91 | removeLinkBlock: (
92 | state,
93 | action: PayloadAction<{ currentIndex: number }>
94 | ) => {
95 | state.post.linkWysiwygDataArray.splice(action.payload.currentIndex, 1);
96 | },
97 |
98 | setCurrentLinkBlockHtml: (
99 | state,
100 | action: PayloadAction<{ inputHtml: string; currentIndex: number }>
101 | ) => {
102 | state.post.linkWysiwygDataArray[action.payload.currentIndex].html =
103 | action.payload.inputHtml;
104 | },
105 | },
106 | });
107 |
108 | export const {
109 | setPostData,
110 | setCurrentBlockHtml,
111 | setPostCategory,
112 | setArticleTitleData,
113 | setBlockTypeData,
114 | addNewBlock,
115 | removeCurrentBlock,
116 | addNewLinkBlock,
117 | removeLinkBlock,
118 | setCurrentLinkBlockHtml,
119 | } = postSlice.actions;
120 | export const postReducer = postSlice.reducer;
121 |
--------------------------------------------------------------------------------
/src/redux-toolkit/slices/user-slice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 | import { User } from 'firebase/auth';
3 |
4 | export type TUid = string | null;
5 |
6 | interface IUser {
7 | user: User | null;
8 | uid: TUid;
9 | }
10 |
11 | const initialState: IUser = {
12 | user: null,
13 | uid: null,
14 | };
15 |
16 | export const userAuthSlice = createSlice({
17 | name: 'user',
18 | initialState,
19 | reducers: {
20 | setUser: (state, action: PayloadAction) => {
21 | state.user = action.payload;
22 | },
23 |
24 | setUid: (state, action: PayloadAction) => {
25 | state.uid = action.payload;
26 | },
27 | },
28 | });
29 |
30 | export const { setUser, setUid } = userAuthSlice.actions;
31 | export const userAuthReducer = userAuthSlice.reducer;
32 |
--------------------------------------------------------------------------------
/src/redux-toolkit/store.ts:
--------------------------------------------------------------------------------
1 | import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit';
2 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
3 | import { postReducer } from './slices/post-slice';
4 | import { tempPostReducer } from './slices/temp-post-slice';
5 | import { userAuthReducer } from './slices/user-slice';
6 |
7 | const store = configureStore({
8 | reducer: {
9 | user: userAuthReducer,
10 | post: postReducer,
11 | tempPost: tempPostReducer,
12 | },
13 | });
14 |
15 | // Use throughout your app instead of plain `useDispatch` and `useSelector`
16 | export type RootState = ReturnType;
17 | export type AppDispatch = typeof store.dispatch;
18 | export const useAppSelector: TypedUseSelectorHook = useSelector;
19 | export const useAppDispatch = () => useDispatch();
20 |
21 | export type AppThunk = ThunkAction<
22 | ReturnType,
23 | RootState,
24 | unknown,
25 | Action
26 | >;
27 |
28 | export default store;
29 |
--------------------------------------------------------------------------------
/src/service/firebase/authentication.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getAuth,
3 | signInWithPopup,
4 | GoogleAuthProvider,
5 | GithubAuthProvider,
6 | Auth,
7 | UserCredential,
8 | User,
9 | } from 'firebase/auth';
10 |
11 | export type TproviderName = 'Google';
12 |
13 | export interface IAuthentication {
14 | logIn: (providerName: TproviderName) => Promise;
15 | logOut: () => Promise;
16 | onAuthChange: (onUserChanged: (user: User) => void) => void;
17 | }
18 |
19 | export class Authentication implements IAuthentication {
20 | private auth: Auth;
21 | private googleAuthProvider: GoogleAuthProvider;
22 |
23 | constructor() {
24 | this.auth = getAuth();
25 | this.googleAuthProvider = new GoogleAuthProvider();
26 | }
27 |
28 | logIn(providerName: TproviderName) {
29 | const authProvider = this.getProvider(providerName);
30 | return signInWithPopup(this.auth, authProvider);
31 | }
32 |
33 | logOut() {
34 | return this.auth.signOut();
35 | }
36 |
37 | onAuthChange(onUserChanged: (user: User) => void) {
38 | this.auth.onAuthStateChanged((user) => {
39 | if (user) {
40 | onUserChanged(user);
41 | // console.log('Signed In');
42 | } else {
43 | // console.log('Signed Out');
44 | // User is signed out
45 | }
46 | });
47 | }
48 |
49 | private getProvider(providerName: TproviderName) {
50 | switch (providerName) {
51 | case 'Google':
52 | return this.googleAuthProvider;
53 | // case 'Github':
54 | // return this.githubAuthProvider;
55 | default:
56 | throw new Error(`not supported provider: ${providerName}`);
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/service/firebase/config.ts:
--------------------------------------------------------------------------------
1 | import { initializeApp } from 'firebase/app';
2 | import { getFirestore } from 'firebase/firestore';
3 | import { getStorage } from 'firebase/storage';
4 | import { getDatabase } from 'firebase/database';
5 |
6 | const firebaseConfig = {
7 | apiKey: process.env.NEXT_PUBLIC_FIREBASE_PUBLIC_API_KEY,
8 | authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
9 | databaseURL: process.env.NEXT_PUBLIC_FIREBASE_REALTIME_DATABASE,
10 | projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
11 | storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
12 | };
13 |
14 | export const initializeFirebaseApp = initializeApp(firebaseConfig);
15 | export const db = getFirestore();
16 | export const storage = getStorage(initializeFirebaseApp);
17 | export const realtimeDB = getDatabase(initializeFirebaseApp);
18 |
19 | // 사용자 관리
20 | // https://firebase.google.com/docs/auth/web/manage-users?authuser=0
21 | // 로그인 유지
22 | // https://firebase.google.com/docs/auth/web/auth-state-persistence
23 | // https://firebase.google.com/docs/auth/admin/manage-cookies
24 | // 엘리님 추천: 파이어베이스에서 제공하는 서버 사이드 세션 쿠키 관리
25 |
--------------------------------------------------------------------------------
/src/service/firebase/realtime-db.ts:
--------------------------------------------------------------------------------
1 | import { ref, set, onValue, push, Unsubscribe } from 'firebase/database';
2 | import { Dispatch, SetStateAction } from 'react';
3 | import { objectToArray } from '../../lib/utils/data';
4 | import { getDate } from '../../lib/utils/get-date';
5 | import { realtimeDB } from './config';
6 |
7 | export type TResponseData = {
8 | profileGradient: string;
9 | date: string;
10 | responseText: string;
11 | };
12 |
13 | export const postResponseRealtimeDB = async (
14 | asPath: string,
15 | responseData: TResponseData
16 | ) => {
17 | const dbRef = ref(realtimeDB, `Response/Post${asPath}`);
18 | const dbRefWithKey = push(dbRef); // 시간순 id 생성
19 |
20 | await set(dbRefWithKey, responseData) //
21 | .catch((error) => {
22 | throw new Error(error);
23 | });
24 | };
25 |
26 | export const getResponseDataFromRealtimeDB = (
27 | asPath: string,
28 | setResponseList: Dispatch>
29 | ) => {
30 | const dbRef = ref(realtimeDB, `Response/Post${asPath}`);
31 |
32 | const unSubscribeOnValueRealtimeDB: Unsubscribe = onValue(
33 | dbRef,
34 | (snapshot) => {
35 | const data = snapshot.val();
36 | const dataArray = data && objectToArray(data);
37 | data && setResponseList(dataArray);
38 | }
39 | );
40 | // 새로운 댓글 생성할 때마다 실시간 업데이트
41 |
42 | return unSubscribeOnValueRealtimeDB;
43 | };
44 |
45 | /**
46 | *
47 | * updateNumberRealtimeDB()
48 | * 통계 재사용 함수
49 | *
50 | */
51 | export const updateNumberRealtimeDB = (path: string) => {
52 | const dbRef = ref(realtimeDB, path);
53 |
54 | const unSubscribeOnValueRealtimeDB: Unsubscribe = onValue(
55 | dbRef,
56 | (snapshot) => {
57 | const data = snapshot.val();
58 | const updateData = Number(data) + 1;
59 | set(dbRef, updateData);
60 | },
61 | {
62 | onlyOnce: true,
63 | }
64 | );
65 |
66 | return unSubscribeOnValueRealtimeDB;
67 | };
68 |
69 | const { year, month, date } = getDate();
70 | const totalVisitorsRef = 'Visitors/Total/All';
71 | const visitorsByYear = `Visitors/Total/All on ${year}`;
72 | const visitorsByMonth = `Visitors/${year}/${month}/All`;
73 | const visitorsByDay = `Visitors/${year}/${month}/${date}`;
74 |
75 | export const updateTotalVisitors = () => {
76 | const visited = sessionStorage.getItem('visitDuringSession');
77 |
78 | if (process.env.NODE_ENV === 'production' && !visited) {
79 | const unSubscribeOnValueTotalVisitors: Unsubscribe =
80 | updateNumberRealtimeDB(totalVisitorsRef);
81 |
82 | const unSubscribeOnValueVisitorsByYear: Unsubscribe =
83 | updateNumberRealtimeDB(visitorsByYear);
84 |
85 | const unSubscribeOnValueVisitorsByMonth: Unsubscribe =
86 | updateNumberRealtimeDB(visitorsByMonth);
87 |
88 | const unSubscribeOnValueVisitorsByDay: Unsubscribe =
89 | updateNumberRealtimeDB(visitorsByDay);
90 |
91 | sessionStorage.setItem('visitDuringSession', 'true');
92 |
93 | const unSubscribeOnValueRealtimeDB = () => {
94 | unSubscribeOnValueTotalVisitors();
95 | unSubscribeOnValueVisitorsByYear();
96 | unSubscribeOnValueVisitorsByMonth();
97 | unSubscribeOnValueVisitorsByDay();
98 | };
99 |
100 | return unSubscribeOnValueRealtimeDB;
101 | }
102 | };
103 |
104 | export const getTodayVisitors = (
105 | setTodayVisitors: Dispatch>
106 | ) => {
107 | const dbRef = ref(realtimeDB, visitorsByDay);
108 |
109 | const unSubscribeOnValueRealtimeDB = onValue(dbRef, (snapshot) => {
110 | const data = snapshot.val();
111 | setTodayVisitors(Number(data));
112 | });
113 |
114 | return unSubscribeOnValueRealtimeDB;
115 | };
116 |
117 | export const getTotalVisitors = (
118 | setTotalVisitors: Dispatch>
119 | ) => {
120 | const dbRef = ref(realtimeDB, totalVisitorsRef);
121 |
122 | const unSubscribeOnValueRealtimeDB = onValue(dbRef, (snapshot) => {
123 | const data = snapshot.val();
124 | setTotalVisitors(Number(data));
125 | });
126 |
127 | return unSubscribeOnValueRealtimeDB;
128 | };
129 |
--------------------------------------------------------------------------------
/src/service/firebase/storage.ts:
--------------------------------------------------------------------------------
1 | import { getBlob, getDownloadURL, ref, uploadBytes } from 'firebase/storage';
2 | import { storage } from './config';
3 |
4 | export const uploadImageToStorage = async (file: File, storageRef: string) => {
5 | const uploadRef = ref(storage, storageRef);
6 | await uploadBytes(uploadRef, file);
7 | };
8 |
9 | export const getImageDownloadURL = async (storageRef: string) => {
10 | const downloadRef = ref(storage, storageRef);
11 | const imageDownloadURL = await getDownloadURL(downloadRef);
12 | return imageDownloadURL;
13 | };
14 |
15 | // CORS 문제 발생
16 | // https://firebase.google.com/docs/storage/web/download-files?hl=ko#cors_configuration
17 | // export const getImageBlob = async () => {
18 | // const imageBlobUrl = await getBlob(devRef);
19 | // return imageBlobUrl;
20 | // };
21 |
--------------------------------------------------------------------------------
/src/styles/colors.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --g1: #29292e;
3 | --g2: #434246;
4 | --g3: #4f4e53;
5 | --g4: #94949c;
6 | --g5: #aeacb3;
7 | --g6: #d5d6e0;
8 | --g7: #e6e5ec;
9 | --g8: #ffffff;
10 |
11 | /* 라이트 모드는 베이지 톤으로 */
12 | /* primary */
13 | --p1: #fd4c81;
14 | --p2: #f53f75;
15 |
16 | /* rgb */
17 | --p1-rgb: 253, 76, 129;
18 |
19 | /* common */
20 | --bw: #fff;
21 | --white: #fff;
22 | --black: #000;
23 | }
24 |
25 | [data-theme='dark'] {
26 | --g1: #e1dee6;
27 | --g2: #bdbac3;
28 | --g3: #afadb8;
29 | --g4: #7e7e85;
30 | --g5: #737075;
31 | --g6: #4c4b4e;
32 | --g7: #38383b;
33 | --g8: #1f1f20;
34 |
35 | /* primary */
36 | --p1: #9797ff;
37 | --p2: #8282f1;
38 |
39 | /* rgb */
40 | --p1-rgb: 151, 151, 255;
41 |
42 | /* common */
43 | --bw: #000;
44 | --white: #fff;
45 | --black: #000;
46 | }
47 |
48 | ::selection {
49 | color: var(--g8);
50 | background-color: var(--g1);
51 | }
52 |
53 | /* --g1: 제목 색, 기본 색 */
54 | /* --g2: g1과 계층 구조를 만드는 색 */
55 |
56 | /* --g3: 글 본문 색 */
57 | /* --g4: 날짜, 캡션 등 --g3와 계층 구조 만드는 색 */
58 |
59 | /* --g5: 플레이스 홀더 */
60 | /* --g6: 구분선 색, 작은 구분선 색 */
61 |
62 | /* --g7: 약한 버튼 색, 코드 블럭 색 --g8과 계층 구조 만드는 색*/
63 | /* --g8: 배경색 */
64 |
65 | /**
66 | *
67 | *
68 | * Core Design System - Custom Button Color
69 | *
70 | *
71 | */
72 |
73 | :root {
74 | /* 임시, 오른쪽 항을 색이 준비되면 'var(--primary7)'로 바꾸기 */
75 | --fill-button-color: var(--g7);
76 | --fill-button-label-color: var(--white);
77 | --line-button-color: var(--g1);
78 | --line-button-label-color: var(--g1);
79 | }
80 |
--------------------------------------------------------------------------------
/src/styles/common.scss:
--------------------------------------------------------------------------------
1 | @use 'text-styles.module';
2 | @use 'motion.module';
3 |
4 | $list__max__width: 640px;
5 | $post__max__width: 740px;
6 |
7 | // for responsive design
8 | $post__list__media__max__width: 704px; // 640 + 32 + 32
9 | $post__media__max__width: 824px; // 760 + 32 + 32 - 상하단 마진 위해
10 | $footer__media__max__width: 1540px;
11 |
12 | $main__layout__padding: 0 4vw;
13 | $footer__bottom__margin: 48px;
14 |
15 | // z-index
16 | $modal__common__background__z__index: 10;
17 | $floating__ui__z__index: 11;
18 | $modal__z__index: 12;
19 |
20 | // :export {
21 | // list__max__width: $list__max__width;
22 | // post__max__width: $post__max__width;
23 |
24 | // post__list__media__max__width: $post__list__media__max__width;
25 | // post__media__max__width: $post__media__max__width;
26 | // footer__media__max__width: $footer__media__max__width;
27 |
28 | // main__layout__padding: $main__layout__padding;
29 | // }
30 |
31 | @mixin placeholder {
32 | &:empty:before {
33 | content: attr(placeholder);
34 | color: var(--g5);
35 | display: inline-block;
36 | }
37 | }
38 |
39 | @mixin article__section__title__divider {
40 | margin-top: 144px;
41 |
42 | @media screen and (max-width: text-styles.$text__first__max__width) {
43 | margin-top: 120px;
44 | }
45 |
46 | @media screen and (max-width: text-styles.$text__second__max__width) {
47 | margin-top: 96px;
48 | }
49 |
50 | h2 {
51 | @include text-styles.title2;
52 | border-bottom: var(--g2) 4px solid;
53 | padding-bottom: 20px;
54 |
55 | @media screen and (max-width: text-styles.$text__first__max__width) {
56 | padding-bottom: 16px;
57 | }
58 |
59 | @media screen and (max-width: text-styles.$text__second__max__width) {
60 | padding-bottom: 12px;
61 | }
62 | }
63 | }
64 |
65 | @mixin admin__button {
66 | @include text-styles.body3;
67 | @include motion.scale__hover;
68 | position: fixed;
69 | bottom: 48px;
70 | padding: 12px 24px;
71 | background-color: var(--g7);
72 | border-radius: 16px;
73 | }
74 |
75 | .admin__button__left {
76 | @include admin__button;
77 | left: 4vw;
78 | }
79 |
80 | .admin__button__left__2 {
81 | @include admin__button;
82 | left: 7.5vw;
83 | color: #ee321d;
84 | }
85 |
86 | .admin__button__right {
87 | @include admin__button;
88 | right: 4vw;
89 | }
90 |
--------------------------------------------------------------------------------
/src/styles/fonts.css:
--------------------------------------------------------------------------------
1 | /* S of Noto Sans KR */
2 | @font-face {
3 | font-family: 'BlogTextStyles';
4 | font-style: normal;
5 | font-weight: 400;
6 | src: url('/fonts/NotoSansKR/NotoSansKR-Regular.woff2') format('woff2'),
7 | url('/fonts/NotoSansKR/NotoSansKR-Regular.woff') format('woff');
8 | unicode-range: U+AC00-D7A3;
9 | }
10 |
11 | @font-face {
12 | font-family: 'BlogTextStyles';
13 | font-style: normal;
14 | font-weight: 500;
15 | src: url('/fonts/NotoSansKR/NotoSansKR-Medium.woff2') format('woff2'),
16 | url('/fonts/NotoSansKR/NotoSansKR-Medium.woff') format('woff');
17 | unicode-range: U+AC00-D7A3;
18 | }
19 |
20 | @font-face {
21 | font-family: 'BlogTextStyles';
22 | font-style: normal;
23 | font-weight: 700;
24 | src: url('/fonts/NotoSansKR/NotoSansKR-Bold.woff2') format('woff2'),
25 | url('/fonts/NotoSansKR/NotoSansKR-Bold.woff') format('woff');
26 | unicode-range: U+AC00-D7A3;
27 | }
28 | /* E of Noto Sans KR */
29 |
30 | /* S of Open Sans */
31 | @font-face {
32 | font-family: 'BlogTextStyles';
33 | font-style: normal;
34 | font-weight: 400;
35 | src: url('/fonts/OpenSans/open-sans-v27-latin-regular.woff2') format('woff2'),
36 | url('/fonts/OpenSans/open-sans-v27-latin-regular.woff') format('woff');
37 | }
38 |
39 | @font-face {
40 | font-family: 'BlogTextStyles';
41 | font-style: normal;
42 | font-weight: 500;
43 | src: url('/fonts/OpenSans/open-sans-v27-latin-500.woff2') format('woff2'),
44 | url('/fonts/OpenSans/open-sans-v27-latin-500.woff') format('woff');
45 | }
46 |
47 | @font-face {
48 | font-family: 'BlogTextStyles';
49 | font-style: normal;
50 | font-weight: 700;
51 | src: url('/fonts/OpenSans/open-sans-v27-latin-700.woff2') format('woff2'),
52 | url('/fonts/OpenSans/open-sans-v27-latin-700.woff') format('woff');
53 | }
54 | /* E of Open Sans */
55 |
56 | /* S of Source Code Pro */
57 | @font-face {
58 | font-family: 'Source Code Pro';
59 | font-style: normal;
60 | font-weight: 400;
61 | src: url('/fonts/SourceCodePro/source-code-pro-v18-latin-regular.woff2')
62 | format('woff2'),
63 | url('/fonts/SourceCodePro/source-code-pro-v18-latin-regular.woff')
64 | format('woff');
65 | }
66 |
67 | @font-face {
68 | font-family: 'Source Code Pro';
69 | font-style: normal;
70 | font-weight: 500;
71 | src: url('/fonts/SourceCodePro/source-code-pro-v18-latin-500.woff2')
72 | format('woff2'),
73 | url('/fonts/SourceCodePro/source-code-pro-v18-latin-500.woff')
74 | format('woff');
75 | }
76 |
77 | @font-face {
78 | font-family: 'Source Code Pro';
79 | font-style: normal;
80 | font-weight: 600;
81 | src: url('/fonts/SourceCodePro/source-code-pro-v20-latin-600.woff2')
82 | format('woff2'),
83 | url('/fonts/SourceCodePro/source-code-pro-v20-latin-600.woff')
84 | format('woff');
85 | }
86 |
87 | @font-face {
88 | font-family: 'Source Code Pro';
89 | font-style: normal;
90 | font-weight: 700;
91 | src: url('/fonts/SourceCodePro/source-code-pro-v18-latin-700.woff2')
92 | format('woff2'),
93 | url('/fonts/SourceCodePro/source-code-pro-v18-latin-700.woff')
94 | format('woff');
95 | }
96 | /* E of Source Code Pro */
97 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | button {
2 | /* unset이 있으므로 첫 번째 */
3 | all: unset;
4 | cursor: pointer;
5 | }
6 |
7 | html,
8 | body {
9 | padding: 0;
10 | margin: 0;
11 | background-color: var(--g8);
12 |
13 | /* 판독성 최적화 */
14 | text-rendering: optimizeLegibility;
15 | -webkit-font-smoothing: antialiased;
16 | -moz-osx-font-smoothing: grayscale;
17 |
18 | /* iOS Safari Momentum Scroll, 버벅임 방지 */
19 | -webkit-overflow-scrolling: touch;
20 |
21 | /* View 전체 가로 스크롤 방지 (Mobile Safari) */
22 | max-width: 100vw;
23 | overflow-x: hidden;
24 | }
25 |
26 | body {
27 | user-select: none;
28 | }
29 |
30 | * {
31 | padding: 0;
32 | margin: 0;
33 | caret-color: var(--g1);
34 | outline-style: none;
35 | box-sizing: border-box;
36 | font-family: 'BlogTextStyles', Apple SD Gothic Neo, Noto Sans KR, Inter,
37 | system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica,
38 | Arial, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, sans-serif;
39 | -webkit-tap-highlight-color: transparent;
40 | }
41 |
42 | input:focus {
43 | outline: none;
44 | }
45 |
46 | h1,
47 | h2,
48 | h3,
49 | h4,
50 | h5,
51 | h6,
52 | p,
53 | a,
54 | button,
55 | li {
56 | color: var(--g1);
57 | }
58 |
59 | h1,
60 | h2,
61 | h3,
62 | h4,
63 | h5,
64 | h6,
65 | code {
66 | word-break: keep-all;
67 | }
68 |
69 | p {
70 | word-break: break-word;
71 | }
72 |
73 | code {
74 | font-family: 'Source Code Pro', Menlo, Monaco, Lucida Console, Liberation Mono,
75 | DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace;
76 | }
77 |
78 | a {
79 | text-decoration: none;
80 | cursor: pointer;
81 | display: inline-block;
82 | text-decoration: none;
83 |
84 | /* iOS에서 a tag long touch 시 나오는 모달 막기 */
85 | -webkit-touch-callout: none;
86 | }
87 |
88 | a,
89 | button,
90 | input,
91 | select,
92 | textarea {
93 | outline-style: none;
94 | }
95 |
96 | ul,
97 | ol,
98 | li {
99 | list-style: none;
100 | }
101 |
102 | address {
103 | font-style: normal;
104 | }
105 |
--------------------------------------------------------------------------------
/src/styles/motion.module.scss:
--------------------------------------------------------------------------------
1 | @use 'text-styles.module';
2 |
3 | $bezier__1: cubic-bezier(0.2, 0.6, 0.35, 1);
4 | $bezier__2: cubic-bezier(0.08, 0.56, 0.72, 0.87);
5 | $fade__in__duration: 0.7s;
6 | $fade__out__duration: 0.4s;
7 |
8 | :export {
9 | bezier__1: $bezier__1;
10 | bezier__2: $bezier__2;
11 | fade__in__duration: $fade__in__duration;
12 | fade__out__duration: $fade__out__duration;
13 | }
14 |
15 | @mixin rotate__hover {
16 | transform: rotateZ(0turn);
17 | transition: transform 0.4s $bezier__1;
18 |
19 | @media (hover: hover) {
20 | &:hover {
21 | transform: rotateZ(0.5turn);
22 | transition: transform 0.4s $bezier__1;
23 | }
24 | }
25 |
26 | @include scale__avtive;
27 | }
28 |
29 | @mixin scale__avtive {
30 | &:active {
31 | transform: scale(0.8);
32 | opacity: 0.9;
33 | transition: transform 0.4s, opacity 0.3s $bezier__1;
34 | }
35 | }
36 |
37 | @mixin opacity__p__color__hover {
38 | @media (hover: hover) {
39 | &:hover {
40 | background-color: rgba(var(--p1-rgb), 0.1);
41 | transition: background-color 0.4s $bezier__1;
42 |
43 | @media screen and (max-width: text-styles.$text__second__max__width) {
44 | background-color: unset;
45 | transition: unset;
46 | }
47 | }
48 | }
49 |
50 | &:active {
51 | background-color: rgba(var(--p1-rgb), 0.3);
52 | transition: background-color 0.4s $bezier__1;
53 |
54 | @media screen and (max-width: text-styles.$text__second__max__width) {
55 | background-color: unset;
56 | transition: unset;
57 | }
58 | }
59 | }
60 |
61 | @mixin scale__hover {
62 | transform: scale(1);
63 | opacity: 1;
64 | transition: transform 0.2s $bezier__2, opacity 0.2s $bezier__2;
65 |
66 | @media (hover: hover) {
67 | &:hover {
68 | transform: scale(1.02);
69 | transition: transform 0.1s $bezier__2;
70 | }
71 | }
72 |
73 | &:active {
74 | transform: scale(0.98);
75 | opacity: 0.9;
76 | transition: transform 0.2s $bezier__2;
77 | }
78 | }
79 |
80 | @mixin fade__in {
81 | animation: fide__in__animation $fade__in__duration $bezier__2;
82 |
83 | @keyframes fide__in__animation {
84 | from {
85 | opacity: 0;
86 | }
87 |
88 | to {
89 | opacity: 1;
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/svg/icon-facebook-24.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 |
3 | const IconFacebook24: FC = () => {
4 | return (
5 | <>
6 |
30 | >
31 | );
32 | };
33 |
34 | export default IconFacebook24;
35 |
--------------------------------------------------------------------------------
/src/svg/icon-linkedin-24.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 |
3 | const IconLinkedIn24: FC = () => {
4 | return (
5 | <>
6 |
22 | >
23 | );
24 | };
25 |
26 | export default IconLinkedIn24;
27 |
--------------------------------------------------------------------------------
/src/svg/icon-menu-24.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 |
3 | type Props = {
4 | color: TSVGColor;
5 | };
6 |
7 | const IconMenu24: FC = ({ color }) => {
8 | return (
9 | <>
10 |
21 | >
22 | );
23 | };
24 |
25 | export default IconMenu24;
26 |
--------------------------------------------------------------------------------
/src/svg/icon-new-tap-24.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 |
3 | type Props = {
4 | color: TSVGColor;
5 | };
6 |
7 | const IconNewTap24: FC = ({ color }) => {
8 | return (
9 | <>
10 |
19 | >
20 | );
21 | };
22 |
23 | export default IconNewTap24;
24 |
--------------------------------------------------------------------------------
/src/svg/icon-plant-family-logo-24.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 |
3 | const IconPlantFamilyLogo24: FC = () => {
4 | return (
5 |
50 | );
51 | };
52 |
53 | export default IconPlantFamilyLogo24;
54 |
--------------------------------------------------------------------------------
/src/svg/icon-reminder-logo-24.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 |
3 | const IconReminderLogo24: FC = () => {
4 | return (
5 |
43 | );
44 | };
45 |
46 | export default IconReminderLogo24;
47 |
--------------------------------------------------------------------------------
/src/svg/icon-theme-24.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 |
3 | type Props = {
4 | color: TSVGColor;
5 | };
6 |
7 | const IconTheme24: FC = ({ color }) => {
8 | return (
9 | <>
10 |
24 | >
25 | );
26 | };
27 |
28 | export default IconTheme24;
29 |
--------------------------------------------------------------------------------
/src/svg/icon-twitter-24.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 |
3 | const IconTwitter24: FC = () => {
4 | return (
5 | <>
6 |
24 | >
25 | );
26 | };
27 |
28 | export default IconTwitter24;
29 |
--------------------------------------------------------------------------------
/src/svg/icon-yoonseul-logo-24.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 |
3 | const IconYoonSeulLogo24: FC = () => {
4 | return (
5 | <>
6 |
61 | >
62 | );
63 | };
64 |
65 | export default IconYoonSeulLogo24;
66 |
--------------------------------------------------------------------------------
/src/types/type.d.ts:
--------------------------------------------------------------------------------
1 | type TtextTagName = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p';
2 |
3 | type Tchildren = JSX.Element | JSX.Element[];
4 |
5 | type TColor =
6 | | '--g1'
7 | | '--g2'
8 | | '--g3'
9 | | '--g4'
10 | | '--g5'
11 | | '--g6'
12 | | '--g7'
13 | | '--g8';
14 |
15 | type TSVGColor =
16 | | 'var(--g1)'
17 | | 'var(--g2)'
18 | | 'var(--g3)'
19 | | 'var(--g4)'
20 | | 'var(--g5)'
21 | | 'var(--g6)'
22 | | 'var(--g7)'
23 | | 'var(--g8)';
24 |
--------------------------------------------------------------------------------
/src/views/screens/ux-collection/list.module.scss:
--------------------------------------------------------------------------------
1 | @use '../../../styles/common';
2 | @use '../../../styles/text-styles.module';
3 |
4 | .main {
5 | margin: 0 auto 180px;
6 | max-width: common.$list__max__width;
7 | // overflow-x: hidden;
8 |
9 | @media screen and (max-width: common.$post__list__media__max__width) {
10 | padding: common.$main__layout__padding;
11 | }
12 |
13 | // 구독 링크 상단 마진 조정 위한 CSS
14 | ul {
15 | section {
16 | margin-top: 72px;
17 |
18 | @media screen and (max-width: common.$post__list__media__max__width) {
19 | margin-top: 36px;
20 | }
21 | }
22 | }
23 | }
24 |
25 | .li {
26 | margin-top: 72px;
27 |
28 | @media screen and (max-width: common.$post__list__media__max__width) {
29 | margin-top: 36px;
30 | }
31 |
32 | time {
33 | @include text-styles.body3;
34 | color: var(--g4);
35 | }
36 |
37 | .text {
38 | @include text-styles.body2;
39 | color: var(--g2);
40 | margin-top: 8px;
41 | line-height: 1.5;
42 | }
43 |
44 | position: relative; // NextJS Image 경고 제거
45 | width: 100%;
46 | height: 100%;
47 | display: block;
48 |
49 | // NextJS Image
50 | span {
51 | position: relative !important;
52 | margin-top: 12px !important;
53 |
54 | img {
55 | width: 100% !important;
56 | height: 100% !important;
57 | position: relative !important;
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true
17 | },
18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------