// post.js
const initialState = {
list: [],
paging: { start: null, next: null, size: 3 },
is_loading: false,
};
...
const LOADING = "LOADING";
...
const setPost = createAction(SET_POST, (post_list, paging) => ({
post_list,
paging,
}));
const loading = createAction(LOADING, (is_loading) => ({ is_loading }));
const getPostFB = (start = null, size = 3) => {
return function (dispatch, getState, { history }) {
let _paging = getState().post.paging;
if (_paging.start && !_paging.next) {
// 시작정보가 기록되었는데 다음 가져올 데이터가 없다면? 앗, 리스트가 끝났겠네요!
// 그럼 아무것도 하지말고 return을 해야죠!
return;
}
dispatch(loading(true));
const postDB = firestore.collection("image_community");
let query = postDB.orderBy("insert_dt", "desc");
if (start) {
query = query.startAt(start);
}
query
.limit(size + 1)
// 사이즈보다 1개 더 크게 가져옵시다.
// 3개씩 끊어서 보여준다고 할 때, 4개를 가져올 수 있으면? 앗 다음 페이지가 있겠네하고 알 수 있으니까요.
// 만약 4개 미만이라면? 다음 페이지는 없겠죠! :)
.get()
.then((docs) => {
let post_list = [];
let paging = {
// 시작점에는 새로 가져온 정보의 시작점을 넣고,
// next에는 마지막 항목을 넣습니다.
// (이 next가 다음번 리스트 호출 때 start 파라미터로 넘어올거예요.)
start: docs.docs[0],
next:
docs.docs.length === size + 1
? docs.docs[docs.docs.length - 1]
: null,
size: size,
};
docs.forEach((doc) => {
let _post = doc.data();
let post = Object.keys(_post).reduce(
// reduce 쓰는법 참고!!!
(acc, cur) => {
if (cur.indexOf("user_") !== -1) {
return {
...acc,
user_info: { ...acc.user_info, [cur]: _post[cur] },
// [cur] 이렇게 써야 cur의 변수 값이 들어가짐. 그냥 cur 쓰면 문자열이 들어감...?!
// value 가져올때도 _post.cur라고 쓰면 안되고 _post[cur] 라고 써야함
// reduce만의 특징인듯?
};
}
return { ...acc, [cur]: _post[cur] };
},
{ id: doc.id, user_info: {} }
);
post_list.push(post);
});
if (docs.docs.length === size + 1) {
post_list.pop();
}
dispatch(setPost(post_list, paging));
});
};
};
...
export default handleActions(
{
[SET_POST]: (state, action) =>
produce(state, (draft) => {
draft.list.push(...action.payload.post_list);
draft.paging = action.payload.paging;
draft.is_loading = false;
}),
...
[LOADING]: (state, action) =>
produce(state, (draft) => {
draft.is_loading = action.payload.is_loading;
}),
},
initialState
);
yarn add lodash
// InfinityScroll.js
import React from "react";
import _ from "lodash";
import { Spinner } from "../elements";
const InfinityScroll = (props) => {
const { children, callNext, is_next, loading } = props;
const _handleScroll = _.throttle(() => {
if (loading) {
// 로딩중이면 리턴
return;
}
const { innerHeight } = window;
const { scrollHeight } = document.body;
const scrollTop =
(document.documentElement && document.documentElement.scrollTop) ||
document.body.scrollTop;
// 크로스 브라우징 때문에 이렇게 두가지로 적음
if (scrollHeight - innerHeight - scrollTop < 200) {
callNext();
}
}, 300);
const handleScroll = React.useCallback(_handleScroll, [loading]);
// loading이 변할때만 _handleScroll 초기화
React.useEffect(() => {
if (loading) {
return;
}
if (is_next) {
// 다음 페이지가 있을때만 handleScroll 붙여주기
window.addEventListener("scroll", handleScroll);
} else {
window.removeEventListener("scroll", handleScroll);
}
return () => window.removeEventListener("scroll", handleScroll);
// 컴포넌트가 소멸할때 이벤트 리스너 없애주기
}, [is_next, loading]);
// is_next 또는 loading이 변할때마다 useEffect 동작
return (
<React.Fragment>
{props.children}
{is_next && <Spinner size="120" />}
</React.Fragment>
);
};
export default InfinityScroll;
InfinityScroll.defaultProps = {
children: null,
callNext: () => {},
is_next: false,
loading: false,
};
// Spinner.js
import React from "react";
import styled from "styled-components";
const Spinner = (props) => {
const { type, size, is_dim } = props;
return (
<React.Fragment>
<SpinnerWrap type={type} is_dim={is_dim}>
<SpinnerSvg size={size} />
</SpinnerWrap>
</React.Fragment>
);
};
Spinner.defaultProps = {
type: "inline", // inline, page
is_dim: false,
size: 60,
};
const SpinnerWrap = styled.div`
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 20px 0;
${(props) =>
props.type === "page"
? `position: fixed;
height: 95vh;
top: 0;
left: 0;
padding: 0;
zIndex: 9999;`
: ``}
${(props) =>
props.is_dim
? `
background: rgba(0,0,0,0.4);
height: 100vh;
`
: ``}
`;
const SpinnerSvg = styled.div`
--size: ${(props) => props.size}px;
width: var(--size);
height: var(--size);
background-image: url(\"data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' style='margin: auto; background: none; display: block; shape-rendering: auto;' width='200px' height='200px' viewBox='0 0 100 100' preserveAspectRatio='xMidYMid'%3E%3Cg transform='rotate(0 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.9166666666666666s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(30 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.8333333333333334s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(60 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.75s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(90 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.6666666666666666s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(120 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.5833333333333334s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(150 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.5s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(180 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.4166666666666667s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(210 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.3333333333333333s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(240 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.25s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(270 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.16666666666666666s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(300 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.08333333333333333s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(330 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='0s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3C/svg%3E\");
background-size: var(--size);
`;
export default Spinner;
// PostList.js
...
import InfinityScroll from "../shared/InfinityScroll";
...
const is_loading = useSelector((state) => state.post.is_loading);
const paging = useSelector((state) => state.post.paging);
...
return (
<React.Fragment>
<InfinityScroll
callNext={() => {
dispatch(postActions.getPostFB(paging.next));
}}
is_next={paging.next ? true : false}
loading={is_loading}
>
{post_list.map((p, idx) => {
if (p.user_info.user_id === user_info?.uid) {
//user_info?.uid -> user_info가 있는지 확인하면서 .uid 가져오기
return <Post key={p.id} {...p} is_me></Post>;
} else {
return <Post key={p.id} {...p}></Post>;
}
// p에 있는 각 key, value를 한 번에 props로 넘겨줄때 {...p} 이렇게 쓰면 되네...?
})}
</InfinityScroll>
</React.Fragment>
);
// PostDetail.js
...
import { useSelector, useDispatch } from "react-redux";
import { actionCreators as postActions } from "../redux/modules/post";
const PostDetail = (props) => {
const id = props.match.params.id;
const post_list = useSelector((store) => store.post.list);
const user_info = useSelector((state) => state.user.user);
const post_idx = post_list.findIndex((p) => p.id === id);
const post = post_list[post_idx];
const dispatch = useDispatch();
React.useEffect(() => {
if (post) {
return;
}
dispatch(postActions.getOnePostFB(id));
}, []);
return (
<React.Fragment>
{post && (
<Post
{...post}
is_me={post.user_info.user_id === user_info?.uid ? true : false}
/>
)}
<CommentWrite />
<CommentList />
</React.Fragment>
);
};
export default PostDetail;
// post.js
...
const getOnePostFB = (id) => {
return function (dispatch, getState, { history }) {
// PostDetail.js
// 리덕스를 불러와서 사용하는 방식은 /detail 페이지에서 새로고침했을때 리덕스가 날아가기때문에 데이터가 없어짐
// 그래서 바로 firestore에서 데이터를 가져와 사용함.
const postDB = firestore.collection("image_community");
postDB
.doc(id)
.get()
.then((doc) => {
let _post = doc.data();
let post = Object.keys(_post).reduce(
// reduce 쓰는법 참고!!!
(acc, cur) => {
if (cur.indexOf("user_") !== -1) {
return {
...acc,
user_info: { ...acc.user_info, [cur]: _post[cur] },
// [cur] 이렇게 써야 cur의 변수 값이 들어가짐. 그냥 cur 쓰면 문자열이 들어감...?!
// value 가져올때도 _post.cur라고 쓰면 안되고 _post[cur] 라고 써야함
// reduce만의 특징인듯?
};
}
return { ...acc, [cur]: _post[cur] };
},
{ id: doc.id, user_info: {} }
);
dispatch(setPost([post]));
});
};
};
...
export default handleActions(
{
[SET_POST]: (state, action) =>
produce(state, (draft) => {
draft.list.push(...action.payload.post_list);
draft.list = draft.list.reduce((acc, cur) => {
// getPostFB, getOnePostFB 에서 모두 dispatch(setPost)를 사용함
// 이로인해 getOnePostFB에서 한번 불러온 post가 getPostFB에서도 불러와질수 있음.
// 아래는 이 때문에 중복된걸 제거해주는 로직
if (acc.findIndex((a) => a.id === cur.id) === -1) {
return [...acc, cur];
} else {
acc[acc.findIndex((a) => a.id === cur.id)] = cur;
return acc;
}
}, []);
if (action.payload.paging) {
draft.paging = action.payload.paging;
}
draft.is_loading = false;
}),
...
// comment.js
import { createAction, handleActions } from "redux-actions";
import { produce } from "immer";
import { firestore } from "../../shared/firebase";
import "moment";
import moment from "moment";
const initialState = {
list: {},
};
const SET_COMMENT = "SET_COMMENT";
const setComment = createAction(SET_COMMENT, (post_id, comment_list) => ({
post_id,
comment_list,
}));
const getCommentFB = (post_id) => {
return function (dispatch, getState, { history }) {
if (!post_id) {
return;
}
const commentDB = firestore.collection("comment");
commentDB
.where("post_id", "==", post_id)
.orderBy("insert_dt", "desc")
// 복합쿼리, 별도의 firestore 설정이 있어야 사용 가능.
.get()
.then((docs) => {
let list = [];
docs.forEach((doc) => {
list.push({ ...doc.data(), id: doc.id });
});
dispatch(setComment(post_id, list));
})
.catch((err) => {
console.log("댓글 정보를 가져오다가 실패했는디요?");
});
};
};
export default handleActions(
{
[SET_COMMENT]: (state, action) =>
produce(state, (draft) => {
draft.list[action.payload.post_id] = action.payload.comment_list;
// comment들을 담는 list를 딕셔너리 형으로 만들고
// post_id를 key, 해당 post의 comment들(딕셔너리)을 배열 형태로 담은 comment_list를 value로 할당.
}),
},
initialState
);
const actionCreators = {
getCommentFB,
setComment,
};
export { actionCreators };
// CommentList.js
const CommentList = (props) => {
const dispatch = useDispatch();
const comment_list = useSelector((state) => state.comment.list);
const { post_id } = props;
React.useEffect(() => {
if (!comment_list[post_id]) {
// 매번 firestore에 접근해서 데이터를 가져오면 리소스 낭비가 큼.
// 해당하는 post_id가 redux 데이터에 없으면 DB에서 한번 가져오고,
// 그 이후부터 firestore에서 한번 가져온 데이터는 redux에서 데이터를 가져옴 (아래 뷰 코드에서 map으로 가져오는중...)
dispatch(commentActions.getCommentFB(post_id));
}
}, []);
if (!comment_list[post_id] || !post_id) {
// 아직 DB에서 데이터가 안넘어왔을때는 오류를 내니까 공백이라도 보여주자.
return null;
}
return (
<React.Fragment>
<Grid padding="16px">
{comment_list[post_id].map((c) => {
return <CommentItem key={c.id} {...c} />;
})}
</Grid>
</React.Fragment>
);
};
// CommentWrite.js
import React from "react";
import { Grid, Input, Button } from "../elements";
import { useDispatch } from "react-redux";
import { actionCreators as commentActions } from "../redux/modules/comment";
const CommentWrite = (props) => {
const dispatch = useDispatch();
const { post_id } = props;
const [comment_text, setCommentText] = React.useState("");
const onChange = (e) => {
setCommentText(e.target.value);
};
const write = () => {
if (comment_text === "") {
window.alert("댓글을 입력해주세요!");
return;
}
// 입력된 텍스트는 지우기!
setCommentText("");
// 파이어스토어에 추가합니다.
dispatch(commentActions.addCommentFB(post_id, comment_text));
};
return (
<React.Fragment>
<Grid padding="16px" is_flex>
<Input
placeholder="댓글 내용을 입력해주세요 :)"
_onChange={onChange}
value={comment_text}
// 제출하면 comment_text가 "" 이렇게 되는데, 이걸 이용해서 제출 후 input을 비워주기 위함.
onSubmit={write}
is_Submit
/>
<Button width="50px" margin="0px 2px 0px 2px" _onClick={write}>
작성
</Button>
</Grid>
</React.Fragment>
);
};
CommentWrite.defaultProps = {
post_id: "",
};
export default CommentWrite;
// Input.js
{is_Submit ? (
// 댓글 작성창 등 submit 후 input value를 비워줘야 할때 사용
// submit하면 value를 받아올때 setCommentText을 이용해 ""로 받아옴.
<ElInput
placeholder={placeholder}
onChange={_onChange}
type={type}
value={value}
onKeyPress={(e) => {
if (e.key === "Enter") {
onSubmit();
}
}}
/>
) : (
<ElInput placeholder={placeholder} onChange={_onChange} type={type} />
// 로그인, 회원가입 등 input value 초기화 안해도 되는곳에서 사용
)}
// comment.js
...
import firebase from "firebase/app";
import { actionCreators as postActions } from "./post";
...
const addCommentFB = (post_id, contents) => {
return function (dispatch, getState, { history }) {
const commentDB = firestore.collection("comment");
const user_info = getState().user.user;
let comment = {
post_id: post_id,
user_id: user_info.uid,
user_name: user_info.user_name,
user_profile: user_info.user_profile,
contents: contents,
insert_dt: moment().format("YYYY-MM-DD hh:mm:ss"),
};
commentDB.add(comment).then((doc) => {
const postDB = firestore.collection("image_community");
const post = getState().post.list.find((l) => l.id === post_id);
const increment = firebase.firestore.FieldValue.increment(1);
// 특정 필드의 value에 값을 더하고 싶을때 이렇게 FieldValue.increment() 쓰면 됨.
comment = { ...comment, id: doc.id };
postDB
.doc(post_id)
.update({ comment_cnt: increment })
.then((_post) => {
dispatch(addComment(post_id, comment));
if (post) {
dispatch(
postActions.editPost(post_id, {
comment_cnt: parseInt(post.comment_cnt) + 1,
})
// 겉으로 보기엔 redux에 1을 더해줌. 실제 DB comment_cnt랑 다를수 있음.
// 새로고침하면 DB의 comment_cnt를 새로 불러옴.
);
}
});
});
};
};
...
export default handleActions(
{
...
[ADD_COMMENT]: (state, action) =>
produce(state, (draft) => {
draft.list[action.payload.post_id].unshift(action.payload.comment);
}),
...
},
initialState
);
// NotiBadge.js
import React from "react";
import { Badge } from "@material-ui/core";
import NotificationsIcon from "@material-ui/icons/Notifications";
import { realtime } from "../shared/firebase";
import { useSelector } from "react-redux";
const NotiBadge = (props) => {
const [is_read, setIsRead] = React.useState(true);
const user_id = useSelector((state) => state.user.user.uid);
const notiCheck = () => {
const notiDB = realtime.ref(`noti/${user_id}`);
notiDB.update({ read: true });
props._onClick();
};
React.useEffect(() => {
const notiDB = realtime.ref(`noti/${user_id}`);
// realtime DB 가져오는법
notiDB.on("value", (snapshot) => {
// realtime DB에서 .on()은 변화를 계속 감지함
if (snapshot.val()) {
setIsRead(snapshot.val().read);
}
});
return () => notiDB.off();
// 컴포넌트 사라질대 notiDB 구독 종료
}, [user_id]);
return (
<React.Fragment>
<Badge
color="secondary"
variant="dot"
invisible={is_read}
onClick={notiCheck}
>
<NotificationsIcon />
</Badge>
</React.Fragment>
);
};
NotiBadge.defaultProps = {
_onClick: () => {},
};
export default NotiBadge;
// Notification.js
import React from "react";
import { Grid, Text, Image } from "../elements";
import Card from "../components/Card";
import { realtime } from "../shared/firebase";
import { useSelector } from "react-redux";
const Notification = (props) => {
const user = useSelector((state) => state.user.user);
const [noti, setNoti] = React.useState([]);
React.useEffect(() => {
if (!user) {
return;
}
const notiDB = realtime.ref(`noti/${user.uid}/list`);
const _noti = notiDB.orderByChild("insert_dt");
_noti.once("value", (snapshot) => {
// realtime DB에서 .once()는 한번 데이터 가져오고 끝. .on()처럼 변화를 감지하지 않음.
if (snapshot.exists()) {
let _data = snapshot.val();
let _noti_list = Object.keys(_data)
.reverse()
.map((s) => {
return _data[s];
});
setNoti(_noti_list);
}
});
}, [user]);
return (
<>
<Grid padding="16px" bg="#EFF6FF">
{noti.map((n, idx) => {
return <Card key={`noti_${idx}`} {...n}></Card>;
})}
</Grid>
</>
);
};
export default Notification;
// comment.js
if (post) {
dispatch(
postActions.editPost(post_id, {
comment_cnt: parseInt(post.comment_cnt) + 1,
})
);
// 댓글 갯수 업데이트 된 이후 noti DB 업데이트 진행
if (post.user_info.user_id !== comment.user_id) {
// realtime DB에 값 추가하는 방법
const _noti_item = realtime.ref(
`noti/${post.user_info.user_id}/list`
);
// ref로 불러옴
_noti_item.set(
// 데이터 추가할때 set을 사용.
{
post_id: post.id,
user_name: comment.user_name,
image_url: post.image_url,
insert_dt: comment.insert_dt,
},
(err) => {
if (err) {
console.log("알림 저장에 실패했어요!");
} else {
const notiDB = realtime.ref(
`noti/${post.user_info.user_id}`
);
notiDB.update({ read: false });
}
}
);
}
}
git commit 카드 클릭 시 상세페이지로 이동시키기 1 - PostDetail.js 에서 firestore 불러와서 state로 뿌려주는 버전
git commit 카드 클릭 시 상세페이지로 이동시키기 2 - post.js 에서 firestore 불러와서 redux로 뿌려주는 버전 (최종)