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) : "비공개 · 브라우저 저장"}
-
-
+
-
+
);
}
diff --git a/web/components/RelatedStocks.tsx b/web/components/RelatedStocks.tsx
index 46e69a5..082be69 100644
--- a/web/components/RelatedStocks.tsx
+++ b/web/components/RelatedStocks.tsx
@@ -2,10 +2,12 @@
// 같은 시장 내 거래량/유동성 기준 관련 종목 8개.
// /api/markets/related/{code} 응답을 좁은 list 카드로 렌더.
+// Collapsible 로 감싸 — 모바일에서 사이드바 길이가 부담되면 사용자가 접어 둠.
import Link from "next/link";
import { useEffect, useState } from "react";
import { api, type RelatedResponse } from "../lib/api";
+import { Collapsible } from "./Collapsible";
export function RelatedStocks({ code }: { code: string }) {
const [data, setData] = useState(null);
@@ -28,64 +30,58 @@ export function RelatedStocks({ code }: { code: string }) {
};
}, [code]);
- if (err)
- return (
-
- 관련 종목 로딩 실패: {err}
-
- );
- if (!data)
- return (
-
- 관련 종목 로딩 중…
-
- );
- if (!data.items.length)
- return (
-
- 같은 시장({data.market}) 데이터가 아직 부족합니다.
-
- );
+ const subtitle = data?.market ? `${data.market} · 거래량 기준` : undefined;
return (
-
-
-
관련 종목
-
{data.market} · 거래량 기준
-
-
- {data.items.map((m) => {
- const pct = m.pct_change;
- const tone =
- pct == null || pct === 0
- ? "text-zinc-400"
- : pct > 0
- ? "text-rose-400"
- : "text-sky-400";
- return (
- -
-
- {m.name}
-
-
- {m.close != null
- ? m.close.toLocaleString(undefined, { maximumFractionDigits: 0 })
- : "—"}
+
+ {err && (
+
관련 종목 로딩 실패: {err}
+ )}
+ {!err && !data && (
+ 관련 종목 로딩 중…
+ )}
+ {!err && data && !data.items.length && (
+
+ 같은 시장({data.market}) 데이터가 아직 부족합니다.
+
+ )}
+ {!err && data && data.items.length > 0 && (
+
+ {data.items.map((m) => {
+ const pct = m.pct_change;
+ const tone =
+ pct == null || pct === 0
+ ? "text-zinc-400"
+ : pct > 0
+ ? "text-rose-400"
+ : "text-sky-400";
+ return (
+ -
+
+ {m.name}
+
+
+ {m.close != null
+ ? m.close.toLocaleString(undefined, {
+ maximumFractionDigits: 0,
+ })
+ : "—"}
+
+
+ {pct != null
+ ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`
+ : ""}
+
-
- {pct != null
- ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`
- : ""}
-
-
-
-
- );
- })}
-
-
+
+
+ );
+ })}
+
+ )}
+
);
}
diff --git a/web/lib/collapsible.ts b/web/lib/collapsible.ts
new file mode 100644
index 0000000..189339f
--- /dev/null
+++ b/web/lib/collapsible.ts
@@ -0,0 +1,29 @@
+// 사이드바 collapsible 패널의 펼침/접힘 상태 영속 저장.
+// 키 형식: `panel-open:` — 종목별이 아닌 panel-name 단위. 사용자는 "관련 종목은 늘
+// 접어 둔다" 같은 panel 단위 습관이 있지 종목별로 다르게 설정하지 않음. 모든 종목 페이지에서
+// 같은 상태로 보이는 것이 직관적.
+//
+// 값 형식: '1' = open, '0' = closed. 다른 값은 default 로 폴백.
+// 쿼타/잠금 환경에선 silently fail — 그 세션 동안 default 상태 유지.
+
+const STORAGE_PREFIX = "panel-open:";
+
+export function readOpen(key: string, defaultOpen: boolean): boolean {
+ if (typeof window === "undefined") return defaultOpen;
+ try {
+ const v = window.localStorage.getItem(STORAGE_PREFIX + key);
+ if (v === null) return defaultOpen;
+ return v === "1";
+ } catch {
+ return defaultOpen;
+ }
+}
+
+export function writeOpen(key: string, open: boolean): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(STORAGE_PREFIX + key, open ? "1" : "0");
+ } catch {
+ // quota / private mode — 다음 페이지 진입엔 default 로 되돌아감.
+ }
+}