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종 + 실서비스 운영 사례 |
|
||||
| `/work/saju` | 사주 분석 — 공개 AI 사주 (로그인 시 무료 해석 1회/일) |
|
||||
| `/tarot` | 타로 — 3카드 셔플·해석 (비로그인 카드 리딩, 로그인 AI 인사이트) |
|
||||
| `/music` | 공개 음악 — 스토리→음악 AI 스튜디오 (studio·samples, 로그인 시 생성·저장) |
|
||||
| `/track/[token]` | 비회원 의뢰 진행 추적 |
|
||||
| `/quote/[token]` | 공개 견적 — 고객 수락/거절 |
|
||||
| `/login` | 로그인 (`?next=` 리다이렉트 지원) |
|
||||
| `/mypage` | 4탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 |
|
||||
| `/mypage` | 5탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 / AI 기록(사주·타로·음악 병합) |
|
||||
| `/legal/*` | 이용약관 · 개인정보처리방침 · 환불정책 |
|
||||
|
||||
## 숨김 서비스 (admin_token 세션 전용)
|
||||
@@ -33,7 +34,6 @@ admin/services 패널에서 ON/OFF 전환 가능.
|
||||
|
||||
| 경로 | 서비스 |
|
||||
|------|--------|
|
||||
| `/music/*` | 음악 팩 (단, `/music/packs`는 `/products`로 308 리다이렉트) |
|
||||
| `/gyeol` | CONTOUR PMF 설문 |
|
||||
|
||||
## 기술 스택
|
||||
@@ -93,9 +93,12 @@ app/
|
||||
saju/analyze/route.ts — 사주 AI 분석 (Gemini)
|
||||
tarot/interpret/route.ts — 타로 AI 인사이트 (로그인·일 3회 제한)
|
||||
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회/일)
|
||||
tarot/ — 공개: 타로 3카드 (셔플·reference·AI 해석)
|
||||
music/ — 숨김: 음악 팩 (packs는 /products로 308)
|
||||
music/ — 공개: 스토리→음악 AI 스튜디오 (studio·samples, packs는 /products로 308)
|
||||
gyeol/ — 숨김: CONTOUR PMF 설문
|
||||
|
||||
lib/
|
||||
@@ -116,6 +119,8 @@ lib/
|
||||
shuffle.ts — 셔플·3카드 드로우 로직
|
||||
reference.ts — 카드 의미 레퍼런스
|
||||
prompt.ts — AI 해석 프롬프트
|
||||
music/
|
||||
story-prompt.ts — 스토리→가사 AI 프롬프트 (시스템 프롬프트·JSON 파싱·검증)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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