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:
2026-07-03 13:29:51 +09:00
parent 39025fc57b
commit 468ee84687
3 changed files with 84 additions and 11 deletions

View File

@@ -68,10 +68,20 @@ type SajuRecordRow = {
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 =
| { kind: 'saju'; data: SajuRecordRow }
| { kind: 'tarot'; data: TarotReadingRow };
| { kind: 'tarot'; data: TarotReadingRow }
| { kind: 'music'; data: MusicTrackRow };
interface Payment {
id: string;
@@ -152,9 +162,10 @@ function MyPageContent() {
const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>('');
const [showTelegramGuide, setShowTelegramGuide] = useState(false);
// AI 기록 탭 — 타로 리딩 / 사주 기록
// AI 기록 탭 — 타로 리딩 / 사주 기록 / 음악 트랙
const [tarotReadings, setTarotReadings] = useState<TarotReadingRow[]>([]);
const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);
const [musicTracks, setMusicTracks] = useState<MusicTrackRow[]>([]);
const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(new Set());
const loadProjects = useCallback(async () => {
@@ -166,7 +177,7 @@ function MyPageContent() {
} catch { /* 미로그인/네트워크 — 무시 */ }
}, []);
// 사주·타로 결과 통합 로드 — 다 실패해도 서로 영향 없이 무시(best-effort)
// 사주·타로·음악 결과 통합 로드 — 다 실패해도 서로 영향 없이 무시(best-effort)
const loadAiRecords = useCallback(async () => {
try {
const tr = await fetch('/api/tarot/readings');
@@ -184,6 +195,10 @@ function MyPageContent() {
setSajuRecords(data ?? []);
}
} 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
}, []);
@@ -369,10 +384,11 @@ function MyPageContent() {
// 입금 확인 대기 중인 주문 (orders 테이블 pending)
const pendingOrders = productOrders.filter((o) => o.status === 'pending');
// AI 기록 탭 — 사주·타로 결과를 created_at 기준 내림차순 병합
// AI 기록 탭 — 사주·타로·음악 결과를 created_at 기준 내림차순 병합
const aiRecords: AiRecordItem[] = [
...sajuRecords.map((r): AiRecordItem => ({ kind: 'saju', 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());
const tabs: { key: Tab; label: string; count?: number }[] = [
@@ -380,7 +396,7 @@ function MyPageContent() {
{ key: 'requests', label: '발주·진행', count: orders.length || undefined },
{ key: 'products', label: '내 제품', count: productGroups.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) {
@@ -910,7 +926,7 @@ function MyPageContent() {
AI
</div>
<div className="text-sm mb-6 break-keep max-w-sm mx-auto" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
· .
· · .
</div>
<div className="flex flex-wrap items-center justify-center gap-3">
<Link
@@ -927,6 +943,13 @@ function MyPageContent() {
>
</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>
) : (
@@ -934,13 +957,15 @@ function MyPageContent() {
{aiRecords.map((item) =>
item.kind === 'saju' ? (
<SajuAiCard key={`saju-${item.data.id}`} record={item.data} />
) : (
) : item.kind === 'tarot' ? (
<TarotAiCard
key={`tarot-${item.data.id}`}
reading={item.data}
expanded={expandedAiCards.has(item.data.id)}
onToggle={() => toggleAiCard(item.data.id)}
/>
) : (
<MusicAiCard key={`music-${item.data.id}`} track={item.data} />
)
)}
</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 }) {
return (
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor" aria-hidden>