feat(phase3a): TopNav 음악 + 마이페이지 AI기록 음악 통합 + CLAUDE.md
- TopNav LINKS에 /music(음악) 추가 — 외주/소프트웨어/제작사례/사주/타로/음악 6링크
- mypage AI 기록 탭에 음악 트랙 병합: MusicTrackRow 타입 + /api/studio/tracks 로드
+ MusicAiCard(제목·스토리 요약·<audio controls>) + 빈 상태 CTA에 /music 추가
(기존 사주·타로 렌더·로직은 미변경)
- CLAUDE.md: /music 공개 전환 반영(숨김 서비스 표에서 제거), api/studio/{story,tracks,callback}
· lib/music/story-prompt.ts 파일 구조 반영, /mypage 5탭 서술(AI 기록: 사주·타로·음악)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
CLAUDE.md
11
CLAUDE.md
@@ -21,10 +21,11 @@
|
|||||||
| `/showcase` | 제작 사례 — 웹 데모 8종 + 실서비스 운영 사례 |
|
| `/showcase` | 제작 사례 — 웹 데모 8종 + 실서비스 운영 사례 |
|
||||||
| `/work/saju` | 사주 분석 — 공개 AI 사주 (로그인 시 무료 해석 1회/일) |
|
| `/work/saju` | 사주 분석 — 공개 AI 사주 (로그인 시 무료 해석 1회/일) |
|
||||||
| `/tarot` | 타로 — 3카드 셔플·해석 (비로그인 카드 리딩, 로그인 AI 인사이트) |
|
| `/tarot` | 타로 — 3카드 셔플·해석 (비로그인 카드 리딩, 로그인 AI 인사이트) |
|
||||||
|
| `/music` | 공개 음악 — 스토리→음악 AI 스튜디오 (studio·samples, 로그인 시 생성·저장) |
|
||||||
| `/track/[token]` | 비회원 의뢰 진행 추적 |
|
| `/track/[token]` | 비회원 의뢰 진행 추적 |
|
||||||
| `/quote/[token]` | 공개 견적 — 고객 수락/거절 |
|
| `/quote/[token]` | 공개 견적 — 고객 수락/거절 |
|
||||||
| `/login` | 로그인 (`?next=` 리다이렉트 지원) |
|
| `/login` | 로그인 (`?next=` 리다이렉트 지원) |
|
||||||
| `/mypage` | 4탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 |
|
| `/mypage` | 5탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 / AI 기록(사주·타로·음악 병합) |
|
||||||
| `/legal/*` | 이용약관 · 개인정보처리방침 · 환불정책 |
|
| `/legal/*` | 이용약관 · 개인정보처리방침 · 환불정책 |
|
||||||
|
|
||||||
## 숨김 서비스 (admin_token 세션 전용)
|
## 숨김 서비스 (admin_token 세션 전용)
|
||||||
@@ -33,7 +34,6 @@ admin/services 패널에서 ON/OFF 전환 가능.
|
|||||||
|
|
||||||
| 경로 | 서비스 |
|
| 경로 | 서비스 |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `/music/*` | 음악 팩 (단, `/music/packs`는 `/products`로 308 리다이렉트) |
|
|
||||||
| `/gyeol` | CONTOUR PMF 설문 |
|
| `/gyeol` | CONTOUR PMF 설문 |
|
||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
@@ -93,9 +93,12 @@ app/
|
|||||||
saju/analyze/route.ts — 사주 AI 분석 (Gemini)
|
saju/analyze/route.ts — 사주 AI 분석 (Gemini)
|
||||||
tarot/interpret/route.ts — 타로 AI 인사이트 (로그인·일 3회 제한)
|
tarot/interpret/route.ts — 타로 AI 인사이트 (로그인·일 3회 제한)
|
||||||
tarot/readings/route.ts — 타로 리딩 저장·조회 (tarot_readings)
|
tarot/readings/route.ts — 타로 리딩 저장·조회 (tarot_readings)
|
||||||
|
studio/story/route.ts — POST: 스토리→가사 생성 (Gemini, 로그인 필요)
|
||||||
|
studio/tracks/route.ts — GET/POST: 음악 트랙 저장·조회 (music_tracks, 본인 것만)
|
||||||
|
studio/callback/route.ts — POST: Suno webhook 수신용 최소 엔드포인트
|
||||||
work/saju/ — 공개: 사주 서비스 (로그인 시 AI 해석 무료 1회/일)
|
work/saju/ — 공개: 사주 서비스 (로그인 시 AI 해석 무료 1회/일)
|
||||||
tarot/ — 공개: 타로 3카드 (셔플·reference·AI 해석)
|
tarot/ — 공개: 타로 3카드 (셔플·reference·AI 해석)
|
||||||
music/ — 숨김: 음악 팩 (packs는 /products로 308)
|
music/ — 공개: 스토리→음악 AI 스튜디오 (studio·samples, packs는 /products로 308)
|
||||||
gyeol/ — 숨김: CONTOUR PMF 설문
|
gyeol/ — 숨김: CONTOUR PMF 설문
|
||||||
|
|
||||||
lib/
|
lib/
|
||||||
@@ -116,6 +119,8 @@ lib/
|
|||||||
shuffle.ts — 셔플·3카드 드로우 로직
|
shuffle.ts — 셔플·3카드 드로우 로직
|
||||||
reference.ts — 카드 의미 레퍼런스
|
reference.ts — 카드 의미 레퍼런스
|
||||||
prompt.ts — AI 해석 프롬프트
|
prompt.ts — AI 해석 프롬프트
|
||||||
|
music/
|
||||||
|
story-prompt.ts — 스토리→가사 AI 프롬프트 (시스템 프롬프트·JSON 파싱·검증)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const LINKS = [
|
|||||||
{ href: '/showcase', label: '제작 사례' },
|
{ href: '/showcase', label: '제작 사례' },
|
||||||
{ href: '/work/saju', label: '사주' },
|
{ href: '/work/saju', label: '사주' },
|
||||||
{ href: '/tarot', label: '타로' },
|
{ href: '/tarot', label: '타로' },
|
||||||
|
{ href: '/music', label: '음악' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function TopNav() {
|
export default function TopNav() {
|
||||||
|
|||||||
@@ -68,10 +68,20 @@ type SajuRecordRow = {
|
|||||||
is_paid: boolean;
|
is_paid: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// AI 기록 탭 — 사주·타로 병합 렌더용 판별 유니언
|
// AI 기록 탭 — 음악 트랙 (app/api/studio/tracks 응답)
|
||||||
|
type MusicTrackRow = {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
story: string | null;
|
||||||
|
audio_url: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// AI 기록 탭 — 사주·타로·음악 병합 렌더용 판별 유니언
|
||||||
type AiRecordItem =
|
type AiRecordItem =
|
||||||
| { kind: 'saju'; data: SajuRecordRow }
|
| { kind: 'saju'; data: SajuRecordRow }
|
||||||
| { kind: 'tarot'; data: TarotReadingRow };
|
| { kind: 'tarot'; data: TarotReadingRow }
|
||||||
|
| { kind: 'music'; data: MusicTrackRow };
|
||||||
|
|
||||||
interface Payment {
|
interface Payment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -152,9 +162,10 @@ function MyPageContent() {
|
|||||||
const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>('');
|
const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>('');
|
||||||
const [showTelegramGuide, setShowTelegramGuide] = useState(false);
|
const [showTelegramGuide, setShowTelegramGuide] = useState(false);
|
||||||
|
|
||||||
// AI 기록 탭 — 타로 리딩 / 사주 기록
|
// AI 기록 탭 — 타로 리딩 / 사주 기록 / 음악 트랙
|
||||||
const [tarotReadings, setTarotReadings] = useState<TarotReadingRow[]>([]);
|
const [tarotReadings, setTarotReadings] = useState<TarotReadingRow[]>([]);
|
||||||
const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);
|
const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);
|
||||||
|
const [musicTracks, setMusicTracks] = useState<MusicTrackRow[]>([]);
|
||||||
const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(new Set());
|
const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const loadProjects = useCallback(async () => {
|
const loadProjects = useCallback(async () => {
|
||||||
@@ -166,7 +177,7 @@ function MyPageContent() {
|
|||||||
} catch { /* 미로그인/네트워크 — 무시 */ }
|
} catch { /* 미로그인/네트워크 — 무시 */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 사주·타로 결과 통합 로드 — 둘 다 실패해도 서로 영향 없이 무시(best-effort)
|
// 사주·타로·음악 결과 통합 로드 — 셋 다 실패해도 서로 영향 없이 무시(best-effort)
|
||||||
const loadAiRecords = useCallback(async () => {
|
const loadAiRecords = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const tr = await fetch('/api/tarot/readings');
|
const tr = await fetch('/api/tarot/readings');
|
||||||
@@ -184,6 +195,10 @@ function MyPageContent() {
|
|||||||
setSajuRecords(data ?? []);
|
setSajuRecords(data ?? []);
|
||||||
}
|
}
|
||||||
} catch { /* 무시 */ }
|
} catch { /* 무시 */ }
|
||||||
|
try {
|
||||||
|
const mt = await fetch('/api/studio/tracks');
|
||||||
|
if (mt.ok) setMusicTracks((await mt.json()).tracks ?? []);
|
||||||
|
} catch { /* 무시 */ }
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -369,10 +384,11 @@ function MyPageContent() {
|
|||||||
// 입금 확인 대기 중인 주문 (orders 테이블 pending)
|
// 입금 확인 대기 중인 주문 (orders 테이블 pending)
|
||||||
const pendingOrders = productOrders.filter((o) => o.status === 'pending');
|
const pendingOrders = productOrders.filter((o) => o.status === 'pending');
|
||||||
|
|
||||||
// AI 기록 탭 — 사주·타로 결과를 created_at 기준 내림차순 병합
|
// AI 기록 탭 — 사주·타로·음악 결과를 created_at 기준 내림차순 병합
|
||||||
const aiRecords: AiRecordItem[] = [
|
const aiRecords: AiRecordItem[] = [
|
||||||
...sajuRecords.map((r): AiRecordItem => ({ kind: 'saju', data: r })),
|
...sajuRecords.map((r): AiRecordItem => ({ kind: 'saju', data: r })),
|
||||||
...tarotReadings.map((r): AiRecordItem => ({ kind: 'tarot', data: r })),
|
...tarotReadings.map((r): AiRecordItem => ({ kind: 'tarot', data: r })),
|
||||||
|
...musicTracks.map((r): AiRecordItem => ({ kind: 'music', data: r })),
|
||||||
].sort((a, b) => new Date(b.data.created_at).getTime() - new Date(a.data.created_at).getTime());
|
].sort((a, b) => new Date(b.data.created_at).getTime() - new Date(a.data.created_at).getTime());
|
||||||
|
|
||||||
const tabs: { key: Tab; label: string; count?: number }[] = [
|
const tabs: { key: Tab; label: string; count?: number }[] = [
|
||||||
@@ -380,7 +396,7 @@ function MyPageContent() {
|
|||||||
{ key: 'requests', label: '발주·진행', count: orders.length || undefined },
|
{ key: 'requests', label: '발주·진행', count: orders.length || undefined },
|
||||||
{ key: 'products', label: '내 제품', count: productGroups.length || undefined },
|
{ key: 'products', label: '내 제품', count: productGroups.length || undefined },
|
||||||
{ key: 'orders', label: '주문 내역', count: (orders.length + payments.length) || undefined },
|
{ key: 'orders', label: '주문 내역', count: (orders.length + payments.length) || undefined },
|
||||||
{ key: 'ai', label: 'AI 기록', count: (sajuRecords.length + tarotReadings.length) || undefined },
|
{ key: 'ai', label: 'AI 기록', count: (sajuRecords.length + tarotReadings.length + musicTracks.length) || undefined },
|
||||||
];
|
];
|
||||||
|
|
||||||
function selectTab(key: Tab) {
|
function selectTab(key: Tab) {
|
||||||
@@ -910,7 +926,7 @@ function MyPageContent() {
|
|||||||
AI 기록이 없습니다
|
AI 기록이 없습니다
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm mb-6 break-keep max-w-sm mx-auto" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
<div className="text-sm mb-6 break-keep max-w-sm mx-auto" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||||
사주 분석·타로 리딩 결과가 여기에 모아서 표시됩니다.
|
사주 분석·타로 리딩·음악 생성 결과가 여기에 모아서 표시됩니다.
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
@@ -927,6 +943,13 @@ function MyPageContent() {
|
|||||||
>
|
>
|
||||||
타로 리딩 하기 →
|
타로 리딩 하기 →
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/music"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm transition-colors hover:bg-[var(--jsm-surface-alt)]"
|
||||||
|
style={{ color: 'var(--jsm-ink)', border: '1px solid var(--jsm-line)' }}
|
||||||
|
>
|
||||||
|
음악 만들기 →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -934,13 +957,15 @@ function MyPageContent() {
|
|||||||
{aiRecords.map((item) =>
|
{aiRecords.map((item) =>
|
||||||
item.kind === 'saju' ? (
|
item.kind === 'saju' ? (
|
||||||
<SajuAiCard key={`saju-${item.data.id}`} record={item.data} />
|
<SajuAiCard key={`saju-${item.data.id}`} record={item.data} />
|
||||||
) : (
|
) : item.kind === 'tarot' ? (
|
||||||
<TarotAiCard
|
<TarotAiCard
|
||||||
key={`tarot-${item.data.id}`}
|
key={`tarot-${item.data.id}`}
|
||||||
reading={item.data}
|
reading={item.data}
|
||||||
expanded={expandedAiCards.has(item.data.id)}
|
expanded={expandedAiCards.has(item.data.id)}
|
||||||
onToggle={() => toggleAiCard(item.data.id)}
|
onToggle={() => toggleAiCard(item.data.id)}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<MusicAiCard key={`music-${item.data.id}`} track={item.data} />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1569,6 +1594,48 @@ function TarotAiCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI 기록 — 음악 카드. 제목·날짜 + 스토리 요약 + 오디오 플레이어(생성 완료 시).
|
||||||
|
function MusicAiCard({ track }: { track: MusicTrackRow }) {
|
||||||
|
return (
|
||||||
|
<Card compact>
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
|
<span
|
||||||
|
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
|
||||||
|
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
|
||||||
|
>
|
||||||
|
음악
|
||||||
|
</span>
|
||||||
|
<span className="text-xs flex-shrink-0" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||||
|
{new Date(track.created_at).toLocaleDateString('ko-KR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm font-semibold mb-2 break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||||
|
{track.title || '제목 없는 트랙'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{track.story && (
|
||||||
|
<p
|
||||||
|
className="text-sm line-clamp-2 break-keep mb-3"
|
||||||
|
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
{track.story}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{track.audio_url ? (
|
||||||
|
<audio controls className="w-full" style={{ height: 36 }}>
|
||||||
|
<source src={track.audio_url} />
|
||||||
|
</audio>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||||
|
생성 준비 중입니다. 잠시 후 다시 확인해주세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TelegramIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
function TelegramIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
|
|||||||
Reference in New Issue
Block a user