From f18493fad47e3a8d348399448e93e5698a7ca822 Mon Sep 17 00:00:00 2001 From: claude-owner Date: Fri, 29 May 2026 00:08:37 +0900 Subject: [PATCH] feat(share): symbol page share button + distinguish note delete vs failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New ShareButton in the page header tries navigator.share() first (native sheet on mobile), then falls back to navigator.clipboard, then to a hidden textarea + execCommand("copy") for non-secure-context LAN. Shows a brief "링크 복사됨" / "복사 안됨" toast; native share success shows none (the OS sheet is the feedback). - Mounted between ⇄ 비교 and the Star button. Fixes reviewer concern on 442cc38: writeNote() used to return null for both "user emptied the textarea (intentional delete)" and "localStorage quota failure," so clearing a note showed "저장 안됨." It now returns a discriminated { status: "saved" | "deleted" | "failed" } and the panel shows "저장됨" / "삭제됨" / "저장 안됨" accordingly. Failure keeps the user's text so they can retry or shorten it. Co-Authored-By: Claude Opus 4.7 --- web/app/[code]/page.tsx | 2 + web/components/InvestmentNote.tsx | 20 ++++-- web/components/ShareButton.tsx | 100 ++++++++++++++++++++++++++++++ web/lib/notes.ts | 17 +++-- 4 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 web/components/ShareButton.tsx diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index a2b8dbb..3a8290d 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -17,6 +17,7 @@ import { PredictionPanel } from "../../components/PredictionPanel"; import { PriceHero } from "../../components/PriceHero"; import { PriceTargets } from "../../components/PriceTargets"; import { RelatedStocks } from "../../components/RelatedStocks"; +import { ShareButton } from "../../components/ShareButton"; import { StarButton } from "../../components/StarButton"; import { StockChart } from "../../components/StockChart"; import { SymbolSidebar } from "../../components/SymbolSidebar"; @@ -163,6 +164,7 @@ export default function CodePage({ params }: { params: { code: string } }) { > ⇄ 비교 + setPeriod(id)} /> diff --git a/web/components/InvestmentNote.tsx b/web/components/InvestmentNote.tsx index a3ee5bd..aa5259d 100644 --- a/web/components/InvestmentNote.tsx +++ b/web/components/InvestmentNote.tsx @@ -7,6 +7,12 @@ import { useEffect, useMemo, useState } from "react"; import { NOTE_MAX_LEN, readNote, writeNote, type Note } from "../lib/notes"; +const FEEDBACK_TEXT: Record<"saved" | "deleted" | "failed", string> = { + saved: "저장됨", + deleted: "삭제됨", + failed: "저장 안됨", +}; + type Props = { code: string; }; @@ -60,10 +66,16 @@ export function InvestmentNote({ code }: Props) { const remaining = useMemo(() => NOTE_MAX_LEN - text.length, [text]); const save = () => { - const next = writeNote(code, text); - setSaved(next); - setText(next?.text ?? ""); - setFeedback(next ? "저장됨" : "저장 안됨"); + const res = writeNote(code, text); + if (res.status === "saved") { + setSaved(res.note); + setText(res.note.text); + } else if (res.status === "deleted") { + setSaved(null); + setText(""); + } + // failed: 입력값 유지 — 사용자가 재시도하거나 일부를 줄일 수 있도록. + setFeedback(FEEDBACK_TEXT[res.status]); }; return ( diff --git a/web/components/ShareButton.tsx b/web/components/ShareButton.tsx new file mode 100644 index 0000000..c572666 --- /dev/null +++ b/web/components/ShareButton.tsx @@ -0,0 +1,100 @@ +"use client"; + +// 종목 페이지 공유 버튼 — 현재 URL 을 클립보드에 복사. +// Toss 의 종목 공유 패턴 (개별 모달 없이 즉시 복사 + 짧은 토스트). +// +// 폴백 순서: +// 1) navigator.clipboard.writeText (HTTPS or localhost) +// 2) document.execCommand("copy") (legacy, http LAN 등 secure context 아닌 경우) +// 3) 두 경로 모두 실패면 "복사 안됨" 안내 + +import { useEffect, useState } from "react"; + +type Props = { + code: string; + name?: string; +}; + +async function copyToClipboard(text: string): Promise { + // 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(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 ( +
+ + {feedback && ( + + {feedback} + + )} +
+ ); +} diff --git a/web/lib/notes.ts b/web/lib/notes.ts index 5f6b5e6..c2e6b09 100644 --- a/web/lib/notes.ts +++ b/web/lib/notes.ts @@ -24,20 +24,25 @@ export function readNote(code: string): Note | null { } } -export function writeNote(code: string, text: string): Note | null { - if (typeof window === "undefined") return null; +export type WriteResult = + | { status: "saved"; note: Note } + | { status: "deleted" } + | { status: "failed" }; + +export function writeNote(code: string, text: string): WriteResult { + if (typeof window === "undefined") return { status: "failed" }; const trimmed = text.slice(0, MAX_LEN); - // 공백만이면 키 자체 제거 → 빈 잔여물 방지. + // 공백만이면 키 자체 제거 → 빈 잔여물 방지. 의도된 삭제이므로 failed 와 구분. if (!trimmed.trim()) { clearNote(code); - return null; + return { status: "deleted" }; } const next: Note = { text: trimmed, updatedAt: new Date().toISOString() }; try { window.localStorage.setItem(`${PREFIX}${code}`, JSON.stringify(next)); - return next; + return { status: "saved", note: next }; } catch { - return null; // quota 등 — UI 가 저장 실패 처리 + return { status: "failed" }; // quota 등 — UI 가 저장 실패 처리 } }