Files
stock_chart_site/web/components/ShareButton.tsx
claude-owner 1e0c917f68 feat(chart): CSV export of current chart range + fix ShareButton comment
- New lib/csv.ts: RFC 4180 escaping, UTF-8 BOM prefix (한글 Excel 호환),
  Blob + temporary anchor download, filename
  `<name>_<code>_<interval>_<from>_<to>.csv` with filesystem-illegal
  characters replaced by `_`.
- StockChart toolbar now always renders (was hidden in 10m intraday mode
  because it gated on showSma) and includes a right-aligned `⬇ CSV`
  button next to the SMA/RSI/MACD chips. Button is disabled when ohlcv
  is empty.

Also addresses the reviewer's non-blocking note on f18493f:
ShareButton.tsx header comment claimed clipboard-first, but the code
calls navigator.share() first. Comment now matches the actual order:
share → clipboard → execCommand → "복사 안됨".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:11:57 +09:00

102 lines
3.3 KiB
TypeScript

"use client";
// 종목 페이지 공유 버튼 — 현재 URL 을 공유 / 클립보드에 복사.
// Toss 의 종목 공유 패턴 (개별 모달 없이 즉시 시도 + 짧은 토스트).
//
// 동작 우선순위:
// 1) navigator.share (모바일 native sheet — 성공 시 OS 시트가 피드백, 토스트 안 띄움)
// 2) navigator.clipboard.writeText (HTTPS or localhost — 클립보드에 복사 + "링크 복사됨")
// 3) document.execCommand("copy") (legacy, http LAN 등 secure context 아닌 경우)
// 4) 모두 실패면 "복사 안됨" 안내
import { useEffect, useState } from "react";
type Props = {
code: string;
name?: string;
};
async function copyToClipboard(text: string): Promise<boolean> {
// Modern path — secure context 에서만 사용 가능.
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// 권한 거부 등 — fallback 시도.
}
}
// Legacy fallback. document.execCommand 는 deprecated 지만 비-HTTPS LAN 환경 호환용.
if (typeof document === "undefined") return false;
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
export function ShareButton({ code, name }: Props) {
const [feedback, setFeedback] = useState<string | null>(null);
useEffect(() => {
if (!feedback) return;
const h = window.setTimeout(() => setFeedback(null), 2000);
return () => window.clearTimeout(h);
}, [feedback]);
const onClick = async () => {
if (typeof window === "undefined") return;
const url = window.location.href;
// Web Share API 우선 — 모바일에서 native sheet 뜸. 데스크탑/실패 시 클립보드로 폴백.
const shareData = {
title: name ? `${name} (${code})` : code,
text: name ? `${name} 종목 정보` : code,
url,
};
if (
typeof navigator !== "undefined" &&
typeof navigator.share === "function"
) {
try {
await navigator.share(shareData);
return; // 사용자가 native sheet 에서 처리. 토스트 안 띄움.
} catch {
// AbortError(사용자 취소) 또는 미지원 — 클립보드로 폴백.
}
}
const ok = await copyToClipboard(url);
setFeedback(ok ? "링크 복사됨" : "복사 안됨");
};
return (
<div className="relative">
<button
type="button"
onClick={onClick}
className="rounded-md border border-zinc-700 px-2 py-0.5 text-[11px] text-zinc-400 transition hover:border-zinc-500 hover:text-zinc-200"
title="이 종목 페이지 공유"
aria-label="이 종목 페이지 공유"
>
</button>
{feedback && (
<span
aria-live="polite"
className="pointer-events-none absolute right-0 top-full z-20 mt-1 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-950/90 px-2 py-0.5 text-[10px] text-zinc-200 shadow"
>
{feedback}
</span>
)}
</div>
);
}