이제 커뉴니티마다의 상세페이지를 생성시켜주자.
✅ 파일 생성
Next.js는 파일 기반 라루팅 동작 방식을 가진다. 따라서, pages의 하위 폴더들은 각각의 route에 해당하는 이름을 갖게되며, 마찬가지로 index.js 파일을 갖고 있다.
특히, route를 동적으로 구성하고 싶을 때는 []
안에 이름을 정의한 컴포넌트 파일을 만들어서 구현할 수 있다.
[]
안에는 보통 동적으로 구성하는 상세 페이지의 변수명을 넣는다. 지금과 같이 게시글의 상세페이지를 구현한다면 그 게시글의 고유의 name 또는 id가 있을 것이므로 [name].tsx
처럼 구현할 수 있는 것이다. (참고)
const SubPage = () => {
...
return ( <>
{sub &&
<>
<div>
<div>
{sub.bannerUrl ? (
<div onClick={() => openFileInput("banner")}></div>
) : (<div onClick={() => openFileInput("banner")}></div>
)}
</div>
{/* 커뮤니티 메타 데이터 */}
<div>
<div>
<div>
{sub.imageUrl && (
<Image
src={sub.imageUrl}
alt="커뮤니티 이미지"
width={70}
height={70}
onClick={() => openFileInput("image")}
/>
)}
</div>
<div>
<div><h1>{sub.title}</h1></div>
<p>{sub.name}</p>
</div>
</div>
</div>
</div>
{/* 포스트와 사이드바 */}
<div>
<div>{renderPosts} </div>
<SideBar sub={sub} />
</div>
</>
}
</>
));
};
✅ api 요청 (client)
1️⃣ 비동기통신 라이브러리인 axios를 작성한 비동기함수를 fetcher에 저장.
2️⃣ router.query
: 매개변수는 쿼리 매개변수로 페이지에 전송되어 subName에 저장(참고)
3️⃣ useSWR hook은 key 문자열과 fetcher 함수를 받는다.
const fetcher = async (url: string) => { // 1️⃣ 번
try {
const res = await Axios.get(url);
return res.data;
} catch (err) {
throw err.response.data;
}
};
const router = useRouter();
const subName = router.query.sub; // 2️⃣ 번
const {data: sub, error} = useSWR(subName ? `subs/${subName}`: null, fetcher);// 3️⃣ 번
✅ getSub 핸들러 (server)
1️⃣ req.params
: 라우터의 매개변수
/:id/:name
경로가 있으면 :id
속성과 :name
속성을 req.params.id
, req.params.name
으로 사용할 수 있다. www.example.com/post/1/jun
일 경우 1
과 jun
을 받는다.참고하자! 👉 [EXPRESS] 📚 req.params / req.query / req.body 🤔 차이 정리
2️⃣ findOneByOrFail
: 주어진 과 일치하는 첫 번째 엔터티를 찾는다. 일치하는 것이 없으면 반환된 약속을 거부. (참고)
const getSub = async (req: Request, res: Response) => {
const name = req.params.name; // 1️⃣ 번
try {
const sub = await Sub.findOneByOrFail({ name }); // 2️⃣ 번
return res.json(sub);
} catch (error) {
return res.status(404).json({ error: '게시글을 찾을 수 없습니다.' });
}
};
router.get('/:name', userMiddleware, getSub);
상세페이지에 커뮤니티에 대한 데이터들이 담기는 것을 확인할 수 있는데, 이미지는 출력이 되지 않는 것을 확인할 수 있다. 서버에서 이미지 url을 전달하지 않아 null
값이 출력된다.
✅ instanceToPlain
: 클래스(생성자) 개체를 일반(리터럴) 개체로 변환.
// Entity.ts
export default abstract class Entity extends BaseEntity {
...
toJSON() {
return instanceToPlain(this);
}
}
아래의 그림은 만약 이미지 업로드를 별도로 하지 않을 경우 출력되는 default 이미지이다. (앞서, server에서 설정해주었다.)
이제 파일을 업로드할 것인데, 위의 이미지를 클릭하였을 때 파일선택을 할 수 있도록 해보자.
즉, 아래처럼 별도의 파일선택 input을 클릭하는 것이 아니라 위의 이미지를 클릭하면 해당 기능이 동작하도록 만들어보자.
✅ useRef은 특정 DOM을 가리킬 때 사용하는 Hook.
1️⃣ useRef
변수 생성
2️⃣ 실제로 동작하는 Input
태그는 숨기고, ref
설정
(= 이미지를 클릭할시 Input이 클릭되는 것과 마찬가지가 된 것.)
3️⃣ 배너 이미지 업로드 (업로드했던 이미지 또는 기본 이미지를 클릭)
4️⃣ 게시글 내용 속 이미지 업로드
5️⃣ ref
는 current
필드를 통해 값에 접근할 수 있는 것으로 이미지를 클릭하였다면 값이 존재하여 true
가 된다. 그리고 전달된 값이 banner
인지 image
인지를 판별한다.
const SubPage = () => {
const fileInputRef = React.useRef<HTMLInputElement>(null); // 1️⃣ 번
const openFileInput = (type: string) => { // 5️⃣ 번
const fileInput = fileInputRef.current;
if (fileInput) {
fileInput.name = type;
fileInput.click();
}
};
return(
<input // 2️⃣ 번
type="file"
hidden={true}
ref={fileInputRef}
onChange={uploadImage}
/>
...
// 배너 이미지
<div>
{sub.bannerUrl ? (
<div
style={backgroundImage: `url(${sub.bannerUrl})}
onClick={() => openFileInput('banner')}> // 3️⃣ 번
</div>
) : (
<div onClick={() => openFileInput('banner')}></div> // 3️⃣ 번
)}
</div>
...
// 커뮤니티 이미지
{sub.imageUrl && (
<Image
src={sub.imageUrl}
alt="커뮤니티 이미지"
width={70}
height={70}
className="rounded-full"
onClick={() => openFileInput('image')} // 4️⃣ 번
/>
)}
)
}
✅ 단, 자신이 생성한 커뮤니티에 대해서만 이미지를 변경 가능하게 설정해야한다.
1️⃣ 만약 ownSub
이 false
이면 openFileInput
은 종료된다. 즉, 자신의 게시글이 아니면 이미지클릭이 불가능하다.
2️⃣ 랜더링시 게시글 데이터가 존재하지 않거나, 유저에 대한 인증이 되지 않은 경우는 그대로 종료
3️⃣ 인증조건을 만족(로그인 O)하고 게시글에 등록된 유저정보와 인증조건의 유저정보가 같다면 ownSub
이 true
로 변환.
const [ownSub, setOwnSub] = useState(false);
const { authenticated, user } = useAuthState();
useEffect(() => {
if (!sub || !user) return; // 2️⃣ 번
setOwnSub(authenticated && user.username === sub.username); // 3️⃣ 번
}, [sub]);
const openFileInput = (type: string) => {
if (!ownSub) return; // 1️⃣ 번
...
}
};
✅ 이제 이미지파일을 선택하는 부분까지 완료하였고, 이미지를 선택하면 해당 이미지가 실제로 업로드 되는 것을 구현해야 한다.
✅ api 요청 (client)
1️⃣ 만약 선택한 파일이 없다면 즉 null
이라면 return
2️⃣ 배열의 첫번째 값을 file변수
에 저장.
3️⃣ FormData
: 이미지 같은 멀티미디어 파일을 페이지 전환 없이 폼 데이터를 비동기로 제출 하고 싶을 때나, 자바스크립트로 좀더 타이트하게 폼 데이터를 관리하고 싶을때 formData 객체를 이용한다.
4️⃣ append()
메소드로 key
와 value
값을 차례로 추가해주면, 곧 input 태그에 값을 입력하는 것과 같은 효과를 가진다.
formData.append(name, value)
함수를 이용해 데이터를 넣을시에 value
는 문자열로만 입력 된다.참고하자!
👉 [JS] 📚 FormData 사용법 & 응용 총정리 (+ fetch 전송하기)
👉 HTTP multipart/form-data 란?
👉 HTTP multipart/form-data 이해하기
5️⃣ api url
, formData
, header
을 post
한다.
const uploadImage = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files === null) return; // 1️⃣ 번
const file = e.target.files[0]; // 2️⃣ 번
const formData = new FormData(); // 3️⃣ 번
formData.append('file', file); // 4️⃣ 번
formData.append('type', fileInputRef.current!.name); // 4️⃣ 번
try {
await Axios.post(`/subs/${sub.name}/upload`, formData, { // 5️⃣ 번
headers: { 'context-Type': 'multipart/form-data' },
});
} catch (error: any) {
console.log(error);
}
};
✅ api 작성 (server)
1️⃣ res.locals
를 활용하여 user
을 전역에서 사용 가능한 변수로 설정
2️⃣ findOneOrFail
: 일부 ID와 일치하는 첫 번째 엔터티를 찾거나 옵션을 찾는다. 일치하는 것이 없으면 반환된 약속을 거부한다.
3️⃣ 만약 커뮤니티에 등록된 usename
과 유저정보의 username
이 같지 않다면 error
반환
4️⃣ res.locals
를 활용하여 sub
을 전역에서 사용 가능한 변수로 설정
5️⃣ ownSub
다음인 upload.single(file)
로 이동
const ownSub = async (req: Request, res: Response, next: NextFunction) => {
const user: User = res.locals.user; // 1️⃣ 번
try {
const sub = await Sub.findOneOrFail({ where: { name: req.params.name } }); // 2️⃣ 번
if (sub.username !== user.username) { // 3️⃣ 번
return res
.status(403)
.json({ error: '사용자님의 소유 게시글이 아닙니다.' });
}
res.locals.sub = sub; // 4️⃣ 번
return next(); // 5️⃣ 번
} catch (error) {
console.log(error);
return res.status(500).json({ error: '문제가 발생하였습니다' });
}
};
router.post(
'/:name/upload',
userMiddleware,
authMiddleware,
ownSub,
upload.single('file'), // 한개의 파일만 업로드할 경우.
uploadSubImage
);
이미지를 업로드하게 되면 프로젝트의 local에 이미지파일로 저장하는 과정이 필요할 것이다.
✅ 이때 필요한 모듈이 multer
npm install multer —save
npm i --save-dev @types/multer
참고하자!
👉 multer를 사용해 이미지 업로드하기
👉 Express Multer
✅ upload 함수 작성
1️⃣ storage
: 파일을 저장할 위치 설정
2️⃣ makeId(10)
: 파일마다 고유한 값 설정
3️⃣ path.extname()
: 파일확장자 추출후 출력. 경로의 마지막 부분의 문자열에서 마지막 '.'에서부터 경로의 확장자를 반환한다.
4️⃣ fileFilter
함수를 만들어 png
, jpeg
이미지 타입만 받겠다는 필터링 (허용되는 파일을 제어하는 기능) (MIME 타입)
const upload = multer({
storage: multer.diskStorage({ // 1️⃣ 번
destination: 'public/images',
filename: (_, file, callback) => {
const name = makeId(10); // 2️⃣ 번
callback(null, name + path.extname(file.originalname)); // 3️⃣ 번
},
}),
fileFilter: (_, file: any, callback: FileFilterCallback) => { // 4️⃣ 번
if (file.mimetype === 'image/jpeg' || file.mimetype === 'image/png') {
callback(null, true);
} else {
callback(new Error('이미지가 아닙니다.'));
}
},
});
💡 makeId(10)
이미지파일의id
를unique
하게 만들어주기 위하여 랜덤으로 10자의 문자를 받아name
변수에 저장한다.// utils/helper.ts export const makeId = length => { let result = ''; const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const charactersLength = characters.length; for (let i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; };
✅ server.js에 static파일을 public 파일 안에 있고 브라우저로 접근할 때 제공을 할 수 있게 해준다.
static
파일이 public
에 존재한다고 알리는 것.// server.js
app.use(express.static("public"));
이제 local에 이미지가 저장되었으니 이미지를 실제 화면에 upload해야 한다.
1️⃣ res.locals
를 활용하여 sub
을 전역에서 사용 가능한 변수로 설정
2️⃣ client
에서 요청한 데이터값을 type
변수에 저장.
//client
formData.append("type", fileInputRef.current!.name);
3️⃣ 만약 type
이 image, banner
가 아닐시에는 유효하지 않는 파일이라고 판별 후 파일 삭제
unlinkSync
를 통해 파일 삭제 (참고)4️⃣ 이전파일을 삭제하기 위하여 사용중인 Urn을 저장한다.
5️⃣ 새로운 파일 이름을 Urn으로 넣어준다.
6️⃣ 만약 새로운 이미지를 업로드할 경우 이전의 이미지파일은 local에 계속 남아서 쌓이게 됨으로 사용하지 않는 이미지파일은 삭제시켜준다.
path.resolve
: 여러 인자를 넣으면 하나의 경로로 합쳐준다. (참고)process.cwd()
: 현재 작업 디렉토리를 반환 (참고)현재작업디렉토리/public/images/업로드했던이미지파일경로
const uploadSubImage = async (req: Request, res: Response) => {
const sub: Sub = res.locals.sub; // 1️⃣ 번
try {
const type = req.body.type; // 2️⃣ 번
if (type !== 'image' && type !== 'banner') { // 3️⃣ 번
if (!req.file?.path) {
return res.status(400).json({ error: '유효하지 않는 파일입니다.' });
}
unlinkSync(req.file.path);
return res.status(400).json({ error: '잘못된 유형입니다.' });
}
let oldImageUrn = '';
if (type === 'image') {
oldImageUrn = sub.imageUrn || ''; // 4️⃣ 번
sub.imageUrn = req.file?.filename || ''; // 5️⃣ 번
} else if (type === 'banner') {
oldImageUrn = sub.bannerUrn || ''; // 4️⃣ 번
sub.bannerUrn = req.file?.filename || ''; // 5️⃣ 번
}
await sub.save();
// 6️⃣ 번
if (oldImageUrn !== '') {
const fullFilename = path.resolve(
process.cwd(),
'public',
'images',
oldImageUrn
);
unlinkSync(fullFilename);
}
return res.json(sub);
} catch (error) {
console.log(error);
return res.status(500).json({ error: '문제가 발생하였습니다' });
}
};
상세페이지에 들아거면 해당 게시글에 대한 정보를 담고 있는 사이드바를 생성해보자.
✅ 파일 생성
dayjs
:JavaScript 날짜 관련 라이브러리중 가장 가벼운 라이브러리npm install dayjs —save
const SideBar = ({ sub }: Props) => {
const { authenticated } = useAuthState();
return (
<div>
<div>
<div>
<p>커뮤니티에 대해서</p>
</div>
<div>
<p>{sub?.description}</p>
<div>
<div>
<p>100</p>
<p>멤버</p>
</div>
</div>
<p>{dayjs(sub?.createdAt).format('MM.DD.YYYY')}</p>
{authenticated && ( // 로그인 인증시에만...
<div>
<Link href={`/r/${sub.name}/create`}> 포스트 생성 </Link>
</div>
)}
</div>
</div>
</div>
);
};