
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>

<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>
백엔드 logout API를 호출하여 쿠키 정보를 삭제하고 디스패치로 유저 정보를 로그아웃 상태로 변경한다.
const handleLogout = () =>{
axios.post("/auth/logout")
.then(()=>{
dispatch("LOGOUT")
window.location.reload();
})
.catch((err)=>{
console.error(err);
})
}
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})
}

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을 주로 사용한다.
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)
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의 정보만 추출
next.config.js에 gravatar를 허용 도메인으로 지정해야 gravatar에서 이미지를 불러올 수 있다.
const nextConfig = {
reactStrictMode: true,
images:{
domains:["www.gravatar.com"]
}
}

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

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();
}
}
특정 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);
}
}
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"})
}
}
업로드한 이미지를 서버에 저장하는 모듈
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"))