Day6 - 커뮤니티 페이지

RINM·2023년 1월 2일

NextJS - Reddit Clone

목록 보기
6/9
post-thumbnail

Navigation Bar

상단 Nav Bar 설정

login 페이지와 register 페이지를 제외한 모든 페이지 상단에 Nav bar 적용
nav bar 적용을 위해 모든 페이지에 상단 패딩

// navbar is unavailable on register and login page
const {pathname} = useRouter();
const authRoutes = ["/register","/login"]
const authRoute = authRoutes.includes(pathname)


return <AuthProvider>
	{!authRoute && <NavBar/>}
    <div className={authRoute ? "" : "pt-12"}>
      <Component {...pageProps} />
    </div>
  </AuthProvider>

UI

UI

  • 로그인한 경우 : 로그아웃 버튼 표시
  • 로그인하지 않은 경우: 로그인 버튼 & 회원가입 버튼 표시
<div className='flex'>
	{!loading &&(authenticated ?(
		<button className='w-20 p-2 mr-2 text-center text-sm text-white bg-gray-400 rounded' onClick={logout}>LOGOUT</button>
        ):(
		<Fragment>
			<div className='w-20 p-2 mr-2 text-center font-bold text-blue-500 border-blue-400 rounded'>
				<Link href="/login">LOGIN</Link>
			</div>
            <div className='w-20 p-2 text-center text-white bg-blue-500 rounded'>
            	<Link href="/login">SIGN UP</Link>
            </div>
        </Fragment>
                )
    )}
</div>

로그아웃

Fontend

백엔드 logout API를 호출하여 쿠키 정보를 삭제하고 디스패치로 유저 정보를 로그아웃 상태로 변경한다.

const handleLogout = () =>{
	axios.post("/auth/logout")
	.then(()=>{
		dispatch("LOGOUT")
		window.location.reload();
    })
	.catch((err)=>{
      console.error(err);
	})
}

Backend

Set-Cookie에서 expires를 0으로 설정하면 쿠키가 바로 만료되어 사라진다.

const logout = async (_: Request,res:Response) => {
    res.set(
        "Set-Cookie",
        cookie.serialize("token","",{
            httpOnly:true,
            expires: new Date(0),
            path:'/'
        })
    )
    res.status(200).json({success: true})
}

커뮤니티 리스트

SWR(Stale-while-revalidate)

axios 대신 SWR을 사용하여 커뮤니티 리스트를 백엔드에서 받아온다.
swr은 데이터를 가져오는 react hook 라이브러리로 캐싱 데이터를 먼저 반환한다음 가져오기 요청으로 최신데이터를 제공한다.

import useSWR from 'swr'

function Profile(){
	const fetcher =async (url:string) => {
		return await axios.get(url).then((res)=>res.data);
	}
	const {data, error} = useSWR('key',fetcher)
}

key는 API URL로 key가 같으면 캐싱을 할 수 있다. 따라서 여러 컴포넌트에서 같은 데이터를 요청하면 캐싱을 사용하여 더 빠르게 데이터를 가져올 수 있다.
fetcher는 Axios나 GraphQL을 주로 사용한다.

Frontend

const fetcher =async (url:string) => {
    return await axios.get(url).then((res)=>res.data);
  }
  const address = "http://localhost:4000/api/subs/sub/topSubs";

  const  {data: topSubs} = useSWR<Sub[]>(address,fetcher)

Backend

const topSubs =async (_: Request, res: Response) => {
    try {
        const imageUrlExp= `COALESCE('${process.env.APP_URL}/images/'||s."imageUrn",
        'https://www.gravatar.com/avatar?d=mp&f=y'
        )`;

        const subs = await AppDataSource
        .createQueryBuilder()
        .select(`s.title, s.name, ${imageUrlExp} as "imageUrl", count(p.id) as postCount`)
        .from(Sub,"s")
        .leftJoin(Post,"p",`s.name=p."subName"`)
        .groupBy('s.title, s.name, "imageUrl"')
        .orderBy(`"postCount"`,"DESC")
        .limit(5)
        .execute();

        return res.json(subs);
      
    } catch (error) {
        console.error(error);
        return res.status(500).json({error: "Something went wrong"})
        
    }
}

COALESCE: imageUrn 컬럼이 비어있는 경우 기본 이미지를 결과값으로 제공
모든 Sub를 해당 sub에 있는 post의 숫자로 정렬한 다음(postCount) 상위 5개 sub의 정보만 추출

gravatar domain 허용

next.config.js에 gravatar를 허용 도메인으로 지정해야 gravatar에서 이미지를 불러올 수 있다.

const nextConfig = {
  reactStrictMode: true,
  images:{
    domains:["www.gravatar.com"]
  }
}

커뮤니티 상세 페이지

instance to plain

base entity에서 toJSON이라는 메서드를 선언한다. 이 메서드에서 instance를 plain 객제로 변환한다. 이를 통해 Sub 객체에서 Expose한 기본 imageUrl 설정을 적용할 수 있다.

