From f9027f5365f6812c4cda676fdc13b171987baf38 Mon Sep 17 00:00:00 2001 From: claude-owner Date: Mon, 1 Jun 2026 10:26:33 +0900 Subject: [PATCH] feat(sidebar): collapsible AlertsPanel / InvestmentNote / RelatedStocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three lower-sidebar panels are now wrapped in a shared Collapsible so mobile readers can fold away sections they're not actively using. New shared pieces: - lib/collapsible.ts — readOpen/writeOpen helpers backed by localStorage under 'panel-open:'. Per-panel-name (not per-code) because the preference is a panel-level habit; users don't want different fold state for every symbol. - components/Collapsible.tsx — button-as-header + chevron + body. aria-expanded for AT. Body uses 'hidden' instead of conditional unmount so textarea text, partial alert form input, and RelatedStocks fetch results all survive collapse cycles. SSR-safe: useState(defaultOpen) then useEffect-sync to avoid React hydration mismatch when localStorage diverges from default. Panel refactors: - RelatedStocks: outer rounded div + header replaced with Collapsible; loading/error/empty/data states all rendered inside body (header stays visible so user can still toggle/recognize the section even while loading). - InvestmentNote: same swap. saved.updatedAt drives the subtitle. - AlertsPanel: same swap. current price drives the subtitle. Defaults are open=true everywhere; the surface area is the toggle itself, not a forced collapse. User's first interaction sets the sticky preference. --- web/components/AlertsPanel.tsx | 15 ++--- web/components/Collapsible.tsx | 79 ++++++++++++++++++++++ web/components/InvestmentNote.tsx | 14 ++-- web/components/RelatedStocks.tsx | 108 ++++++++++++++---------------- web/lib/collapsible.ts | 29 ++++++++ 5 files changed, 173 insertions(+), 72 deletions(-) create mode 100644 web/components/Collapsible.tsx create mode 100644 web/lib/collapsible.ts 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) : "비공개 · 브라우저 저장"} - -
+