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:
@@ -12,6 +12,7 @@ const LINKS = [
|
||||
{ href: '/showcase', label: '제작 사례' },
|
||||
{ href: '/work/saju', label: '사주' },
|
||||
{ href: '/tarot', label: '타로' },
|
||||
{ href: '/music', label: '음악' },
|
||||
];
|
||||
|
||||
export default function TopNav() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user