import { instanceToPlain } from "class-transformer";
import { BaseEntity, CreateDateColumn, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";

export default abstract class Entity extends BaseEntity{
    @PrimaryGeneratedColumn()
    id: number;

    @CreateDateColumn()
    createdAt: Date;

    @UpdateDateColumn()
    updatedAt: Date;

    toJSON(){
        return instanceToPlain(this)
    }
}

Frontend

swr을 이용하여 해당 sub의 정보를 받아온다.

const fetcher =async (url:string) => {
	try {
		const res  = await axios.get(url);
		return res.data
	} catch (error:any) {
		throw error.response.data
	}
}

const router = useRouter()
const subName = router.query.sub;

const {data: sub, error} = useSWR(subName?`/subs/${subName}`:null,fetcher);

Backend

const getSub = async (req: Request, res:Response) => {
    const name = req.params.name;

    try {
        const sub = await Sub.findOneByOrFail({name});
        return res.json(sub);
    } catch (error) {
        return res.status(404).json({error:"Sub not found"})
    }
}
router.get("/:name",userMiddleware, getSub)

이미지 업로드

Frontend

sub의 소유자일 때만 배너나 프로필을 선택하여 이미지를 업로드할 수 있게 설정

const [ownSub, setownSub] = useState(false)
const {authenticated, user} = useAuthState();

useEffect(() => {
	if(!sub || !user)  return;
	
	setownSub(authenticated && user.username === sub.username)
}, [sub])

const openFileInput = (type: string) =>{
	if(!ownSub) return ;
	
    const fileInput = fileInputRef.current
	if(fileInput){
		fileInput.name= type;
		fileInput.click();
	}
}

useRef

특정 dom을 선택할 때 사용
banner 혹은 profile image를 선택했을 때 input으로 이미지를 업로드할 수 있도록 설정

const fileInputRef = useRef<HTMLInputElement>(null)

const openFileInput = (type: string) =>{
	const fileInput = fileInputRef.current
	if(fileInput){
		fileInput.name= type;
      	//임의로 클릭
		fileInput.click();
	}
}

파일 업로드. 이때 type에 배너 이미지인지 프로필 이미지인지 명시하여 formData를 전송한다.

    const uploadImage = async (e:ChangeEvent<HTMLInputElement>) => {
        if(e.target.files === null) return;
        
        const file = e.target.files[0];

        const formData = new FormData();
        formData.append("file",file)
        formData.append("type",fileInputRef.current!.name)

        try {
            await axios.post(`subs/${sub.name}/upload`,formData,{
                headers:{"Context-Type":"multipart/form-data"}
            })
        } catch (error) {
            console.error(error);
            
        }
    }

Backend

server에서도 이미지를 업로드하려는 사용자가 sub의 owner가 맞는지 확인
미들웨어, locals.sub으로 sub 정보를 전달한다.

const ownSub =async (req: Request, res:Response, next: NextFunction) => {
    const user : User = res.locals.user;
    try {
        const sub = await Sub.findOneByOrFail({name:req.params.name})

        if(sub.username !== user.username){
            return res.status(403).json({error: "Not the owner"})
        }

        res.locals.sub = sub;
        return next();
    } catch (error) {
        console.error(error);
        return res.status(500).json({error: "Something went wrong"})
    }
}

muter

업로드한 이미지를 서버에 저장하는 모듈

npm i --save multer
npm i --save-dev @types/multer

저장 경로, 파일 저장 이름, 파일 필터

import multer, { FileFilterCallback } from "multer";

const upload = multer({
    storage: multer.diskStorage({
        destination: "public/images",
        filename: (_,file,callback) =>{
            const name = makeId(10);
            callback(null,name+path.extname(file.originalname));
        },
    }),
    fileFilter: (_,file: any, callback: FileFilterCallback) =>{
        if(file.mimetype == "image/jpeg" || file.mimetype=="image/png"){
            callback(null,true);
        } else{
            callback(new Error("Not an image"))
        }
    }
})

/server/public/image에 이미지 저장. 해당 Sub의 imageUrn 교체, 지난 파일 삭제

const uploadSubImage =async (req: Request, res:Response) => {
    const sub : Sub = res.locals.sub;
    try {
        const type = req.body.type;

        //delete file when type is not defined
        if(type !=="image" && type !=="banner"){
            if(!req.file?.path){
                return res.status(400).json({error:"Unvalid file"})
            }
            unlinkSync(req.file.path);
            return res.status(400).json({error: "Wrong upload type"})
        }

        //If there's aleady an image, then delete old image and change urn on sub
        let oldImageUrn : string = "";
        if(type==="image"){
            oldImageUrn = sub.imageUrn || ""
            sub.imageUrn = req.file?.filename || "";
        } else if(type==="banner"){
            oldImageUrn = sub.bannerUrn || ""
            sub.bannerUrn = req.file?.filename || "";
        }

        //save Sub
        await sub.save();

        //delete old image
        if(oldImageUrn !==""){
            const fullFileName = path.resolve(
                process.cwd(), "public", "images", oldImageUrn
            );
            unlinkSync(fullFileName)
        }
        
        return res.json(sub);
    } catch (error) {
        console.error(error);
        return res.status(500).json({error: "Something went wrong"})
    }
}

서버에서 이미지를 front에 넘겨주기 위하여 server.ts에서 이미지 경로인 public 폴더를 브라우저에서 접근할 수 있게 허용

app.use(express.static("public"))

0개의 댓글