diff --git a/app/page.tsx b/app/page.tsx index 22158e1..9d5d800 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,17 +1,17 @@ "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"; @@ -19,17 +19,6 @@ 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: [ @@ -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" }], }, ], }; @@ -68,7 +57,6 @@ export default function Home() { setViewState({ ...viewState, scale: clampScale(viewState.scale / zoomStep) }); const sequenceInputRef = useRef(""); - const [resultGenome, setResultGenome] = useState(null); const steps = [ { id: 1, label: "Template & Essential" }, @@ -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(null); const [errorMessage, setErrorMessage] = useState(null); const [step1WarningMessage, setStep1WarningMessage] = useState(null); - const [isModalOpen, setIsModalOpen] = useState(false); + const [restrictionEnzymes, setRestrictionEnzymes] = useState([]); const totalSteps = steps.length; const handleStepChange = (next: number) => { const clamped = Math.min(Math.max(next, 1), totalSteps) as 1 | 2 | 3 | 4; @@ -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 { @@ -233,7 +237,12 @@ export default function Home() { {step === 2 && } - {step === 3 && } + {step === 3 && ( + + )} {step === 4 && ( - setIsModalOpen(false)} - /> ); } diff --git a/app/result/ResultClientPage.tsx b/app/result/ResultClientPage.tsx new file mode 100644 index 0000000..cff6d14 --- /dev/null +++ b/app/result/ResultClientPage.tsx @@ -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 ( +
+ {!resultKey && ( +
+
+

+ Primer Design +

+

+ 결과를 준비 중입니다. +

+

+ 메인 탭에서 분석이 완료되면 이 페이지가 자동으로 업데이트됩니다. +

+
+
+ )} + + {resultKey && errorMessage && ( +
+
+

결과 로딩 실패

+

{errorMessage}

+ +
+
+ )} + + {resultKey && apiResult && resultGenome && ( +
+ +
+ )} +
+ ); +} diff --git a/app/result/page.tsx b/app/result/page.tsx new file mode 100644 index 0000000..f590c46 --- /dev/null +++ b/app/result/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const ResultClientPage = dynamic(() => import("./ResultClientPage"), { + ssr: false, +}); + +export default function ResultPage() { + return ; +} diff --git a/components/PrimerResultModal.tsx b/components/PrimerResultModal.tsx index 2a96264..2eec93b 100644 --- a/components/PrimerResultModal.tsx +++ b/components/PrimerResultModal.tsx @@ -10,7 +10,6 @@ import type { } from "@/types"; interface PrimerResultModalProps { - isOpen: boolean; apiResult: PrimerDesignResponseUI | null; genome: GenomeData | null; onClose: () => void; @@ -55,71 +54,72 @@ function PrimerResultCanvasPanel({ const handleReset = () => setViewState(initialViewState); return ( -
-
-
+
+
+

Primer Design Results

-

Primer Design Results

+

Primer Design Results

+
-
-
-
- - Zoom {viewState.scale.toFixed(2)}x - - - - -
-
- Result: {apiResult?.result} | Score: {apiResult?.score} -
+
+
+
+ + Zoom {viewState.scale.toFixed(2)}x + + + + +
+
+ Result: {apiResult?.result} | Score: {apiResult?.score}
+
-
-
- { +
+
+ { const { data, viewport, viewState: canvasViewState } = renderState; if (!data) return; @@ -262,12 +262,11 @@ function PrimerResultCanvasPanel({ y += trackHeight + trackGap; }); - }} - /> -
+ }} + />
-
+
); } @@ -278,47 +277,15 @@ const createInitialViewState = (): GenomeCanvasViewState => ({ }); export default function PrimerResultModal({ - isOpen, apiResult, genome, onClose, }: PrimerResultModalProps) { const canvasShellRef = useRef(null); - const [canvasWidth, setCanvasWidth] = useState(() => - typeof window === "undefined" ? 960 : Math.max(320, Math.floor(window.innerWidth * 0.72)), - ); - - useEffect(() => { - if (!isOpen) return; - - const { body, documentElement } = document; - const previousBodyOverflow = body.style.overflow; - const previousBodyPaddingRight = body.style.paddingRight; - const previousBodyOverscrollBehavior = body.style.overscrollBehavior; - const previousHtmlOverflow = documentElement.style.overflow; - const previousHtmlOverscrollBehavior = documentElement.style.overscrollBehavior; - - const scrollbarWidth = window.innerWidth - documentElement.clientWidth; - body.style.overflow = "hidden"; - body.style.overscrollBehavior = "none"; - documentElement.style.overflow = "hidden"; - documentElement.style.overscrollBehavior = "none"; - - if (scrollbarWidth > 0) { - body.style.paddingRight = `${scrollbarWidth}px`; - } - - return () => { - body.style.overflow = previousBodyOverflow; - body.style.paddingRight = previousBodyPaddingRight; - body.style.overscrollBehavior = previousBodyOverscrollBehavior; - documentElement.style.overflow = previousHtmlOverflow; - documentElement.style.overscrollBehavior = previousHtmlOverscrollBehavior; - }; - }, [isOpen]); + const [canvasWidth, setCanvasWidth] = useState(960); useEffect(() => { - if (!isOpen) return; + if (!genome) return; const element = canvasShellRef.current; if (!element) return; @@ -338,7 +305,7 @@ export default function PrimerResultModal({ return () => { resizeObserver.disconnect(); }; - }, [isOpen]); + }, [genome]); const fittedInitialViewState = useMemo(() => { if (!genome) return createInitialViewState(); @@ -352,7 +319,7 @@ export default function PrimerResultModal({ }); }, [canvasWidth, genome]); - if (!isOpen || !genome) return null; + if (!genome) return null; const featureCount = genome.tracks.reduce( (count, track) => count + (Array.isArray(track.features) ? track.features.length : 0), diff --git a/components/steps/Step3BindingLocation.tsx b/components/steps/Step3BindingLocation.tsx index 8a46959..74ad9f9 100644 --- a/components/steps/Step3BindingLocation.tsx +++ b/components/steps/Step3BindingLocation.tsx @@ -1,8 +1,66 @@ "use client"; -import { MapPin } from "lucide-react"; +import { MapPin, X } from "lucide-react"; +import { useState, type KeyboardEvent } from "react"; + +type Step3BindingLocationProps = { + restrictionEnzymes: string[]; + onRestrictionEnzymesChange: (next: string[]) => void; +}; + +const normalizeEnzymeName = (value: string) => value.trim().replace(/\s+/g, " "); + +export default function Step3BindingLocation({ + restrictionEnzymes, + onRestrictionEnzymesChange, +}: Step3BindingLocationProps) { + const [enzymeInput, setEnzymeInput] = useState(""); + + const addRestrictionEnzyme = (rawValue: string) => { + const normalized = normalizeEnzymeName(rawValue); + if (!normalized) { + return false; + } + + const hasDuplicate = restrictionEnzymes.some( + (enzyme) => enzyme.toLowerCase() === normalized.toLowerCase(), + ); + if (hasDuplicate) { + return false; + } + + onRestrictionEnzymesChange([...restrictionEnzymes, normalized]); + return true; + }; + + const removeRestrictionEnzyme = (enzymeToRemove: string) => { + onRestrictionEnzymesChange( + restrictionEnzymes.filter((enzyme) => enzyme !== enzymeToRemove), + ); + }; + + const handleEnzymeInputKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === ",") { + event.preventDefault(); + if (addRestrictionEnzyme(enzymeInput)) { + setEnzymeInput(""); + } + return; + } + + if (event.key === "Backspace" && !enzymeInput && restrictionEnzymes.length > 0) { + event.preventDefault(); + const next = restrictionEnzymes.slice(0, -1); + onRestrictionEnzymesChange(next); + } + }; + + const handleEnzymeInputBlur = () => { + if (addRestrictionEnzyme(enzymeInput)) { + setEnzymeInput(""); + } + }; -export default function Step3BindingLocation() { return (
@@ -93,18 +151,26 @@ export default function Step3BindingLocation() { Restriction Enzymes
- {["EcoRI", "BamHI", "HindIII"].map((site) => ( - ( + ))} setEnzymeInput(event.target.value)} + onKeyDown={handleEnzymeInputKeyDown} + onBlur={handleEnzymeInputBlur} />
diff --git a/src/lib/algorithms/focusViewState.ts b/src/lib/algorithms/focusViewState.ts index b407812..3e118bc 100644 --- a/src/lib/algorithms/focusViewState.ts +++ b/src/lib/algorithms/focusViewState.ts @@ -28,6 +28,7 @@ const isPrimerRelatedTrack = (trackIdOrName: string) => { const normalized = trackIdOrName.toLowerCase(); return ( normalized.includes("primer") || + normalized.includes("template") || normalized.includes("amplicon") || normalized.includes("target") ); @@ -37,6 +38,7 @@ const isPrimerRelatedFeature = (feature: GenomeFeature) => { const label = String(feature.label ?? feature.name ?? feature.id ?? "").toLowerCase(); return ( label.includes("primer") || + label.includes("template") || label.includes("amplicon") || label.includes("target") ); diff --git a/src/lib/storage/primerResultStorage.ts b/src/lib/storage/primerResultStorage.ts new file mode 100644 index 0000000..d8f0122 --- /dev/null +++ b/src/lib/storage/primerResultStorage.ts @@ -0,0 +1,49 @@ +import type { PrimerDesignResponseUI } from "@/types"; + +type PrimerResultStorageEnvelope = { + createdAt: string; + apiResult: PrimerDesignResponseUI; +}; + +const PRIMER_RESULT_STORAGE_PREFIX = "primerflow:result:"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const createStorageKey = () => + `${PRIMER_RESULT_STORAGE_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + +export const savePrimerResultToStorage = (apiResult: PrimerDesignResponseUI): string => { + if (typeof window === "undefined") { + throw new Error("Primer result can only be stored in a browser environment."); + } + + const key = createStorageKey(); + const payload: PrimerResultStorageEnvelope = { + createdAt: new Date().toISOString(), + apiResult, + }; + window.localStorage.setItem(key, JSON.stringify(payload)); + return key; +}; + +export const loadPrimerResultFromStorage = ( + key: string, +): PrimerDesignResponseUI | null => { + if (typeof window === "undefined") return null; + + const rawValue = window.localStorage.getItem(key); + if (!rawValue) return null; + + try { + const parsedValue: unknown = JSON.parse(rawValue); + if (!isRecord(parsedValue)) return null; + + const apiResult = parsedValue.apiResult; + if (!isRecord(apiResult)) return null; + + return apiResult as unknown as PrimerDesignResponseUI; + } catch { + return null; + } +}; diff --git a/src/services/analysisService.ts b/src/services/analysisService.ts index 11b2cbd..381a8cb 100644 --- a/src/services/analysisService.ts +++ b/src/services/analysisService.ts @@ -202,18 +202,18 @@ const toUiResponse = (raw: PrimerDesignResponse): PrimerDesignResponseUI => { const starts = uiCandidates.map((c) => c.start ?? 0).filter((n) => Number.isFinite(n)); const ends = uiCandidates.map((c) => c.end ?? 0).filter((n) => Number.isFinite(n)); - const ampTrack = + const templateTrack = starts.length && ends.length ? [ { - id: "amplicon", - name: "Amplicon", + id: "template", + name: "Template", features: [ { - id: "amplicon-1", + id: "template-1", start: Math.min(...starts), end: Math.max(...ends), - label: "Amplicon", + label: "Template", color: "#f97316", }, ], @@ -224,7 +224,7 @@ const toUiResponse = (raw: PrimerDesignResponse): PrimerDesignResponseUI => { const uiGenome: UIGenome = { ...genome, length_bp: genome.length_bp ?? length, - tracks: [...baseTracks, ...ampTrack, ...primerTrack], + tracks: [...baseTracks, ...templateTrack, ...primerTrack], }; return {