메인페이지 원복시키기

main
Mina Choi 2026-05-15 13:39:56 +09:00
parent c07ef255d4
commit 5f7e0051cd
9 changed files with 178 additions and 123 deletions

View File

@ -31,7 +31,6 @@ import {
pickPrimaryUrl, pickPrimaryUrl,
type ClassifiedUrls, type ClassifiedUrls,
} from '../lib/classifyUrls'; } from '../lib/classifyUrls';
import { Button } from '@/shared/ui/button';
import { PLATFORM_META } from '@/features/clinics/components/PlatformChips'; import { PLATFORM_META } from '@/features/clinics/components/PlatformChips';
import type { PlatformKey } from '@/features/clinics/types/workspace'; import type { PlatformKey } from '@/features/clinics/types/workspace';
@ -159,15 +158,13 @@ export default function MultiChannelInput({ variant = 'hero', onAnalyze }: Multi
const Icon = meta.Icon; const Icon = meta.Icon;
const value = urls[key]; const value = urls[key];
const status = validateField(value, key); const status = validateField(value, key);
const inactiveColor = isHero ? '#94a3b8' /* slate-400 */ : 'rgba(255,255,255,0.4)';
return ( return (
<div key={key} className="relative"> <div key={key} className="relative">
{/* , . {/* 좌측 채널 아이콘 — 입력 시 컬러 적용, 빈 칸일 땐 회색 */}
z-10 input bg/backdrop-blur ( input ) */} <div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none flex items-center gap-2">
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none flex items-center gap-2 z-10">
<Icon <Icon
size={16} size={16}
style={{ color: value ? meta.color : inactiveColor }} style={{ color: value ? meta.color : isHero ? '#94a3b8' : 'rgba(255,255,255,0.4)' }}
/> />
</div> </div>
<input <input
@ -181,8 +178,8 @@ export default function MultiChannelInput({ variant = 'hero', onAnalyze }: Multi
spellCheck={false} spellCheck={false}
autoComplete="off" autoComplete="off"
/> />
{/* 우측 검증 상태 아이콘 — 동일하게 z-10 */} {/* 우측 검증 상태 아이콘 */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none z-10"> <div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
{status === 'valid' && ( {status === 'valid' && (
<CheckFilled size={16} className="text-emerald-500" /> <CheckFilled size={16} className="text-emerald-500" />
)} )}
@ -201,18 +198,18 @@ export default function MultiChannelInput({ variant = 'hero', onAnalyze }: Multi
</p> </p>
{/* 분석 시작 버튼 — DS Primary pill */} {/* 분석 시작 버튼 — DS Primary pill */}
<Button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canAnalyze} disabled={!canAnalyze}
className={`w-full max-w-md mx-auto mt-4 px-10 py-4 h-auto text-lg font-medium rounded-full shadow-xl hover:shadow-2xl flex items-center justify-center gap-2 group bg-gradient-to-r transform-gpu will-change-transform transition-[transform,filter,box-shadow] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:hover:scale-[1.02] motion-safe:hover:brightness-110 active:scale-[0.98] active:duration-150 ${ className={`w-full max-w-md mx-auto mt-4 px-10 py-4 text-lg font-medium rounded-full transition-all duration-150 shadow-xl hover:shadow-2xl flex items-center justify-center gap-2 group bg-gradient-to-r active:scale-[0.98] ${
isHero isHero
? 'from-brand-purple to-brand-purple-deep text-white' ? 'from-[#4F1DA1] to-[#021341] text-white hover:from-[#AF90FF] hover:to-[#AF90FF]'
: 'from-brand-grad-peach via-brand-grad-violet to-brand-grad-sky text-primary-900' : 'from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] text-primary-900 hover:scale-[1.02]'
}`} }`}
> >
Analyze Analyze
<ArrowRight className="size-5 transform-gpu will-change-transform transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:group-hover:translate-x-1" /> <ArrowRight className="w-5 h-5 group-hover:translate-x-1" />
</Button> </button>
{/* 보조 안내 (하단) */} {/* 보조 안내 (하단) */}
<p className={`text-xs font-medium mt-3 text-center leading-relaxed break-keep ${helperClass}`}> <p className={`text-xs font-medium mt-3 text-center leading-relaxed break-keep ${helperClass}`}>

View File

@ -0,0 +1,74 @@
import { useMemo } from 'react';
import { useGetClinic, useGetClinicHistory } from '@/shared/api/generated/clinics/clinics';
import { AnalysisStatus } from '@/shared/api/model/analysisStatus';
import type {
WorkspaceData,
WorkspaceRun,
WorkspaceRunStatus,
} from '../types/workspace';
/**
* /clinics/:clinicId .
*
* - useGetClinic: (hospital_name, hospital_name_en )
* - useGetClinicHistory: run +
*
* plans plans .
* ( useGetPlan / list )
*/
export function useClinicWorkspace(clinicId: string | undefined) {
const clinicQuery = useGetClinic(clinicId ?? '', {
query: { enabled: !!clinicId },
});
const historyQuery = useGetClinicHistory(clinicId ?? '', {
query: { enabled: !!clinicId },
});
const data = useMemo<WorkspaceData | null>(() => {
if (!clinicId) return null;
if (clinicQuery.data?.status !== 200) return null;
if (historyQuery.data?.status !== 200) return null;
const clinic = clinicQuery.data.data;
const history = historyQuery.data.data;
const runs: WorkspaceRun[] = history.runs.map((r) => ({
runId: r.run_id,
startedAt: r.started_at,
completedAt: r.completed_at ?? null,
status: normalizeRunStatus(r.status),
overallScore: typeof r.overall_score === 'number' ? r.overall_score : null,
// 백엔드에 분석 대상 targets 가 아직 없음 — 빈 배열
targets: [],
}));
return {
clinic: {
clinicId: clinic.hospital_id,
name: clinic.hospital_name,
nameEn: clinic.hospital_name_en ?? undefined,
location: clinic.road_address ?? undefined,
defaultTargets: [],
},
runs,
plans: [],
};
}, [clinicId, clinicQuery.data, historyQuery.data]);
return {
data,
isLoading: clinicQuery.isLoading || historyQuery.isLoading,
error:
clinicQuery.error?.message ||
historyQuery.error?.message ||
(clinicQuery.data && clinicQuery.data.status !== 200 ? '클리닉 정보를 불러올 수 없습니다.' : null) ||
(historyQuery.data && historyQuery.data.status !== 200 ? '분석 이력을 불러올 수 없습니다.' : null),
};
}
function normalizeRunStatus(s: string): WorkspaceRunStatus {
if (s === AnalysisStatus.completed) return 'completed';
if (s === AnalysisStatus.failed) return 'failed';
if (s === 'queued') return 'queued';
return 'running';
}

View File

@ -15,7 +15,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs';
import { WorkspaceHeader } from '../components/WorkspaceHeader'; import { WorkspaceHeader } from '../components/WorkspaceHeader';
import { AnalysisTab } from '../components/tabs/AnalysisTab'; import { AnalysisTab } from '../components/tabs/AnalysisTab';
import { SettingsTab } from '../components/tabs/SettingsTab'; import { SettingsTab } from '../components/tabs/SettingsTab';
import { mockWorkspace } from '../data/mockClinicWorkspace'; import { useClinicWorkspace } from '../hooks/useClinicWorkspace';
import { PageContainer } from '@/shared/ui/page-container'; import { PageContainer } from '@/shared/ui/page-container';
const VALID_TABS = ['analysis', 'settings'] as const; const VALID_TABS = ['analysis', 'settings'] as const;
@ -31,24 +31,16 @@ export default function ClinicWorkspacePage() {
const requested = searchParams.get('tab'); const requested = searchParams.get('tab');
const activeTab: TabKey = isTabKey(requested) ? requested : 'analysis'; const activeTab: TabKey = isTabKey(requested) ? requested : 'analysis';
// TODO: 실 API 연동 (useGetClinicHistory + report 메타 보강) const { data, isLoading, error } = useClinicWorkspace(clinicId);
const data = useMemo(
() => ({
...mockWorkspace,
clinic: {
...mockWorkspace.clinic,
clinicId: clinicId ?? mockWorkspace.clinic.clinicId,
},
}),
[clinicId],
);
const sortedRuns = useMemo( const sortedRuns = useMemo(
() => () =>
[...data.runs].sort( data
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), ? [...data.runs].sort(
), (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
[data.runs], )
: [],
[data],
); );
const latestRun = sortedRuns[0]; const latestRun = sortedRuns[0];
const previousRun = sortedRuns[1]; const previousRun = sortedRuns[1];
@ -79,6 +71,28 @@ export default function ClinicWorkspacePage() {
window.scrollTo({ top: y, behavior: 'auto' }); window.scrollTo({ top: y, behavior: 'auto' });
}; };
if (isLoading) {
return (
<div className="pt-20 min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
<p className="text-slate-500 text-sm"> ...</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="pt-20 min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-[#7C3A4B] font-medium mb-2"> </p>
<p className="text-slate-500 text-sm">{error ?? '워크스페이스 데이터를 찾을 수 없습니다.'}</p>
</div>
</div>
);
}
return ( return (
<div className="pt-20 flex-1 flex flex-col"> <div className="pt-20 flex-1 flex flex-col">
<WorkspaceHeader <WorkspaceHeader

View File

@ -59,7 +59,7 @@ export default function CTA() {
> >
<MultiChannelInput variant="cta" onAnalyze={handleAnalyze} /> <MultiChannelInput variant="cta" onAnalyze={handleAnalyze} />
{/* CTA ( ) {/* 보조 CTA — 가격 플랜 */}
<div className="mt-6 flex justify-center"> <div className="mt-6 flex justify-center">
<Link <Link
to="/pricing?from=cta" to="/pricing?from=cta"
@ -68,7 +68,6 @@ export default function CTA() {
</Link> </Link>
</div> </div>
*/}
</motion.div> </motion.div>
</div> </div>
</section> </section>

View File

@ -2,16 +2,25 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation, useParams } from 'react-router'; import { useNavigate, useLocation, useParams } from 'react-router';
import { useCreateClinic } from '@/shared/api/generated/clinics/clinics'; import { useCreateClinic } from '@/shared/api/generated/clinics/clinics';
import { useStartAnalysis, getAnalysisStatus } from '@/shared/api/generated/analyses/analyses'; import { useStartAnalysis, getAnalysisStatus } from '@/shared/api/generated/analyses/analyses';
import { getReport } from '@/shared/api/generated/reports/reports'; import { AnalysisStatus } from '@/shared/api/model/analysisStatus';
import { sleep } from '@/shared/lib/utils';
// AnalyzePayload(MultiChannelInput) 와 호환되도록 string | string[] 둘 다 허용.
// 백엔드 SDK 는 단일 string 만 받으므로 내부에서 첫 요소만 추출.
export type ManualChannels = { export type ManualChannels = {
instagram?: string; instagram?: string | string[];
youtube?: string; youtube?: string | string[];
facebook?: string; facebook?: string | string[];
naverBlog?: string; naverBlog?: string | string[];
gangnamUnni?: string; gangnamUnni?: string | string[];
}; };
function pickFirst(v: string | string[] | undefined): string | null {
if (!v) return null;
if (Array.isArray(v)) return v[0] ?? null;
return v;
}
export type Phase = export type Phase =
| 'resuming' | 'resuming'
| 'discovering' | 'discovering'
@ -47,22 +56,9 @@ function clearSession() {
Object.values(SESSION_KEYS).forEach((k) => sessionStorage.removeItem(k)); Object.values(SESSION_KEYS).forEach((k) => sessionStorage.removeItem(k));
} }
// 백엔드 AnalysisStatus enum: discovering | collecting | analyzing | completed | failed // 백엔드 AnalysisStatus 4단계를 UI 5단계로 매핑.
// UI Phase 스텝과 매핑 // analyzing 한 단계는 길어서 generating → planning 으로 시간 기반 분할.
function phaseFromStatus(s: string): Phase { const ANALYZING_SPLIT_MS = 30_000;
if (s === 'discovering') return 'collecting';
if (s === 'collecting') return 'generating';
if (s === 'analyzing') return 'planning';
if (s === 'completed') return 'complete';
return 'discovering';
}
function resumePhaseFromStatus(s: string): Phase {
if (s === 'discovering') return 'collecting';
if (s === 'collecting') return 'generating';
if (s === 'analyzing') return 'planning';
return 'discovering';
}
interface UseAnalysisPipelineResult { interface UseAnalysisPipelineResult {
phase: Phase; phase: Phase;
@ -98,13 +94,26 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
const runPipeline = useCallback( const runPipeline = useCallback(
async ( async (
startUrl?: string, startUrl?: string,
resumeFrom?: { reportId: string; clinicId?: string; runId?: string; phase: Phase }, resumeFrom?: { reportId: string; clinicId?: string; runId?: string },
) => { ) => {
try { try {
let reportId = resumeFrom?.reportId || ''; let reportId = resumeFrom?.reportId || '';
let clinicId = resumeFrom?.clinicId; let clinicId = resumeFrom?.clinicId;
let runId = resumeFrom?.runId; let runId = resumeFrom?.runId;
// analyzing 진입 시각을 기록해서 generating → planning 시점을 시간으로 판단
let analyzingStartedAt: number | null = null;
const mapPhase = (s: AnalysisStatus): Phase => {
if (s === AnalysisStatus.discovering) return 'discovering';
if (s === AnalysisStatus.collecting) return 'collecting';
if (s === AnalysisStatus.analyzing) {
if (analyzingStartedAt === null) analyzingStartedAt = Date.now();
return Date.now() - analyzingStartedAt > ANALYZING_SPLIT_MS ? 'planning' : 'generating';
}
if (s === AnalysisStatus.completed) return 'complete';
return 'discovering';
};
// Phase 1: 신규 분석이면 createClinic → startAnalysis // Phase 1: 신규 분석이면 createClinic → startAnalysis
if (!resumeFrom?.runId) { if (!resumeFrom?.runId) {
if (!startUrl) throw new Error('No URL provided'); if (!startUrl) throw new Error('No URL provided');
@ -120,11 +129,11 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
data: { data: {
clinic_id: clinicId, clinic_id: clinicId,
channels: { channels: {
youtube: manualChannels?.youtube ?? null, youtube: pickFirst(manualChannels?.youtube),
instagram: manualChannels?.instagram ?? null, instagram: pickFirst(manualChannels?.instagram),
facebook: manualChannels?.facebook ?? null, facebook: pickFirst(manualChannels?.facebook),
naver_blog: manualChannels?.naverBlog ?? null, naver_blog: pickFirst(manualChannels?.naverBlog),
gangnam_unni: manualChannels?.gangnamUnni ?? null, gangnam_unni: pickFirst(manualChannels?.gangnamUnni),
}, },
}, },
}); });
@ -146,40 +155,20 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
const statusRes = await getAnalysisStatus(runId); const statusRes = await getAnalysisStatus(runId);
if (statusRes.status !== 200) throw new Error('분석 상태 조회에 실패했습니다.'); if (statusRes.status !== 200) throw new Error('분석 상태 조회에 실패했습니다.');
const statusData = statusRes.data; const statusData = statusRes.data;
setPhase(phaseFromStatus(statusData.status)); setPhase(mapPhase(statusData.status));
if (statusData.channel_errors && Object.keys(statusData.channel_errors).length > 0) { if (statusData.channel_errors && Object.keys(statusData.channel_errors).length > 0) {
console.warn('[pipeline] Partial failures:', statusData.channel_errors); console.warn('[pipeline] Partial failures:', statusData.channel_errors);
} }
if (statusData.status === 'completed') break; if (statusData.status === AnalysisStatus.completed) break;
if (statusData.status === 'failed') { if (statusData.status === AnalysisStatus.failed) {
throw new Error('파이프라인 실패: failed'); throw new Error('파이프라인 실패: failed');
} }
await new Promise((r) => setTimeout(r, 1500)); await sleep(1500);
} }
setPhase('complete'); setPhase('complete');
clearSession(); clearSession();
navigate(`/report/${reportId}`, { replace: true });
// 최종 리포트 조회 (best effort)
const reportRes = await getReport(reportId).catch(() => null);
const reportPayload = reportRes && reportRes.status === 200 ? reportRes.data : null;
setTimeout(() => {
navigate(`/report/${reportId}`, {
replace: true,
state: reportPayload
? {
report: reportPayload,
metadata: {
url: startUrl,
clinicName: '',
generatedAt: new Date().toISOString(),
},
reportId,
clinicId,
}
: undefined,
});
}, 800);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'An error occurred'; const msg = err instanceof Error ? err.message : 'An error occurred';
setError(msg); setError(msg);
@ -203,13 +192,12 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
reportId: session.reportId, reportId: session.reportId,
clinicId: session.clinicId || undefined, clinicId: session.clinicId || undefined,
runId: session.runId || undefined, runId: session.runId || undefined,
phase,
}); });
} else if (url || session.url) { } else if (url || session.url) {
hasStarted.current = false; hasStarted.current = false;
runPipeline(url || session.url || undefined); runPipeline(url || session.url || undefined);
} }
}, [phase, url, runPipeline]); }, [url, runPipeline]);
const handleAbort = useCallback(() => { const handleAbort = useCallback(() => {
clearSession(); clearSession();
@ -229,11 +217,11 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
.then((res) => { .then((res) => {
if (res.status !== 200) throw new Error('status fetch failed'); if (res.status !== 200) throw new Error('status fetch failed');
const status = res.data; const status = res.data;
if (status.status === 'completed') { if (status.status === AnalysisStatus.completed) {
navigate(`/report/${urlReportId}`, { replace: true }); navigate(`/report/${urlReportId}`, { replace: true });
return; return;
} }
if (status.status === 'failed') { if (status.status === AnalysisStatus.failed) {
setError('Data collection failed. Please retry.'); setError('Data collection failed. Please retry.');
setPhase('collecting'); setPhase('collecting');
return; return;
@ -248,7 +236,6 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
reportId: urlReportId, reportId: urlReportId,
clinicId: session.clinicId || undefined, clinicId: session.clinicId || undefined,
runId, runId,
phase: resumePhaseFromStatus(status.status),
}); });
}) })
.catch(() => { .catch(() => {
@ -266,7 +253,7 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
.then((res) => { .then((res) => {
if (res.status !== 200) throw new Error('status fetch failed'); if (res.status !== 200) throw new Error('status fetch failed');
const status = res.data; const status = res.data;
if (status.status === 'completed') { if (status.status === AnalysisStatus.completed) {
clearSession(); clearSession();
navigate(`/report/${session.reportId}`, { replace: true }); navigate(`/report/${session.reportId}`, { replace: true });
return; return;
@ -275,7 +262,6 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
reportId: session.reportId!, reportId: session.reportId!,
clinicId: session.clinicId || undefined, clinicId: session.clinicId || undefined,
runId, runId,
phase: resumePhaseFromStatus(status.status),
}); });
}) })
.catch(() => { .catch(() => {

View File

@ -8,16 +8,7 @@
*/ */
import ky, { type KyInstance } from 'ky' import ky, { type KyInstance } from 'ky'
// Vite 가 빌드 시 치환하는 환경변수 (개발 서버에서도 동일). const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
// import.meta 를 모듈 최상단에서 읽으면 일부 번들러가 cjs 변환 시 경고를 내므로
// 함수 내부에서 lazy 하게 접근.
function getApiKey(): string | undefined {
try {
return (import.meta as { env?: { VITE_API_KEY?: string } }).env?.VITE_API_KEY;
} catch {
return undefined;
}
}
export const kyInstance: KyInstance = ky.create({ export const kyInstance: KyInstance = ky.create({
timeout: 10_000, timeout: 10_000,
@ -27,7 +18,7 @@ export const kyInstance: KyInstance = ky.create({
hooks: { hooks: {
beforeRequest: [ beforeRequest: [
(request) => { (request) => {
const apiKey = getApiKey(); const apiKey = import.meta.env.VITE_API_KEY;
if (apiKey) request.headers.set('x-api-key', apiKey); if (apiKey) request.headers.set('x-api-key', apiKey);
}, },
// TODO: 인증 토큰 주입 // TODO: 인증 토큰 주입
@ -46,7 +37,10 @@ export const customFetcher = async <T>(
url: string, url: string,
init?: RequestInit, init?: RequestInit,
): Promise<T> => { ): Promise<T> => {
const response = await kyInstance(url, init as Parameters<KyInstance>[1]) // orval 이 생성하는 url 은 `/api/...` 상대경로라서, 그대로 두면 dev 서버 origin 으로 감.
// VITE_API_BASE_URL 이 있으면 절대 URL 로 만들어서 백엔드로 직접 요청.
const fullUrl = /^https?:\/\//.test(url) ? url : `${API_BASE_URL}${url}`
const response = await kyInstance(fullUrl, init as Parameters<KyInstance>[1])
let data: unknown = null let data: unknown = null
try { try {
data = await response.json() data = await response.json()

View File

@ -39,26 +39,12 @@ export default function Navbar() {
<a href="/#home" className="hover:text-primary-900 transition-colors"> <a href="/#home" className="hover:text-primary-900 transition-colors">
Product Product
</a> </a>
<a href="/#audience" className="hover:text-primary-900 transition-colors">
Audience
</a>
<a href="/#problems" className="hover:text-primary-900 transition-colors">
Problems
</a>
<a href="/#solution" className="hover:text-primary-900 transition-colors">
Solution
</a>
<a href="/#modules" className="hover:text-primary-900 transition-colors">
Modules
</a>
<a href="/#use-cases" className="hover:text-primary-900 transition-colors"> <a href="/#use-cases" className="hover:text-primary-900 transition-colors">
Use Cases Use Cases
</a> </a>
{/* Pricing
<Link to="/pricing?from=header" className="hover:text-primary-900 transition-colors"> <Link to="/pricing?from=header" className="hover:text-primary-900 transition-colors">
Pricing Pricing
</Link> </Link>
*/}
</div> </div>
{/* 우측 CTA — Login(Secondary) + 문의하기(Primary) */} {/* 우측 CTA — Login(Secondary) + 문의하기(Primary) */}

View File

@ -4,3 +4,7 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@ -1,17 +1,18 @@
import path from 'node:path' import path from 'node:path'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
export default defineConfig(() => {
// Docker on macOS/Windows에서 파일 변경 감지를 위해 polling 필요 // Docker on macOS/Windows에서 파일 변경 감지를 위해 polling 필요
// CHOKIDAR_USEPOLLING=true 환경변수가 있을 때만 켬 (로컬 dev에선 비활성) // CHOKIDAR_USEPOLLING=true 환경변수가 있을 때만 켬 (로컬 dev에선 비활성)
const usePolling = process.env.CHOKIDAR_USEPOLLING === 'true' const usePolling = process.env.CHOKIDAR_USEPOLLING === 'true'
// Docker 안에서 dev 서버가 돌면 호스트의 localhost는 host.docker.internal로 접근해야 함
// VITE_API_TARGET 환경변수로 오버라이드 가능
const apiTarget = const apiTarget =
process.env.VITE_API_TARGET ?? env.VITE_API_TARGET ||
env.VITE_API_BASE_URL ||
(usePolling ? 'http://host.docker.internal:8001' : 'http://localhost:8001') (usePolling ? 'http://host.docker.internal:8001' : 'http://localhost:8001')
return { return {