feat(sidebar): collapsible AlertsPanel / InvestmentNote / RelatedStocks
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:<name>'. 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.
This commit is contained in:
@@ -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<RelatedResponse | null>(null);
|
||||
@@ -28,64 +30,58 @@ export function RelatedStocks({ code }: { code: string }) {
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
if (err)
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
관련 종목 로딩 실패: {err}
|
||||
</div>
|
||||
);
|
||||
if (!data)
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
관련 종목 로딩 중…
|
||||
</div>
|
||||
);
|
||||
if (!data.items.length)
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
같은 시장({data.market}) 데이터가 아직 부족합니다.
|
||||
</div>
|
||||
);
|
||||
const subtitle = data?.market ? `${data.market} · 거래량 기준` : undefined;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<div className="mb-2 flex items-baseline justify-between">
|
||||
<div className="text-sm font-medium text-zinc-200">관련 종목</div>
|
||||
<div className="text-[11px] text-zinc-500">{data.market} · 거래량 기준</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{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 (
|
||||
<li key={m.code}>
|
||||
<Link
|
||||
href={`/${m.code}`}
|
||||
className="flex items-center justify-between gap-2 py-1.5 text-xs hover:bg-zinc-800/40"
|
||||
>
|
||||
<span className="min-w-0 truncate text-zinc-100">{m.name}</span>
|
||||
<span className="shrink-0 text-right tabular-nums">
|
||||
<span className="text-zinc-100">
|
||||
{m.close != null
|
||||
? m.close.toLocaleString(undefined, { maximumFractionDigits: 0 })
|
||||
: "—"}
|
||||
<Collapsible title="관련 종목" subtitle={subtitle} storageKey="related">
|
||||
{err && (
|
||||
<div className="text-xs text-zinc-500">관련 종목 로딩 실패: {err}</div>
|
||||
)}
|
||||
{!err && !data && (
|
||||
<div className="text-xs text-zinc-500">관련 종목 로딩 중…</div>
|
||||
)}
|
||||
{!err && data && !data.items.length && (
|
||||
<div className="text-xs text-zinc-500">
|
||||
같은 시장({data.market}) 데이터가 아직 부족합니다.
|
||||
</div>
|
||||
)}
|
||||
{!err && data && data.items.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{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 (
|
||||
<li key={m.code}>
|
||||
<Link
|
||||
href={`/${m.code}`}
|
||||
className="flex items-center justify-between gap-2 py-1.5 text-xs hover:bg-zinc-800/40"
|
||||
>
|
||||
<span className="min-w-0 truncate text-zinc-100">{m.name}</span>
|
||||
<span className="shrink-0 text-right tabular-nums">
|
||||
<span className="text-zinc-100">
|
||||
{m.close != null
|
||||
? m.close.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
: "—"}
|
||||
</span>
|
||||
<span className={`ml-2 ${tone}`}>
|
||||
{pct != null
|
||||
? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`ml-2 ${tone}`}>
|
||||
{pct != null
|
||||
? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user