프로그래스바 수정, 이미지 분류 추가 및 UI 수정
parent
8a39a34630
commit
2f720dfa92
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
|
import { searchAccommodation, cleanSearchTitle, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
|
||||||
import { useSearchHistory } from './SearchHistory/useSearchHistory';
|
import { useSearchHistory } from './SearchHistory/useSearchHistory';
|
||||||
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
|
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
|
||||||
import BusinessNameInputModal from './BusinessNameInputModal';
|
import BusinessNameInputModal from './BusinessNameInputModal';
|
||||||
|
|
@ -80,8 +80,9 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
||||||
setInputValue(item.title.replace(/<[^>]*>/g, ''));
|
const cleanTitle = cleanSearchTitle(item.title);
|
||||||
setSelectedItem(item);
|
setInputValue(cleanTitle);
|
||||||
|
setSelectedItem({ ...item, title: cleanTitle });
|
||||||
setShowAutocomplete(false);
|
setShowAutocomplete(false);
|
||||||
setAutocompleteResults([]);
|
setAutocompleteResults([]);
|
||||||
setHighlightedIndex(-1);
|
setHighlightedIndex(-1);
|
||||||
|
|
|
||||||
|
|
@ -265,7 +265,7 @@
|
||||||
"back": "Go Back",
|
"back": "Go Back",
|
||||||
"loadMore": "Load more",
|
"loadMore": "Load more",
|
||||||
"uploadFailed": "Image upload failed.",
|
"uploadFailed": "Image upload failed.",
|
||||||
"uploading": "Uploading... (30 sec – 2 min)",
|
"uploading": "Uploading... (30 sec – 1 min)",
|
||||||
"nextStep": "Next Step"
|
"nextStep": "Next Step"
|
||||||
},
|
},
|
||||||
"soundStudio": {
|
"soundStudio": {
|
||||||
|
|
|
||||||
|
|
@ -264,7 +264,7 @@
|
||||||
"back": "뒤로가기",
|
"back": "뒤로가기",
|
||||||
"loadMore": "더보기",
|
"loadMore": "더보기",
|
||||||
"uploadFailed": "이미지 업로드에 실패했습니다.",
|
"uploadFailed": "이미지 업로드에 실패했습니다.",
|
||||||
"uploading": "업로드 중 (30초~2분 소요)",
|
"uploading": "업로드 중 (30~60초 소요)",
|
||||||
"nextStep": "다음 단계"
|
"nextStep": "다음 단계"
|
||||||
},
|
},
|
||||||
"soundStudio": {
|
"soundStudio": {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
|
||||||
const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
|
const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
|
||||||
return Math.min(prev + increment, 100);
|
return Math.min(prev + increment, 100);
|
||||||
});
|
});
|
||||||
}, 100);
|
}, 150);
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const getImageSrc = (item: ImageItem): string => {
|
const getImageSrc = (item: ImageItem): string => {
|
||||||
return item.type === 'url' ? item.url : item.preview;
|
return item.type === 'url' ? item.preview_url : item.preview;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextWithUpload = async () => {
|
const handleNextWithUpload = async () => {
|
||||||
|
|
@ -59,7 +59,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
if (prev >= 99) return prev;
|
if (prev >= 99) return prev;
|
||||||
return prev + (prev < 50 ? 0.6 : prev < 80 ? 0.3 : prev < 90 ? 0.15 : 0.11);
|
return prev + (prev < 50 ? 0.6 : prev < 80 ? 0.3 : prev < 90 ? 0.15 : 0.11);
|
||||||
});
|
});
|
||||||
}, 300);
|
}, 200);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const urlImages: ImageUrlItem[] = imageList
|
const urlImages: ImageUrlItem[] = imageList
|
||||||
|
|
@ -74,6 +74,12 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
const response = await uploadImages(urlImages, fileImages);
|
const response = await uploadImages(urlImages, fileImages);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
|
// 이미지 변경 시 이전 음악/영상 생성 결과 초기화 — 반드시 재생성하도록 강제
|
||||||
|
localStorage.removeItem('castad_song_generation');
|
||||||
|
localStorage.removeItem('castad_song_completion');
|
||||||
|
localStorage.removeItem('castad_song_task_id');
|
||||||
|
localStorage.removeItem('castad_video_generation');
|
||||||
|
localStorage.removeItem('castad_video_complete');
|
||||||
onNext(response.task_id);
|
onNext(response.task_id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -181,6 +187,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
<img
|
<img
|
||||||
src={getImageSrc(item)}
|
src={getImageSrc(item)}
|
||||||
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
|
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
{item.type === 'file' && (
|
{item.type === 'file' && (
|
||||||
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>
|
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>
|
||||||
|
|
|
||||||
|
|
@ -500,19 +500,22 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
|
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
|
||||||
}
|
}
|
||||||
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
|
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
|
||||||
const outro = lines.slice(-1);
|
const sections: { tag: string | null; lines: string[] }[] = [];
|
||||||
const body = lines.slice(0, -1);
|
lines.forEach((line: string) => {
|
||||||
const half = Math.ceil(body.length / 2);
|
const tagMatch = line.trim().match(/^\[(.+)\]$/);
|
||||||
const sections = [
|
if (tagMatch) {
|
||||||
{ tag: '[Verse]', lines: body.slice(0, half) },
|
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
|
||||||
{ tag: '[Chorus]', lines: body.slice(half) },
|
} else if (sections.length === 0) {
|
||||||
{ tag: '[Outro]', lines: outro },
|
sections.push({ tag: null, lines: [line] });
|
||||||
].filter(s => s.lines.length > 0);
|
} else {
|
||||||
|
sections[sections.length - 1].lines.push(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div className="comp2-lyrics-paragraphs">
|
<div className="comp2-lyrics-paragraphs">
|
||||||
{sections.map((section, i) => (
|
{sections.filter(s => s.lines.length > 0).map((section, i) => (
|
||||||
<div key={i} className="comp2-lyrics-para-section">
|
<div key={i} className="comp2-lyrics-para-section">
|
||||||
<span className="comp2-lyrics-tag">{section.tag}</span>
|
{section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
|
||||||
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
|
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import MyInfoContent from './MyInfoContent';
|
||||||
import ContentCalendarContent from './ContentCalendarContent';
|
import ContentCalendarContent from './ContentCalendarContent';
|
||||||
import LoadingSection from '../Analysis/LoadingSection';
|
import LoadingSection from '../Analysis/LoadingSection';
|
||||||
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
||||||
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
||||||
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
|
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
|
|
@ -52,7 +52,7 @@ interface BusinessInfo {
|
||||||
interface GenerationFlowProps {
|
interface GenerationFlowProps {
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
initialActiveItem?: string;
|
initialActiveItem?: string;
|
||||||
initialImageList?: string[];
|
initialImageList?: ImageListItem[];
|
||||||
businessInfo?: BusinessInfo;
|
businessInfo?: BusinessInfo;
|
||||||
initialAnalysisData?: CrawlingResponse | null;
|
initialAnalysisData?: CrawlingResponse | null;
|
||||||
}
|
}
|
||||||
|
|
@ -169,9 +169,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
// URL 이미지를 ImageItem 형태로 변환하여 초기화
|
// URL 이미지를 ImageItem 형태로 변환하여 초기화
|
||||||
const getInitialImageList = (): ImageItem[] => {
|
const getInitialImageList = (): ImageItem[] => {
|
||||||
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
||||||
return analysisData.image_list.map(url => ({ type: 'url', url }));
|
return analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }));
|
||||||
}
|
}
|
||||||
return initialImageList.map(url => ({ type: 'url', url }));
|
return initialImageList.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList());
|
const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList());
|
||||||
|
|
@ -183,7 +183,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
|
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
|
||||||
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
||||||
if (prevAnalysisMIdRef.current !== analysisData.m_id) {
|
if (prevAnalysisMIdRef.current !== analysisData.m_id) {
|
||||||
setImageList(analysisData.image_list.map(url => ({ type: 'url', url })));
|
setImageList(analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevAnalysisMIdRef.current = analysisData?.m_id;
|
prevAnalysisMIdRef.current = analysisData?.m_id;
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,21 @@ const getEffectiveGenre = (selectedGenre: string): string => {
|
||||||
return genreMap[selectedGenre] || 'pop';
|
return genreMap[selectedGenre] || 'pop';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 실제 영상 생성에 사용된 genre 값(kpop, hip-hop 등)을 화면 표시용 라벨로 변환
|
||||||
|
const getGenreDisplayLabel = (genreValue: string, t: (key: string) => string): string => {
|
||||||
|
const displayMap: Record<string, string> = {
|
||||||
|
kpop: 'K-POP',
|
||||||
|
pop: 'POP',
|
||||||
|
ballad: t('soundStudio.genreBallad'),
|
||||||
|
'hip-hop': 'Hip-Hop',
|
||||||
|
rnb: 'R&B',
|
||||||
|
edm: 'EDM',
|
||||||
|
jazz: 'JAZZ',
|
||||||
|
rock: 'ROCK',
|
||||||
|
};
|
||||||
|
return displayMap[genreValue] || genreValue;
|
||||||
|
};
|
||||||
|
|
||||||
const sanitizeError = (message: string, fallback: string): string => {
|
const sanitizeError = (message: string, fallback: string): string => {
|
||||||
if (
|
if (
|
||||||
message.includes('Traceback') ||
|
message.includes('Traceback') ||
|
||||||
|
|
@ -110,7 +125,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
const saveSongCompletionData = () => {
|
const saveSongCompletionData = () => {
|
||||||
const completionData = {
|
const completionData = {
|
||||||
businessName: businessInfo?.customer_name || '알 수 없음',
|
businessName: businessInfo?.customer_name || '알 수 없음',
|
||||||
genre: selectedGenre,
|
// '자동 선택'처럼 UI에서 고른 값이 아니라, 실제 영상 생성에 사용된 genre(usedGenre)를 저장
|
||||||
|
genre: usedGenre ? getGenreDisplayLabel(usedGenre, t) : selectedGenre,
|
||||||
lyrics: lyrics,
|
lyrics: lyrics,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
@ -367,6 +383,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
try {
|
try {
|
||||||
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
||||||
const isInstrumental = selectedType === '배경음악';
|
const isInstrumental = selectedType === '배경음악';
|
||||||
|
// 수동 선택한 장르는 가사 생성 시점부터 넘겨서, 그 장르 템포에 맞는 줄 수로 가사를 쓰게 한다.
|
||||||
|
// '자동 선택'이면 넘기지 않고, 가사 생성 결과로 받은 recommended genre를 그대로 쓴다.
|
||||||
|
const manualGenre = selectedGenre === '자동 선택' ? undefined : genreMap[selectedGenre];
|
||||||
|
|
||||||
// 1. 가사 생성 요청
|
// 1. 가사 생성 요청
|
||||||
console.log('[SoundStudio] Sending m_id:', mId);
|
console.log('[SoundStudio] Sending m_id:', mId);
|
||||||
|
|
@ -380,6 +399,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
industry,
|
industry,
|
||||||
orientation,
|
orientation,
|
||||||
...(isInstrumental && { instrumental: true }),
|
...(isInstrumental && { instrumental: true }),
|
||||||
|
...(manualGenre && { genre: manualGenre }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!lyricResponse.success || !lyricResponse.task_id) {
|
if (!lyricResponse.success || !lyricResponse.task_id) {
|
||||||
|
|
@ -387,6 +407,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
let songLyrics: string | undefined;
|
let songLyrics: string | undefined;
|
||||||
|
// BGM 모드는 백엔드가 가사/장르 추천 없이 즉시 completed 처리하므로 GPT 추천 장르가 없다.
|
||||||
|
// 이 경우에만 기존 랜덤 선택 로직으로 폴백한다.
|
||||||
|
let effectiveGenre = manualGenre || getEffectiveGenre(selectedGenre);
|
||||||
|
|
||||||
if (!isInstrumental) {
|
if (!isInstrumental) {
|
||||||
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
|
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
|
||||||
|
|
@ -411,12 +434,12 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
|
|
||||||
setLyrics(lyricDetailResponse.lyric_result);
|
setLyrics(lyricDetailResponse.lyric_result);
|
||||||
songLyrics = lyricDetailResponse.lyric_result;
|
songLyrics = lyricDetailResponse.lyric_result;
|
||||||
|
// 가사와 함께 확정된 장르(수동 선택값 또는 GPT 추천값)를 그대로 음악 생성에 사용
|
||||||
|
effectiveGenre = lyricDetailResponse.genre || effectiveGenre;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('generating_song');
|
setStatus('generating_song');
|
||||||
setStatusMessage(t('soundStudio.generatingSong'));
|
setStatusMessage(t('soundStudio.generatingSong'));
|
||||||
|
|
||||||
const effectiveGenre = getEffectiveGenre(selectedGenre);
|
|
||||||
setUsedGenre(effectiveGenre);
|
setUsedGenre(effectiveGenre);
|
||||||
|
|
||||||
const songResponse = await generateSong(imageTaskId, {
|
const songResponse = await generateSong(imageTaskId, {
|
||||||
|
|
@ -698,19 +721,22 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
<div className="lyrics-display">
|
<div className="lyrics-display">
|
||||||
{lyrics ? (() => {
|
{lyrics ? (() => {
|
||||||
const lines = lyrics.split('\n').filter(l => l.trim());
|
const lines = lyrics.split('\n').filter(l => l.trim());
|
||||||
const outro = lines.slice(-1);
|
const sections: { tag: string | null; lines: string[] }[] = [];
|
||||||
const body = lines.slice(0, -1);
|
lines.forEach(line => {
|
||||||
const half = Math.ceil(body.length / 2);
|
const tagMatch = line.trim().match(/^\[(.+)\]$/);
|
||||||
const sections = [
|
if (tagMatch) {
|
||||||
{ tag: '[Verse]', lines: body.slice(0, half) },
|
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
|
||||||
{ tag: '[Chorus]', lines: body.slice(half) },
|
} else if (sections.length === 0) {
|
||||||
{ tag: '[Outro]', lines: outro },
|
sections.push({ tag: null, lines: [line] });
|
||||||
].filter(s => s.lines.length > 0);
|
} else {
|
||||||
|
sections[sections.length - 1].lines.push(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div className="lyrics-paragraphs">
|
<div className="lyrics-paragraphs">
|
||||||
{sections.map((section, i) => (
|
{sections.filter(s => s.lines.length > 0).map((section, i) => (
|
||||||
<div key={i} className="lyrics-section">
|
<div key={i} className="lyrics-section">
|
||||||
<span className="lyrics-tag">{section.tag}</span>
|
{section.tag && <span className="lyrics-tag">{section.tag}</span>}
|
||||||
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
|
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1419,7 +1419,7 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
background-color: #002224;
|
background-color: var(--color-bg-darker);
|
||||||
}
|
}
|
||||||
|
|
||||||
.myinfo-title {
|
.myinfo-title {
|
||||||
|
|
|
||||||
|
|
@ -547,7 +547,7 @@
|
||||||
.lyrics-paragraphs {
|
.lyrics-paragraphs {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 1rem;
|
padding: 0 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
|
|
@ -574,14 +574,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-tag {
|
.lyrics-tag {
|
||||||
font-size: var(--text-xs);
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #379599;
|
color: #379599;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-paragraph {
|
.lyrics-paragraph {
|
||||||
font-size: var(--text-sm);
|
font-size: 14px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #CEE5E6;
|
color: #CEE5E6;
|
||||||
line-height: 1.9;
|
line-height: 1.9;
|
||||||
|
|
@ -594,7 +594,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: var(--text-sm);
|
font-size: 14px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #6AB0B3;
|
color: #6AB0B3;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,13 @@ export interface MarketingAnalysis {
|
||||||
target_keywords: string[];
|
target_keywords: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageListItem {
|
||||||
|
preview: string; // 미리보기용 CDN 리사이즈 URL
|
||||||
|
original: string; // 업로드용 원본 URL
|
||||||
|
}
|
||||||
|
|
||||||
export interface CrawlingResponse {
|
export interface CrawlingResponse {
|
||||||
image_list: string[];
|
image_list: ImageListItem[] | null;
|
||||||
image_count: number;
|
image_count: number;
|
||||||
m_id: number;
|
m_id: number;
|
||||||
industry: string;
|
industry: string;
|
||||||
|
|
@ -49,7 +54,8 @@ export interface CrawlingResponse {
|
||||||
// URL 이미지 (서버에서 가져온 이미지)
|
// URL 이미지 (서버에서 가져온 이미지)
|
||||||
export interface UrlImage {
|
export interface UrlImage {
|
||||||
type: 'url';
|
type: 'url';
|
||||||
url: string;
|
url: string; // 업로드용 원본 URL
|
||||||
|
preview_url: string; // 미리보기용 CDN 리사이즈 URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// 업로드된 파일 이미지
|
// 업로드된 파일 이미지
|
||||||
|
|
@ -72,6 +78,7 @@ export interface LyricGenerateRequest {
|
||||||
industry?: string;
|
industry?: string;
|
||||||
orientation?: 'vertical' | 'horizontal';
|
orientation?: 'vertical' | 'horizontal';
|
||||||
instrumental?: boolean;
|
instrumental?: boolean;
|
||||||
|
genre?: string; // 사용자가 수동으로 고른 장르. '자동 선택'이면 생략 (GPT가 추천)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 가사 생성 응답
|
// 가사 생성 응답
|
||||||
|
|
@ -101,6 +108,7 @@ export interface LyricDetailResponse {
|
||||||
status: string;
|
status: string;
|
||||||
lyric_prompt: string;
|
lyric_prompt: string;
|
||||||
lyric_result: string;
|
lyric_result: string;
|
||||||
|
genre?: string | null; // 확정된 장르 (수동 선택값 또는 GPT 추천값)
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -844,6 +844,16 @@ export interface AccommodationSearchItem {
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 네이버 검색 API는 title에 <b> 태그와 & 같은 HTML 엔티티를 함께 내려주므로
|
||||||
|
// 태그 제거 후 엔티티까지 디코딩해야 "&" 같은 원문이 그대로 노출되지 않는다.
|
||||||
|
// textarea는 DOM에 붙이지 않고 content model상 스크립트를 실행하지 않으므로 엔티티 디코딩 용도로 안전하다.
|
||||||
|
export const cleanSearchTitle = (title: string): string => {
|
||||||
|
const withoutTags = title.replace(/<[^>]*>/g, '');
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.innerHTML = withoutTags;
|
||||||
|
return textarea.value;
|
||||||
|
};
|
||||||
|
|
||||||
export interface AccommodationSearchResponse {
|
export interface AccommodationSearchResponse {
|
||||||
count: number;
|
count: number;
|
||||||
items: AccommodationSearchItem[];
|
items: AccommodationSearchItem[];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue