https://record-pan-pang.vercel.app/
일상과 기분을 공유하며 노래를 추천하는 뉴스피드 사이트입니다.
사용자들이 자신의 감정과 순간을 노래와 함께 표현할 수 있는 공간을 제공합니다.
[내일배움캠프] 2조
| 송진우 | 이보영 | 정수희 | 조아영 | 조해인 |
|---|---|---|---|---|
| 팀원 | 팀원 | 팀원 | 팀원 | 팀장 |
| 댓글 전반 | 음악 검색 기능, 음악 정보 표시 | 음악 플레이어, 좋아요 | 게시글 전반 | 회원가입/로그인, 프로필 수정 |
┣ 📂src
┃ ┣ 📂app
┃ ┃ ┣ 📂(assets)
┃ ┃ ┃ ┣ 📜CommentCon.tsx
┃ ┃ ┃ ┣ 📜EmptyHeart.tsx
┃ ┃ ┃ ┣ 📜FillHeart.tsx
┃ ┃ ┃ ┣ 📜PauseCon.tsx
┃ ┃ ┃ ┣ 📜PlayCon.tsx
┃ ┃ ┃ ┣ 📜Plus.tsx
┃ ┃ ┃ ┗ 📜StopCon.tsx
┃ ┃ ┣ 📂api
┃ ┃ ┃ ┗ 📜spotifyToken.ts
┃ ┃ ┣ 📂detail
┃ ┃ ┃ ┗ 📂[id]
┃ ┃ ┃ ┃ ┗ 📜page.tsx
┃ ┃ ┣ 📂fonts
┃ ┃ ┃ ┗ 📜PretendardVariable.woff2
┃ ┃ ┣ 📂mypage
┃ ┃ ┃ ┗ 📜page.tsx
┃ ┃ ┣ 📂sign-in
┃ ┃ ┃ ┣ 📜error.tsx
┃ ┃ ┃ ┗ 📜page.tsx
┃ ┃ ┣ 📂sign-up
┃ ┃ ┃ ┣ 📜error.tsx
┃ ┃ ┃ ┗ 📜page.tsx
┃ ┃ ┣ 📂write
┃ ┃ ┃ ┣ 📂[id]
┃ ┃ ┃ ┃ ┗ 📜page.tsx
┃ ┃ ┃ ┗ 📜page.tsx
┃ ┃ ┣ 📜favicon.ico
┃ ┃ ┣ 📜globals.css
┃ ┃ ┣ 📜layout.tsx
┃ ┃ ┗ 📜page.tsx
┃ ┣ 📂components
┃ ┃ ┣ 📂commonUI
┃ ┃ ┃ ┣ 📜Background.tsx
┃ ┃ ┃ ┣ 📜LikeButton.tsx
┃ ┃ ┃ ┗ 📜PostCard.tsx
┃ ┃ ┣ 📂features
┃ ┃ ┃ ┣ 📂auth
┃ ┃ ┃ ┃ ┣ 📜authForm.tsx
┃ ┃ ┃ ┃ ┗ 📜signOutButton.tsx
┃ ┃ ┃ ┣ 📂comment
┃ ┃ ┃ ┃ ┣ 📜CommentInput.tsx
┃ ┃ ┃ ┃ ┣ 📜CommentItem.tsx
┃ ┃ ┃ ┃ ┣ 📜CommentList.tsx
┃ ┃ ┃ ┃ ┗ 📜CommentSection.tsx
┃ ┃ ┃ ┣ 📂mypage
┃ ┃ ┃ ┃ ┣ 📜EditProfileButton.tsx
┃ ┃ ┃ ┃ ┣ 📜EditProfileModal.tsx
┃ ┃ ┃ ┃ ┣ 📜MyComment.tsx
┃ ┃ ┃ ┃ ┣ 📜MyLike.tsx
┃ ┃ ┃ ┃ ┣ 📜MyPageTabs.tsx
┃ ┃ ┃ ┃ ┣ 📜MyPost.tsx
┃ ┃ ┃ ┃ ┣ 📜Profile.tsx
┃ ┃ ┃ ┃ ┣ 📜ProfileError.tsx
┃ ┃ ┃ ┃ ┗ 📜ProfileLoading.tsx
┃ ┃ ┃ ┣ 📂navbar
┃ ┃ ┃ ┃ ┣ 📜Footer.tsx
┃ ┃ ┃ ┃ ┣ 📜Header.tsx
┃ ┃ ┃ ┃ ┗ 📜ProfileImg.tsx
┃ ┃ ┃ ┣ 📂player
┃ ┃ ┃ ┃ ┣ 📜DetailPlayButton.tsx
┃ ┃ ┃ ┃ ┣ 📜DetailPlayer.tsx
┃ ┃ ┃ ┃ ┣ 📜PlayButton.tsx
┃ ┃ ┃ ┃ ┣ 📜Player.tsx
┃ ┃ ┃ ┃ ┗ 📜PlayIcon.tsx
┃ ┃ ┃ ┣ 📂post
┃ ┃ ┃ ┃ ┣ 📜PostButtons.tsx
┃ ┃ ┃ ┃ ┣ 📜PostCommnetCount.tsx
┃ ┃ ┃ ┃ ┣ 📜PostForm.tsx
┃ ┃ ┃ ┃ ┣ 📜PostList.tsx
┃ ┃ ┃ ┃ ┗ 📜PostSection.tsx
┃ ┃ ┃ ┗ 📂spotifySearch
┃ ┃ ┃ ┃ ┣ 📜Card.tsx
┃ ┃ ┃ ┃ ┣ 📜Command.tsx
┃ ┃ ┃ ┃ ┗ 📜SearchForPost.tsx
┃ ┃ ┣ 📂providers
┃ ┃ ┃ ┗ 📜QueryClientProvider.tsx
┃ ┃ ┗ 📂ui
┃ ┃ ┃ ┣ 📜button.tsx
┃ ┃ ┃ ┣ 📜card.tsx
┃ ┃ ┃ ┣ 📜command.tsx
┃ ┃ ┃ ┣ 📜dialog.tsx
┃ ┃ ┃ ┣ 📜input.tsx
┃ ┃ ┃ ┗ 📜textarea.tsx
┃ ┣ 📂hook
┃ ┃ ┣ 📜usePostById.ts
┃ ┃ ┣ 📜usePostByUserId.ts
┃ ┃ ┗ 📜usePosts.ts
┃ ┣ 📂lib
┃ ┃ ┗ 📜utils.ts
┃ ┣ 📂store
┃ ┃ ┣ 📜playerStore.tsx
┃ ┃ ┗ 📜spotifyStore.tsx
┃ ┣ 📂types
┃ ┃ ┣ 📜auth.ts
┃ ┃ ┣ 📜comment.ts
┃ ┃ ┣ 📜post.ts
┃ ┃ ┣ 📜Spotify.ts
┃ ┃ ┗ 📜track.ts
┃ ┣ 📂utils
┃ ┃ ┣ 📂supabase
┃ ┃ ┃ ┣ 📜client-actions.ts
┃ ┃ ┃ ┣ 📜client.tsx
┃ ┃ ┃ ┣ 📜middleware.ts
┃ ┃ ┃ ┣ 📜server-actions.ts
┃ ┃ ┃ ┗ 📜server.tsx
┃ ┃ ┣ 📜formatTrackData.ts
┃ ┃ ┣ 📜getYoutubeID.ts
┃ ┃ ┗ 📜spotify-client.ts
┃ ┗ 📜middleware.ts
┣ 📜.env.local
┣ 📜.eslintrc.json
┣ 📜.gitignore
┣ 📜.prettierrc
┣ 📜components.json
┣ 📜next-env.d.ts
┣ 📜next.config.mjs
┣ 📜package-lock.json
┣ 📜package.json
┣ 📜postcss.config.mjs
┣ 📜README.md
┣ 📜tailwind.config.ts
┣ 📜tsconfig.json
┗ 📜yarn.lock
Supabase Auth를 사용해 관리했습니다.
zod와 react-hook-form를 사용합니다. 존재하는 이메일은 별도의 유효성 검사를 통해 알려줍니다.// ./src/components/auth/Auth
const AuthForm = () => {
...
const schema =
path === SIGN_UP
? z.object({
email: z
.string()
.email({ message: "이메일 형식으로 입력해주세요" })
.min(1, { message: "이메일을 입력해주세요" }),
password: z.string().min(6, "6자 이상 입력해주세요"),
nickname: z.string().min(1, "닉네임을 입력해주세요.").max(10, "최대 10자 입력 가능합니다.")
})
: z.object({
email: z.string().min(1, "이메일을 입력해주세요"),
password: z.string().min(1, "비밀번호를 입력해주세요")
});
...
const { register, handleSubmit, formState } = useForm({
mode: "onChange", //'onBlur' : focus가 사라졌을 때
defaultValues,
resolver: zodResolver(schema)
});
...
return (
<div className="container modal">
<form onSubmit={handleSubmit(onSubmit)} className="p-4 flex flex-col items-center m-auto">
<Input
{...register("email")}
placeholder="email"
className={AUTH_CSS}
onChange={() => setEmailMessage("")}
/>
{formState.errors.email && <span className="text-sky-300 leading-tight">{formState.errors.email.message}</span>}
{!!emailMessage && <span className="text-sky-300 leading-tight">{emailMessage}</span>}
...
</form>
<div className="embla" ref={emblaRef}>
<div className="embla__container">
{carousel &&
[0, 2, 4, 6].map(
(
i // 각 슬라이드에 두개씩 보여줌
) => <Slide play={[carousel[i], carousel[i + 1]]} key={`slide-${i}`} />
)}
</div>
</div>
);
};
profiles 테이블에 저장된 email을 불러와서 해당 이메일이 존재하는 확인합니다.// ./src/components/auth/Auth
const AuthForm = () => {
...
const { register, handleSubmit, formState } = useForm({
mode: "onChange", //'onBlur' : focus가 사라졌을 때
defaultValues,
resolver: zodResolver(schema)
});
...
const onSubmit = async (data: FieldValues) => {
const emailData = await checkEmail(data.email);
if (path === SIGN_UP) {
if (emailData.length !== 0) {
setEmailMessage("이미 존재하는 계정입니다.");
} else {
await signup({
email: data.email,
password: data.password,
options: { data: { nickname: data.nickname, email: data.email, profile_img: "default" } }
});
}
} else {
if (emailData.length === 0) {
setEmailMessage("존재하지 않는 계정입니다.");
} else {
await signin({ email: data.email, password: data.password });
}
}
};
...
};
// ./src/utils/supabase/client-actions.ts
export async function checkEmail(email: string) {
const { data, error } = await supabase.from(PROFILES).select("email").eq("email", email);
if (error) {
console.error(error);
return [];
}
return data;
}
메인 페이지에서 동작하는 플레이어입니다.
1. useRef 사용
// youtube iframe과 앨범 커버를 연결하여 영상은 노출되지 않고 노래만 재생됩니다.
// 성능 최적화를 위해 재생을 클릭한 영상의 iframe만 렌더링 되도록 구현했습니다.
const { playedVideo, setIsPlay, setPlayedVideo, playedPlayer, setPlayedPlayer } = useYoutubnStore();
const playerRef = useRef<YouTubePlayer | null>(null);
const [showYouTube, setShowYouTube] = useState(false);
{showYouTube && (
<div className="hidden">
<YouTube videoId={id} onReady={(e: YouTubeEvent) => onReady(e, playerRef)} />
</div>
)}
// 영상을 처음 클릭하면 showYouTube를 통해 프레임이 생성되고 로딩이 완료될 때 onReady 함수가 실행됩니다.
const togglePlayVideo = async () => {
if (!showYouTube) {
setShowYouTube(true);
}
// 한 번 틀었던 노래를 재생, 일시정지 할 때 실행되는 부분
if (playerRef.current && playedVideo.id === music.id) {
if (playedVideo.isPlay) {
playerRef.current.pauseVideo();
setIsPlay();
} else {
playerRef.current.playVideo();
setIsPlay();
}
}
// 노래를 듣다가 다른 노래를 틀었을 때 실행되는 부분
if (playedVideo.id !== music.id && playerRef.current) {
if (playedVideo.isPlay && playedPlayer) {
// 만약 다른 노래가 재생 중이라면 일시정지 함
playedPlayer.pauseVideo();
}
// 모든 플레이어 정보를 방금 선택한 영상으로 변경하고 재생시킴
setPlayedVideo(music.id);
setPlayedPlayer(playerRef.current);
playerRef.current.playVideo();
}
};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
togglePlayVideo();
};
// 프레임이 로딩되었을 때 추가 조작 없이 바로 재생될 수 있도록 설정
const onReady = (e: YouTubeEvent, playerRef: MutableRefObject<YouTubePlayer | null>) => {
playerRef.current = e.target;
if (playedPlayer) {
playedPlayer.pauseVideo();
}
setPlayedVideo(music.id);
playerRef.current.playVideo();
setPlayedPlayer(playerRef.current);
};
spotify에서 track data를 불러와서 search input box에 글자를 입력할 때마다 data 50개씩 dropdown modal 안으로 들어오도록 구현하였습니다.
dropdown으로 보여지는 track list들 중 한개를 선택하면 해당하는 track의 사진과 정보가 track info box에 자동으로 입력되어 들어옵니다.
사용자는 track을 검색하고 본인이 선택한 track의 정보를 track info box에서 확인할 수 있습니다.
//SearchForPost.tsx
const SpotifySearch = ({ setCard, card, cardError }: Props) => {
const [token, setToken] = useState("");
const [search, setSearch] = useState<string>("");
const [open, setOpen] = useState(false);
const [tracks, setTracks] = useState<Track[]>([]);
//처음 렌더링시에 fetchToke함수를 실행시켜주고 token을 가져와서 상태값 token에 담아줌
useEffect(() => {
const clientId = process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_ID;
const clientPW = process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_SECRET;
const fetchToken = async () => {
const res = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
cache: "no-store",
body: `grant_type=client_credentials&client_id=${clientId}&client_secret=${clientPW}`
});
if (!res.ok) {
throw new Error("Failed to fetch token");
}
const data = await res.json();
const { access_token: token } = data;
setToken(token);
};
fetchToken();
}, []);
//사용자가 입력한 검색단어들이 search로 들어감, 입력값이 0보다 길어지면 dropdown됨
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === "0") {
return;
}
setSearch(e.target.value);
setOpen(e.target.value.length > 0);
};
useEffect(() => {
if (!search) {
return;
}
// spotify에서 track data를 가져오는 비동기 함수
const getTrack = async () => {
const res = await fetch(`https://api.spotify.com/v1/search?q=${search}&type=track&limit=50&offset=0`, {
method: "GET",
headers: {
Authorization: "Bearer " + `${token}`
}
});
if (!res.ok) {
throw new Error("Failed to fetch track");
}
const data: SpotifyTracks = await res.json();
const tracks: Track[] = data.tracks.items;
setTracks(tracks);
};
//사용자가 검색 단어를 입력할 때마다 getTrack을 실행시켜줌
getTrack();
}, [search, token]);
//검색 리스트 클릭시 불러온 데이터의 목록과 대조하여 해당하는 데이터만 추출해서 상태값card에 넣어주는 함수
const shiftTrackToInfocard = (id: string) => {
const trackInfo = tracks.find((item) => {
return item.id === id;
});
if (trackInfo) {
setCard(trackInfo);
}
setOpen(false);
setSearch("");
};
//초로 만들어진 시간은 분초로 변경해주는 함수
const formatDuration = (durationMs: number) => {
const minutes = Math.floor(durationMs / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
return `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`;
};
return (
<div className="container mx-auto flex flex-col gap-8 justify-center items-center">
<CommandForPost
search={search}
tracks={tracks}
open={open}
handleInputChange={handleInputChange}
shiftTrackToInfocard={shiftTrackToInfocard}
cardError={cardError}
/>
<CardForPost card={card} formatDuration={formatDuration} />
</div>
);
};
게시글 목록을 TanStack Query를 이용하여 실시간으로 반영되도록 구현했습니다.
이 기능을 통해 사용자는 게시글이 추가, 수정, 삭제될 때 즉시 업데이트된 내용을 확인할 수 있습니다.
// PostList.tsx
const PostList = ({ user, token }: Props) => {
// 게시글 데이터를 가져오는 훅 호출
const { data: posts, isLoading, isError } = usePosts();
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return <div>게시글을 불러오는 데 문제가 발생했습니다.</div>;
}
if (!posts) {
return <div>게시글이 없습니다.</div>;
}
return (
<ul className="flex flex-col gap-6">
{posts.map((post) => (
<li key={post.post_id}>
{/* 각 게시글을 PostCard 컴포넌트로 렌더링 */}
<PostCard post={post} user={user} token={token} />
</li>
))}
</ul>
);
};
// usePosts.ts
export const usePosts = () => {
return useQuery({
queryKey: ["posts"], // 쿼리 키
queryFn: fetchPosts // 데이터 가져오는 함수
});
};
// client-actions.ts
export async function fetchPosts() {
// posts 테이블에서 데이터 가져오기
const { data: posts, error: postsError } = await supabase
.from("posts")
.select("*, profiles(nickname, profile_img)") // 프로필 데이터 포함
.order("created_at", { ascending: false }); // 최신 게시글 우선 정렬
// 에러 발생 시 처리
if (postsError || !posts) {
console.error(postsError);
return []; // 에러 발생 시 빈 배열 반환
}
// 결과에서 각 포스트의 프로필 데이터 추가
return posts.map((post) => ({
...post
}));
}
사용자가 댓글을 작성할 수 있습니다.
정렬을 통해 댓글을 작성하면 댓글 목록 맨 위에서 확인할 수 있고, 댓글 작성,수정 시간을 확인할 수 있습니다.
클라이언트 액션 - 댓글 조회를 처리하여 사용자가 빠르게 댓글을 확인할 수 있습니다.
client-action
// 댓글 조회
export async function fetchComment(postId: string): Promise<Comment[]> {
const STORAGE = "profiles";
const { data: comments, error: commentError } = await supabase
.from("comments")
.select("comment_id, content, user_id, created_at, update_at")
.eq("post_id", postId)
.order("created_at", { ascending: false }); // 생성 시간 기준으로 정렬
if (commentError) {
console.error(commentError.message);
throw new Error("댓글을 불러오는데 실패했습니다.");
}
const commentsWithProfile = await Promise.all(
comments.map(async (comment) => {
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("nickname, profile_img")
.eq("user_id", comment.user_id)
.single();
if (profileError) {
throw new Error("프로필 정보를 불러오는데 실패했습니다.");
}
// `profile_img`를 가져와 절대 경로 생성
const { data: { publicUrl: profileImgUrl } = {} } = supabase.storage
.from(STORAGE)
.getPublicUrl(profile.profile_img ?? "default");
return {
...comment,
profile: {
nickname: profile.nickname,
profile_img: profileImgUrl || "/default-profile.png"
}
};
})
);
return commentsWithProfile;
}
// 댓글 추가
export async function addComment(content: string, postId: string) {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user) throw new Error("로그인이 필요합니다.");
const { error } = await supabase.from("comments").insert([{ content, post_id: postId, user_id: user.id }]);
if (error) throw new Error("댓글 추가에 실패했습니다.");
}
// 댓글 삭제
export async function deleteComment(commentId: string) {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user) throw Error("로그인이 필요합니다.");
const { error } = await supabase.from("comments").delete().eq("comment_id", commentId).eq("user_id", user.id);
if (error) {
throw new Error("댓글 삭제에 실패했습니다.");
}
}
// 댓글 수정
export async function updateComment(commentId: string, content: string) {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user) {
throw new Error("로그인 해주세요.");
}
const { error } = await supabase
.from("comments")
.update({ content })
.eq("comment_id", commentId)
.eq("user_id", user.id);
if (error) {
throw new Error("댓글 수정에 실패했습니다.");
}
}
사용자 정보와 사용자가 작성한 게시글과 댓글, 좋아요한 게시글을 확인할 수 있습니다.
프로필 수정하기 버튼을 클릭하면 모달창을 통해 사용자 정보를 수정할 수 있습니다.invalidateQueries를 통해 변경된 정보를 가져오도록 합니다.// ./src/components/features/mypage/EditProfileModal.tsx
const EditProfileModal = ({
user,
setShowModal
}: {
user: User | undefined;
setShowModal: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
...
// 사용자 프로필 업데이트 시 정보 바로 갱신되도록
const queryClient = useQueryClient();
// 사용자 정보 업데이트 성공 시 invalidateQueries
const { mutate: handleUpdateUser } = useMutation({
mutationFn: () => updateUser(user as User, nickname, profileImg),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["user", "client"]
});
queryClient.invalidateQueries({ queryKey: ["post", currentUserId] });
queryClient.invalidateQueries({ queryKey: ["posts"] });
}
});
...
};
액티브 탭
const MyPageTabs = ({ user, token }: Props) => {
const [activeTab, setActiveTab] = useState(1);
const { setToken } = useSpotifyStore();
useEffect(() => {
const getToken = async () => {
const token = await fetchToken();
setToken(token);
};
getToken();
}, [setToken]);
const tabs = [
{ id: 1, label: "게시글", component: <MyPost user={user} token={token} /> },
{ id: 2, label: "댓글", component: <MyComment /> },
{ id: 3, label: "좋아요", component: <MyLike /> }
];
return (
<div className="max-w-full mx-auto">
<ul className="flex justify-around border-b border-gray-300 my-4">
{tabs.map((tab) => (
<ul
key={tab.id}
className={`w-full text-center py-2 cursor-pointer ${
activeTab === tab.id
? "border-b-2 border-sky-400 text-sky-400 font-semibold"
: "text-gray-400 active:text-gray-300"
}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</ul>
))}
</ul>
<div className="p-4">{tabs.find((tab) => tab.id === activeTab)?.component}</div>
</div>
);
};
로그인 정보가 없을 시 회원가입, 로그인 버튼이 우측 상단에 위치하며, 로그인 정보가 있을 시 로그아웃, 마이페이지 버튼과 프로필 이미지가 우측 상단에 위치합니다.
페이지 정보를 미리 불러와서 이동 시 시간을 줄일 수 있도록 했습니다.
// ./src/components/features/navbar/ProfileImg.tsx
const ProfileImg = () => {
...
const userImg = getPublicUrl("profiles", user?.user_metadata.profile_img);
return (
<Link href={"/mypage"} className="min-w-fit min-h-fit rounded-full">
<Image
src={userImg}
alt="프로필 이미지"
width={40}
height={40}
className="w-[40px] h-[40px] border-2 rounded-full aspect-auto object-cover"
/>
</Link>
);
};
🔥 로그아웃 해도 '로그아웃, 마이페이지' 버튼이 유지됨.
서버용/클라이언트용 supabase client를 제대로 숙지하지 못해, supabase client가 제대로 작동하지 않아 사용자 정보가 업데이트 되지 않아 발생한 문제였습니다.
서버용 supabase client를 사용할 때는 각 함수마다 client를 생성하고, 클라이언트용 supabase client는 하나의 client만 생성해 import하여 사용했습니다.
// ./src/utils/supabase/server-action.ts
"use server";
...
import { createClient } from "./server";
export async function signin(formData: SignInWithPasswordCredentials) {
const supabase = createClient();
...
}
export async function signup(formData: SignUpWithPasswordCredentials) {
const supabase = createClient();
...
}
export async function signout() {
const supabase = createClient();
...
}
...
// ./src/utils/supabase/server.tsx
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_KEY!);
}
export const supabase = createClient();
🔥 회원가입/로그인 시 유효성 검사에 사용할 테이블이 없었음.
email 컬럼 값을 unique하게 설정하기 위해 모든 사용자를 지우는 과정이 필요했습니다. 덩달아 연결된 정보도 같이 사라지게 되어 결국 모든 데이터를 지울 수 밖에 없었습니다.
좀 더 자세히 생각하고 데이터 베이스를 설계해야 한다는 것을 배웠습니다.
🔥 배포 후, 특별한 오류코드 없이 메인 페이지에서 플레이어가 노출되지 않는 문제 발생
player 내부에서 음악 정보가 없으면 return되지 않도록 설정한 부분이 문제라고 생각함.
if (!music) {
return (
<div>
Loading...
</div>
);
}
코드를 변경하여 테스트 진행하니 Loading이 출력됨
음악 정보를 불러오는 데에 필요한 token, id가 props로 제대로 전달되지 않는 것 같아 2차 테스트
if (!music) {
return (
<div>
{token}, {id}, {music}
</div>
);
}
해당 코드로 token 값이 들어오지 않는다는 걸 확인함.
token은 가장 상위 컴포넌트인 메인 페이지의 page.tsx에서 서버 액션을 사용해서 얻어 내려주고 있었음.
현재 postList 컴포넌트를 클라이언트 컴포넌트로 변경했기 때문에 해당 컴포넌트에서 useEffect 훅을 사용하여 발급받는 것으로 변경
const { setToken, token } = useSpotifyStore();
useEffect(() => {
const getToken = async () => {
const token = await fetchToken();
setToken(token);
};
getToken();
}, [setToken]);
🔥 dropdown search input box를 구현하기위해 shadcn에서 commmand 컴포넌트를 가져와서 data와 연결을 했는데, 해당 input에 검색어를 입력할 때는 문제가 없었는데 input에 들어갔던 글자가 사라지는 동시에 edirect-boundary.js:57 Uncaught TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator))이러한 에러가 나왔습니다.
삼항연산자를 이용해서 input에 값이 없을 때에도 빈 tag를 그려지도록 하여서 에러처리를 해주었습니다.
{open ? (
<CommandList className="absolute top-full left-0 w-full bg-white rounded-b-lg border-t-0 max-h-[300px] overflow-y-auto shadow-lg">
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
{tracks.map((track) => (
<CommandItem key={track.id} onSelect={() => shiftTrackToInfocard(track.id)}>
<Music className="mr-2 h-4 w-4" />
{track.name} - {track.artists[0]?.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
) : (
<CommandList></CommandList>
)}
</Command>
🔥 Supabase 외래키 연결
posts 테이블에서 post_id로 user_id를 찾아 profiles 테이블에서 nickname, profile_img 데이터를 가져오려 했습니다. 아래 코드와 같이 작성하고 Supabase에서 외래키 연결을 시도했지만 연결되지 않았습니다. 'insert or update on table "posts" violates foreign key constraint "posts_user_id_fkey"'오류는 posts 테이블의 user_id 필드가 profiles 테이블의 user_id와 외래키로 연결되어 있는데, 삽입하려는 user_id 값이 profiles 테이블에 존재하지 않거나 유효하지 않은 경우 이 오류가 발생합니다.// post_id로 게시글 정보 조회
export async function getPostById(postId: string) {
const { data, error } = await supabase
.from("posts")
.select("*, profiles(nickname, profile_img)")
.eq("post_id", postId)
.single();
if (error || !data) {
console.error(error);
return null;
}
return data;
}
const { data: comments, error: commentError } = await supabase
.from("comments")
.select("comment_id, content, user_id, created_at, update_at")
.eq("post_id", postId)
.order("created_at", { ascending: false }); // 생성 시간 기준으로 정렬
🔥 다른 사용자로 로그인하면 기존 사용자 정보가 마이 페이지 사용자 정보에 적용됨.
TanStack Query를 사용해 데이터를 불러와서 auth state가 변경되면 invalidateQueries를 실행함. 이때 클라이언트 컴포넌트에서만 TanStack Query를 사용할 수 있고, 항상 상단에 노출되어있는 client 컴포넌트가 네비게이션 바의 프로필 이미지이므로 해당 컴포넌트에 onAuthStateChange를 적용함.
// ./xrc/components/features/navbar/ProfileImg.tsx
const ProfileImg = () => {
...
supabase.auth.onAuthStateChange(() => {
// 모든 auth state 변화에 따라 session 다시 저장
queryClient.invalidateQueries({ queryKey: ["user", "client"] });
});
...
};
🔥 사용자 정보 변경 시 프로필 이미지가 같이 반영되지 않음
TanStack Query의 Provider 내부에 헤더를 포함시켜 invalidateQueries의 영향을 받도록 함.
// ./src/app/layout.tsx
export default async function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased flex flex-col min-h-screen`}>
<Providers>
<Header />
<Background>{children}</Background>
<Footer />
</Providers>
</body>
</html>
);
}
// ./xrc/components/features/navbar/ProfileImg.tsx
const ProfileImg = () => {
const defaultImg = getPublicUrl("profiles", "default");
const {
data: user,
isLoading,
isError
} = useQuery({
queryKey: ["user", "client"],
queryFn: () => fetchSessionData()
});
supabase.auth.onAuthStateChange(() => {
// 모든 auth state 변화에 따라 session 다시 저장
queryClient.invalidateQueries({ queryKey: ["user", "client"] });
});
...
};