Skip to content

Commit 64e2ea0

Browse files
authored
Merge pull request #218 from GulSam00/feat/217-addSongReport
feat: 곡 오류 신고 기능 추가 (#217)
2 parents f1d8160 + 3203fce commit 64e2ea0

12 files changed

Lines changed: 604 additions & 11 deletions

File tree

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@radix-ui/react-dialog": "^1.1.6",
2424
"@radix-ui/react-dropdown-menu": "^2.1.6",
2525
"@radix-ui/react-label": "^2.1.4",
26+
"@radix-ui/react-radio-group": "^1.3.8",
2627
"@radix-ui/react-scroll-area": "^1.2.4",
2728
"@radix-ui/react-select": "^2.2.4",
2829
"@radix-ui/react-separator": "^1.1.2",

apps/web/public/sitemap-0.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
3-
<url><loc>https://www.singcode.kr/manifest.webmanifest</loc><lastmod>2026-05-01T01:34:01.917Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
4-
<url><loc>https://www.singcode.kr</loc><lastmod>2026-05-01T01:34:01.918Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
3+
<url><loc>https://www.singcode.kr/manifest.webmanifest</loc><lastmod>2026-05-02T12:24:19.950Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
4+
<url><loc>https://www.singcode.kr</loc><lastmod>2026-05-02T12:24:19.952Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
55
</urlset>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { NextResponse } from 'next/server';
2+
3+
import createClient from '@/lib/supabase/server';
4+
import { ApiResponse } from '@/types/apiRoute';
5+
import { REPORT_CATEGORIES, ReportCategory } from '@/types/report';
6+
import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser';
7+
8+
const POSTGRES_UNIQUE_VIOLATION = '23505';
9+
10+
function isReportCategory(value: unknown): value is ReportCategory {
11+
return typeof value === 'string' && REPORT_CATEGORIES.includes(value as ReportCategory);
12+
}
13+
14+
export async function POST(request: Request): Promise<NextResponse<ApiResponse<void>>> {
15+
try {
16+
const supabase = await createClient();
17+
const userId = await getAuthenticatedUser(supabase);
18+
19+
const { songId, category, suggested_value } = await request.json();
20+
21+
if (!songId || !category) {
22+
return NextResponse.json(
23+
{ success: false, error: 'Missing songId or category' },
24+
{ status: 400 },
25+
);
26+
}
27+
28+
if (!isReportCategory(category)) {
29+
return NextResponse.json({ success: false, error: 'Invalid category' }, { status: 400 });
30+
}
31+
32+
const isNumberCategory = category === 'num_tj' || category === 'num_ky';
33+
34+
let normalizedSuggestedValue: string | null;
35+
if (suggested_value === null) {
36+
if (!isNumberCategory) {
37+
return NextResponse.json(
38+
{ success: false, error: 'suggested_value is required for this category' },
39+
{ status: 400 },
40+
);
41+
}
42+
normalizedSuggestedValue = null;
43+
} else if (typeof suggested_value === 'string') {
44+
const trimmed = suggested_value.trim();
45+
if (!trimmed) {
46+
return NextResponse.json(
47+
{ success: false, error: 'Missing suggested_value' },
48+
{ status: 400 },
49+
);
50+
}
51+
if (isNumberCategory && !/^\d{1,5}$/.test(trimmed)) {
52+
return NextResponse.json(
53+
{ success: false, error: 'Invalid number format' },
54+
{ status: 400 },
55+
);
56+
}
57+
normalizedSuggestedValue = trimmed;
58+
} else {
59+
return NextResponse.json(
60+
{ success: false, error: 'Invalid suggested_value' },
61+
{ status: 400 },
62+
);
63+
}
64+
65+
const { error: insertError } = await supabase.from('song_reports').insert({
66+
user_id: userId,
67+
song_id: songId,
68+
category,
69+
suggested_value: normalizedSuggestedValue,
70+
});
71+
72+
if (insertError) {
73+
if ((insertError as { code?: string }).code === POSTGRES_UNIQUE_VIOLATION) {
74+
return NextResponse.json({ success: false, error: 'Already reported' }, { status: 409 });
75+
}
76+
throw insertError;
77+
}
78+
79+
return NextResponse.json({ success: true });
80+
} catch (error) {
81+
if (error instanceof Error && error.cause === 'auth') {
82+
return NextResponse.json(
83+
{ success: false, error: 'User not authenticated' },
84+
{ status: 401 },
85+
);
86+
}
87+
console.error('Error in report API:', error);
88+
return NextResponse.json({ success: false, error: 'Failed to post report' }, { status: 500 });
89+
}
90+
}

apps/web/src/app/privacy/page.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,17 @@ export default function PrivacyPage() {
2121
</p>
2222
<p className="text-muted-foreground">
2323
서비스는 『개인정보 보호법』, 『정보통신망 이용촉진 및 정보보호 등에 관한 법률』 등 관련
24-
법령을 준수하며, 본 개인정보처리방침을 통해 사용자의 개인정보가 어떤 방식으로 수집·이용되고
25-
있는지, 어떤 보호 조치를 취하고 있는지를 안내드립니다.
24+
법령을 준수하며, 본 개인정보처리방침을 통해 사용자의 개인정보가 어떤 방식으로
25+
수집·이용되고 있는지, 어떤 보호 조치를 취하고 있는지를 안내드립니다.
2626
</p>
2727
</section>
2828

2929
<section className="space-y-3">
3030
<h2 className="text-lg font-semibold">개인정보의 수집·이용에 대한 동의</h2>
3131
<p className="text-muted-foreground">
3232
Singcode는 개인정보 수집 및 이용에 대한 동의를 받기 위해, 회원가입 및 소셜 로그인 시 관련
33-
내용을 안내하고 사용자가 &lsquo;동의&rsquo; 버튼을 클릭한 경우에만 개인정보를 수집·이용합니다.
33+
내용을 안내하고 사용자가 &lsquo;동의&rsquo; 버튼을 클릭한 경우에만 개인정보를
34+
수집·이용합니다.
3435
</p>
3536
</section>
3637

@@ -66,7 +67,9 @@ export default function PrivacyPage() {
6667

6768
<div className="space-y-2">
6869
<p className="font-medium">4. 수집하지 않는 항목</p>
69-
<p className="text-muted-foreground">서비스는 아래와 같은 민감정보는 수집하지 않습니다.</p>
70+
<p className="text-muted-foreground">
71+
서비스는 아래와 같은 민감정보는 수집하지 않습니다.
72+
</p>
7073
<ul className="text-muted-foreground ml-4 list-disc space-y-1">
7174
<li>인종, 종교, 건강, 정치적 성향 등 민감 정보</li>
7275
<li>실명, 주민등록번호, 주소, 전화번호 등 신원 확인 정보</li>

apps/web/src/app/search/SearchResultCard.tsx

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AnimatePresence, motion } from 'framer-motion';
22
import {
33
ChevronDown,
4+
Flag,
45
ListPlus,
56
ListRestart,
67
MinusCircle,
@@ -12,6 +13,7 @@ import { useState } from 'react';
1213
import { toast } from 'sonner';
1314

1415
import MarqueeText from '@/components/MarqueeText';
16+
import ReportSongModal from '@/components/ReportSongModal';
1517
import ThumbUpModal from '@/components/ThumbUpModal';
1618
import { Button } from '@/components/ui/button';
1719
import { Card } from '@/components/ui/card';
@@ -47,6 +49,7 @@ export default function SearchResultCard({
4749
const { isAuthenticated } = useAuthStore();
4850

4951
const [open, setOpen] = useState(false);
52+
const [reportOpen, setReportOpen] = useState(false);
5053
const [isExpanded, setIsExpanded] = useState(false);
5154

5255
const handleClickThumbsUp = () => {
@@ -57,6 +60,14 @@ export default function SearchResultCard({
5760
setOpen(true);
5861
};
5962

63+
const handleClickReport = () => {
64+
if (!isAuthenticated) {
65+
toast.error('로그인하고 오류 신고에 참여해주세요!');
66+
return;
67+
}
68+
setReportOpen(true);
69+
};
70+
6071
return (
6172
<Card className="w-full overflow-hidden p-4">
6273
{/* 메인 콘텐츠 영역 */}
@@ -66,18 +77,20 @@ export default function SearchResultCard({
6677
{/* 제목 및 가수 */}
6778
<div className="flex justify-between">
6879
<div className="flex w-[calc(100%-40px)] flex-col gap-0.5 truncate">
69-
<MarqueeText className="text-base font-medium">{title}</MarqueeText>
80+
<MarqueeText className="text-base font-medium">
81+
{title_ko && title_ko !== title ? title_ko : title}
82+
</MarqueeText>
7083
{title_ko && title_ko !== title && (
71-
<MarqueeText className="text-muted-foreground text-xs">{title_ko}</MarqueeText>
84+
<MarqueeText className="text-muted-foreground text-xs">{title}</MarqueeText>
7285
)}
7386
<MarqueeText
7487
className="text-muted-foreground hover:text-accent mt-0.5 cursor-pointer text-sm hover:underline hover:underline-offset-4"
7588
onClick={onClickArtist}
7689
>
77-
{artist}
90+
{artist_ko && artist_ko !== artist ? artist_ko : artist}
7891
</MarqueeText>
7992
{artist_ko && artist_ko !== artist && (
80-
<MarqueeText className="text-muted-foreground/70 text-xs">{artist_ko}</MarqueeText>
93+
<MarqueeText className="text-muted-foreground/70 text-xs">{artist}</MarqueeText>
8194
)}
8295
</div>
8396

@@ -181,10 +194,36 @@ export default function SearchResultCard({
181194
{isSave ? <ListRestart className="h-5 w-5" /> : <ListPlus className="h-5 w-5" />}
182195
<span className="text-xs">{isSave ? '재생목록 수정' : '재생목록 추가'}</span>
183196
</Button>
197+
198+
<Button
199+
variant="ghost"
200+
size="icon"
201+
className="h-13 flex-1 flex-col items-center justify-center"
202+
aria-label="오류 신고"
203+
onClick={handleClickReport}
204+
>
205+
<Flag className="h-5 w-5" />
206+
<span className="text-xs">오류 신고</span>
207+
</Button>
184208
</div>
185209
</motion.div>
186210
)}
187211
</AnimatePresence>
212+
213+
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
214+
<DialogContent>
215+
<ReportSongModal
216+
songId={id}
217+
title={title}
218+
artist={artist}
219+
title_ko={title_ko}
220+
artist_ko={artist_ko}
221+
num_tj={num_tj}
222+
num_ky={num_ky}
223+
handleClose={() => setReportOpen(false)}
224+
/>
225+
</DialogContent>
226+
</Dialog>
188227
</div>
189228
</Card>
190229
);

0 commit comments

Comments
 (0)