Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 34 additions & 31 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,24 @@
"use client";

import { useRef, useState } from "react";
import PrimerResultModal from "@/components/PrimerResultModal";
import {
analyzeGenome,
type AnalyzeRequestInput,
} from "@/services/analysisService";
import type { GenomeData, PrimerDesignResponseUI } from "@/types";
import type { GenomeData } from "@/types";
import { useViewStore } from "@/store/useViewStore";
import {
getInvalidStep1TemplateSequenceChar,
normalizeStep1TemplateSequence,
} from "../src/lib/parsers/step1TemplateSequence";
import { savePrimerResultToStorage } from "@/lib/storage/primerResultStorage";
import Step1TemplateEssential from "@/components/steps/Step1TemplateEssential";
import Step2PrimerProperties from "@/components/steps/Step2PrimerProperties";
import Step3BindingLocation from "@/components/steps/Step3BindingLocation";
import Step4SpecificityPreview from "@/components/steps/Step4SpecificityPreview";
import WizardFooterNav from "@/components/ui/WizardFooterNav";
import WizardHeader from "@/components/ui/WizardHeader";

const toGenomeDataFromResponse = (response: PrimerDesignResponseUI | null): GenomeData | null => {
if (!response?.genome) return null;
const length =
response.genome.length ??
response.genome.length_bp ??
response.genome.sequence?.length ??
0;
const tracks = response.genome.tracks ?? [];
return { length, tracks };
};

