feat(share): symbol page share button + distinguish note delete vs failure
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import { PredictionPanel } from "../../components/PredictionPanel";
|
|||||||
import { PriceHero } from "../../components/PriceHero";
|
import { PriceHero } from "../../components/PriceHero";
|
||||||
import { PriceTargets } from "../../components/PriceTargets";
|
import { PriceTargets } from "../../components/PriceTargets";
|
||||||
import { RelatedStocks } from "../../components/RelatedStocks";
|
import { RelatedStocks } from "../../components/RelatedStocks";
|
||||||
|
import { ShareButton } from "../../components/ShareButton";
|
||||||
import { StarButton } from "../../components/StarButton";
|
import { StarButton } from "../../components/StarButton";
|
||||||
import { StockChart } from "../../components/StockChart";
|
import { StockChart } from "../../components/StockChart";
|
||||||
import { SymbolSidebar } from "../../components/SymbolSidebar";
|
import { SymbolSidebar } from "../../components/SymbolSidebar";
|
||||||
@@ -163,6 +164,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
>
|
>
|
||||||
⇄ 비교
|
⇄ 비교
|
||||||
</Link>
|
</Link>
|
||||||
|
<ShareButton code={code} name={chart?.name} />
|
||||||
<StarButton code={code} name={chart?.name} />
|
<StarButton code={code} name={chart?.name} />
|
||||||
<PeriodTabs value={period} onChange={(id) => setPeriod(id)} />
|
<PeriodTabs value={period} onChange={(id) => setPeriod(id)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { NOTE_MAX_LEN, readNote, writeNote, type Note } from "../lib/notes";
|
import { NOTE_MAX_LEN, readNote, writeNote, type Note } from "../lib/notes";
|
||||||
|
|
||||||
|
const FEEDBACK_TEXT: Record<"saved" | "deleted" | "failed", string> = {
|
||||||
|
saved: "저장됨",
|
||||||
|
deleted: "삭제됨",
|
||||||
|
failed: "저장 안됨",
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
code: string;
|
code: string;
|
||||||
};
|
};
|
||||||
@@ -60,10 +66,16 @@ export function InvestmentNote({ code }: Props) {
|
|||||||
const remaining = useMemo(() => NOTE_MAX_LEN - text.length, [text]);
|
const remaining = useMemo(() => NOTE_MAX_LEN - text.length, [text]);
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
const next = writeNote(code, text);
|
const res = writeNote(code, text);
|
||||||
setSaved(next);
|
if (res.status === "saved") {
|
||||||
setText(next?.text ?? "");
|
setSaved(res.note);
|
||||||
setFeedback(next ? "저장됨" : "저장 안됨");
|
setText(res.note.text);
|
||||||
|
} else if (res.status === "deleted") {
|
||||||
|
setSaved(null);
|
||||||
|
setText("");
|
||||||
|
}
|
||||||
|
// failed: 입력값 유지 — 사용자가 재시도하거나 일부를 줄일 수 있도록.
|
||||||
|
setFeedback(FEEDBACK_TEXT[res.status]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
100
web/components/ShareButton.tsx
Normal file
100
web/components/ShareButton.tsx
Normal file
@@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,20 +24,25 @@ export function readNote(code: string): Note | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeNote(code: string, text: string): Note | null {
|
export type WriteResult =
|
||||||
if (typeof window === "undefined") return null;
|
| { 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);
|
const trimmed = text.slice(0, MAX_LEN);
|
||||||
// 공백만이면 키 자체 제거 → 빈 잔여물 방지.
|
// 공백만이면 키 자체 제거 → 빈 잔여물 방지. 의도된 삭제이므로 failed 와 구분.
|
||||||
if (!trimmed.trim()) {
|
if (!trimmed.trim()) {
|
||||||
clearNote(code);
|
clearNote(code);
|
||||||
return null;
|
return { status: "deleted" };
|
||||||
}
|
}
|
||||||
const next: Note = { text: trimmed, updatedAt: new Date().toISOString() };
|
const next: Note = { text: trimmed, updatedAt: new Date().toISOString() };
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(`${PREFIX}${code}`, JSON.stringify(next));
|
window.localStorage.setItem(`${PREFIX}${code}`, JSON.stringify(next));
|
||||||
return next;
|
return { status: "saved", note: next };
|
||||||
} catch {
|
} catch {
|
||||||
return null; // quota 등 — UI 가 저장 실패 처리
|
return { status: "failed" }; // quota 등 — UI 가 저장 실패 처리
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user