diff --git a/web/components/AlertsPanel.tsx b/web/components/AlertsPanel.tsx index 7b5977a..f4b6e18 100644 --- a/web/components/AlertsPanel.tsx +++ b/web/components/AlertsPanel.tsx @@ -3,9 +3,11 @@ // 종목 페이지의 가격 알람 패널. // 현재 종목의 활성 알람 리스트 + 새 알람 추가 폼 (≥ / ≤ + 가격). // 발사 자체는 AlertsToaster 가 글로벌로 처리. 여기는 입력/표시만. +// Collapsible 본문은 hidden 으로만 가리므로 접어도 op/target partial 입력 유지. import { useEffect, useState } from "react"; import { alerts, type Alert, type AlertOp } from "../lib/alerts"; +import { Collapsible } from "./Collapsible"; type Props = { code: string; @@ -32,14 +34,11 @@ export function AlertsPanel({ code, name, current }: Props) { setTarget(""); }; + const subtitle = + current != null ? `현재가 ${current.toLocaleString()}원` : undefined; + return ( -
-
-
가격 알람
-
- {current != null ? `현재가 ${current.toLocaleString()}원` : ""} -
-
+
+ ); } diff --git a/web/components/Collapsible.tsx b/web/components/Collapsible.tsx new file mode 100644 index 0000000..4f79522 --- /dev/null +++ b/web/components/Collapsible.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { readOpen, writeOpen } from "../lib/collapsible"; + +// 사이드바 패널의 공용 collapsible 래퍼. +// - 헤더 row 가 button 으로 동작 → 클릭 시 본문 토글, aria-expanded 로 보조기술 알림. +// - 사용자 선택은 localStorage 에 panel-open: 로 영속. +// - 본문은 `hidden` 으로 숨겨 unmount 시키지 않음 → InvestmentNote 입력 텍스트, +// AlertsPanel 폼 partial 입력, RelatedStocks fetch 결과 등이 토글 사이클 동안 +// 보존. (조건부 렌더로 children 을 끄면 re-mount → 데이터 재요청/입력 손실.) +// +// SSR 안전: +// 초기 state 를 readOpen() 으로 lazy 초기화하면 서버는 window 가 없어 defaultOpen 을 +// 반환하고, 클라이언트 hydration 첫 렌더에서 localStorage 값과 다르면 React 가 +// hydration mismatch 를 경고함. useState(defaultOpen) → useEffect 에서 sync 패턴으로 +// 해결 — 1프레임 미세 jank 는 감수 (localStorage 값이 default 와 다를 때만, 사이드바 +// 패널이라 시선 끄는 위치도 아님). + +type Props = { + title: ReactNode; + subtitle?: ReactNode; + storageKey: string; + defaultOpen?: boolean; + children: ReactNode; + className?: string; +}; + +export function Collapsible({ + title, + subtitle, + storageKey, + defaultOpen = true, + children, + className = "rounded-md border border-zinc-800 bg-zinc-900/40", +}: Props) { + const [open, setOpen] = useState(defaultOpen); + + useEffect(() => { + setOpen(readOpen(storageKey, defaultOpen)); + }, [storageKey, defaultOpen]); + + const toggle = () => { + setOpen((cur) => { + const next = !cur; + writeOpen(storageKey, next); + return next; + }); + }; + + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/web/components/InvestmentNote.tsx b/web/components/InvestmentNote.tsx index aa5259d..0766e2f 100644 --- a/web/components/InvestmentNote.tsx +++ b/web/components/InvestmentNote.tsx @@ -3,9 +3,11 @@ // 종목 페이지의 사이드 메모 패널. // Toss "투자 메모" 패턴 — 진입 이유, 관전 포인트를 짧게 적는 자리. // localStorage 만 사용, 서버 전송 없음. +// Collapsible 본문은 hidden 으로만 가리므로 접어도 textarea dirty 텍스트 유지. import { useEffect, useMemo, useState } from "react"; import { NOTE_MAX_LEN, readNote, writeNote, type Note } from "../lib/notes"; +import { Collapsible } from "./Collapsible"; const FEEDBACK_TEXT: Record<"saved" | "deleted" | "failed", string> = { saved: "저장됨", @@ -78,14 +80,10 @@ export function InvestmentNote({ code }: Props) { setFeedback(FEEDBACK_TEXT[res.status]); }; + const subtitle = saved ? fmtUpdatedAt(saved.updatedAt) : "비공개 · 브라우저 저장"; + return ( -
-
-

투자 메모

- - {saved ? fmtUpdatedAt(saved.updatedAt) : "비공개 · 브라우저 저장"} - -
+