const DEFAULT_PREVIEW_GENOME: GenomeData = {
length: 12000,
tracks: [
Expand All @@ -47,7 +36,7 @@ const DEFAULT_PREVIEW_GENOME: GenomeData = {
id: "preview-target-region",
name: "Target 구간",
height: 18,
features: [{ id: "amplicon", start: 1500, end: 5200, label: "Amplicon", color: "#f97316" }],
features: [{ id: "template", start: 1500, end: 5200, label: "Template", color: "#f97316" }],
},
],
};
Expand All @@ -68,7 +57,6 @@ export default function Home() {
setViewState({ ...viewState, scale: clampScale(viewState.scale / zoomStep) });

const sequenceInputRef = useRef("");
const [resultGenome, setResultGenome] = useState<GenomeData | null>(null);

const steps = [
{ id: 1, label: "Template & Essential" },
Expand All @@ -79,10 +67,9 @@ export default function Home() {

const [step, setStep] = useState<1 | 2 | 3 | 4>(1);
const [isLoading, setIsLoading] = useState(false);
const [apiResult, setApiResult] = useState<PrimerDesignResponseUI | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [step1WarningMessage, setStep1WarningMessage] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [restrictionEnzymes, setRestrictionEnzymes] = useState<string[]>([]);
const totalSteps = steps.length;
const handleStepChange = (next: number) => {
const clamped = Math.min(Math.max(next, 1), totalSteps) as 1 | 2 | 3 | 4;
Expand Down Expand Up @@ -180,24 +167,41 @@ export default function Home() {
end_mismatch_region_size: 5,
end_mismatch_min_mismatch: 1,
search_start: 1,
restriction_enzymes: restrictionEnzymes,
};

let resultTab: Window | null = null;
if (typeof window !== "undefined") {
resultTab = window.open("/result", "_blank");
if (!resultTab) {
setErrorMessage(
"브라우저에서 새 탭 열기를 차단했습니다. 팝업 차단 해제 후 다시 시도해 주세요.",
);
return;
}
}

setIsLoading(true);
setErrorMessage(null);

try {
const result = await analyzeGenome(payload);
setApiResult(result);
const genomeFromApi = toGenomeDataFromResponse(result);
setResultGenome(genomeFromApi ?? null);
setIsModalOpen(true);
const resultKey = savePrimerResultToStorage(result);
const resultUrl = `/result?resultKey=${encodeURIComponent(resultKey)}`;

if (resultTab && !resultTab.closed) {
resultTab.location.href = resultUrl;
resultTab.focus();
} else if (typeof window !== "undefined") {
window.open(resultUrl, "_blank");
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to generate primers.";
setErrorMessage(message);
setApiResult(null);
setResultGenome(null);
setIsModalOpen(false);
if (resultTab && !resultTab.closed) {
resultTab.close();
}
// Surface the error for visibility during development.
console.error("Generate Primers failed", error);
} finally {
Expand Down Expand Up @@ -233,7 +237,12 @@ export default function Home() {

{step === 2 && <Step2PrimerProperties />}

{step === 3 && <Step3BindingLocation />}
{step === 3 && (
<Step3BindingLocation
restrictionEnzymes={restrictionEnzymes}
onRestrictionEnzymesChange={setRestrictionEnzymes}
/>
)}

{step === 4 && (
<Step4SpecificityPreview
Expand Down Expand Up @@ -261,12 +270,6 @@ export default function Home() {
)}
</main>

<PrimerResultModal
isOpen={isModalOpen}
apiResult={apiResult}
genome={resultGenome}
onClose={() => setIsModalOpen(false)}
/>
</div>
);
}
108 changes: 108 additions & 0 deletions app/result/ResultClientPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"use client";

import { useMemo } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import PrimerResultModal from "@/components/PrimerResultModal";
import { loadPrimerResultFromStorage } from "@/lib/storage/primerResultStorage";
import type { GenomeData, PrimerDesignResponseUI } from "@/types";

const toGenomeDataFromResponse = (
response: PrimerDesignResponseUI | null,
): GenomeData | null => {
if (!response?.genome) return null;

const length =
response.genome.length ??
response.genome.length_bp ??
response.genome.sequence?.length ??
0;

return {
length,
tracks: response.genome.tracks ?? [],
};
};

export default function ResultClientPage() {
const router = useRouter();
const searchParams = useSearchParams();
const resultKey = searchParams.get("resultKey");

const { apiResult, errorMessage } = useMemo(() => {
if (!resultKey) {
return {
apiResult: null as PrimerDesignResponseUI | null,
errorMessage: null as string | null,
};
}

const restoredResult = loadPrimerResultFromStorage(resultKey);
if (!restoredResult) {
return {
apiResult: null as PrimerDesignResponseUI | null,
errorMessage: "결과 데이터를 찾을 수 없습니다. 메인 페이지에서 다시 실행해 주세요.",
};
}

return {
apiResult: restoredResult,
errorMessage: null as string | null,
};
}, [resultKey]);

const resultGenome = useMemo(
() => toGenomeDataFromResponse(apiResult),
[apiResult],
);

const closeTab = () => {
window.close();
router.push("/");
};

return (
<div className="relative min-h-screen overflow-hidden bg-[#070d18] text-slate-100">
{!resultKey && (
<main className="relative mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-6 py-10 text-center">
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-900/80 p-8">
<p className="text-sm uppercase tracking-[0.18em] text-slate-400">
Primer Design
</p>
<h1 className="text-2xl font-semibold text-white">
결과를 준비 중입니다.
</h1>
<p className="text-sm text-slate-300">
메인 탭에서 분석이 완료되면 이 페이지가 자동으로 업데이트됩니다.
</p>
</div>
</main>
)}

{resultKey && errorMessage && (
<main className="relative mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-6 py-10 text-center">
<div className="space-y-4 rounded-2xl border border-red-500/40 bg-slate-900/80 p-8">
<h1 className="text-2xl font-semibold text-white">결과 로딩 실패</h1>
<p className="text-sm text-red-200">{errorMessage}</p>
<button
type="button"
onClick={() => router.push("/")}
className="rounded-full border border-slate-700 bg-slate-800 px-4 py-2 text-sm font-semibold text-slate-100 transition hover:border-blue-500 hover:text-white"
>
메인으로 이동
</button>
</div>
</main>
)}

{resultKey && apiResult && resultGenome && (
<main className="relative mx-auto w-full px-6 py-10 lg:px-10">
<PrimerResultModal
apiResult={apiResult}
genome={resultGenome}
onClose={closeTab}
/>
</main>
)}
</div>
);
}
11 changes: 11 additions & 0 deletions app/result/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"use client";

import dynamic from "next/dynamic";

const ResultClientPage = dynamic(() => import("./ResultClientPage"), {
ssr: false,
});

export default function ResultPage() {
return <ResultClientPage />;
}
Loading