refactor: 拆分 ShareCardModal(951 行 → 4 个模块)
- ShareCardModal.tsx (199 行): 模态框编排逻辑(图片生成、保存、分享) - RestaurantShareCard.tsx: 餐厅分享卡片纯展示组件 - BlindboxShareCard.tsx: 盲盒分享卡片纯展示组件 - shareImage.ts: 图片工具函数(toPng 封装、dataUrl 转换、下载)
This commit is contained in:
@@ -0,0 +1,323 @@
|
|||||||
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
|
||||||
|
export interface BlindboxShareData {
|
||||||
|
type: "blindbox";
|
||||||
|
idea: string;
|
||||||
|
submitter?: { avatar: string; username: string };
|
||||||
|
drawer?: { avatar: string; username: string };
|
||||||
|
roomName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlindboxShareCard({
|
||||||
|
data,
|
||||||
|
cardRef,
|
||||||
|
}: {
|
||||||
|
data: BlindboxShareData;
|
||||||
|
cardRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}) {
|
||||||
|
const { idea, submitter, drawer, roomName } = data;
|
||||||
|
const shareUrl =
|
||||||
|
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
style={{
|
||||||
|
width: 340,
|
||||||
|
padding: 1.5,
|
||||||
|
borderRadius: 20,
|
||||||
|
background: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)",
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 18.5,
|
||||||
|
background: "#0a0810",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Decorative glows */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -30,
|
||||||
|
right: -20,
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "radial-gradient(circle, rgba(124,58,237,0.2), transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 80,
|
||||||
|
left: -30,
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "radial-gradient(circle, rgba(99,102,241,0.12), transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Brand header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "14px 20px 12px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 18 }}>🎁</span>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "#ffffff",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
NoWhatever
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "rgba(255,255,255,0.3)",
|
||||||
|
letterSpacing: "0.15em",
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
别说随便 · ADVENTURE ROULETTE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thin accent line */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
margin: "0 20px",
|
||||||
|
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Room name badge */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "20px 20px 8px",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.25em",
|
||||||
|
color: "rgba(167,139,250,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✦ {roomName} ✦
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Idea card */}
|
||||||
|
<div style={{ padding: "0 16px 20px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
background: "rgba(255,255,255,0.04)",
|
||||||
|
border: "1px solid rgba(167,139,250,0.1)",
|
||||||
|
padding: "28px 24px",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Corner decorations */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 10,
|
||||||
|
left: 10,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderLeft: "2px solid rgba(167,139,250,0.25)",
|
||||||
|
borderTop: "2px solid rgba(167,139,250,0.25)",
|
||||||
|
borderTopLeftRadius: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRight: "2px solid rgba(167,139,250,0.25)",
|
||||||
|
borderTop: "2px solid rgba(167,139,250,0.25)",
|
||||||
|
borderTopRightRadius: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 10,
|
||||||
|
left: 10,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderLeft: "2px solid rgba(167,139,250,0.25)",
|
||||||
|
borderBottom: "2px solid rgba(167,139,250,0.25)",
|
||||||
|
borderBottomLeftRadius: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 10,
|
||||||
|
right: 10,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRight: "2px solid rgba(167,139,250,0.25)",
|
||||||
|
borderBottom: "2px solid rgba(167,139,250,0.25)",
|
||||||
|
borderBottomRightRadius: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "#ffffff",
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{idea}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 1,
|
||||||
|
margin: "16px auto",
|
||||||
|
background:
|
||||||
|
"linear-gradient(to right, transparent, rgba(167,139,250,0.4), transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "rgba(167,139,250,0.35)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
此契约一旦开启,绝不反悔
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attribution */}
|
||||||
|
{(submitter || drawer) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "0 20px 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitter && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "rgba(196,181,253,0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 14 }}>{submitter.avatar}</span>
|
||||||
|
<span>{submitter.username} 投入</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{submitter && drawer && (
|
||||||
|
<span style={{ color: "rgba(196,181,253,0.2)" }}>·</span>
|
||||||
|
)}
|
||||||
|
{drawer && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "rgba(196,181,253,0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 14 }}>{drawer.avatar}</span>
|
||||||
|
<span>{drawer.username} 抽中</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR footer */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "14px 20px 16px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 14,
|
||||||
|
borderTop: "1px solid rgba(255,255,255,0.04)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 5,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#ffffff",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QRCodeSVG
|
||||||
|
value={shareUrl}
|
||||||
|
size={52}
|
||||||
|
level="M"
|
||||||
|
bgColor="#ffffff"
|
||||||
|
fgColor="#0a0810"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
扫码一起「别说随便」
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: "rgba(255,255,255,0.2)",
|
||||||
|
marginTop: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shareUrl.replace(/^https?:\/\//, "")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import { Star, MapPin, Zap } from "lucide-react";
|
||||||
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
import type { Restaurant, MatchType, SceneType } from "@/types";
|
||||||
|
|
||||||
|
export interface RestaurantShareData {
|
||||||
|
type: "restaurant";
|
||||||
|
restaurant: Restaurant;
|
||||||
|
matchType: MatchType;
|
||||||
|
matchLikes: number;
|
||||||
|
userCount: number;
|
||||||
|
scene?: SceneType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RestaurantShareCard({
|
||||||
|
data,
|
||||||
|
cardRef,
|
||||||
|
imageDataUrl,
|
||||||
|
}: {
|
||||||
|
data: RestaurantShareData;
|
||||||
|
cardRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
imageDataUrl: string | null;
|
||||||
|
}) {
|
||||||
|
const { restaurant, matchType, matchLikes, userCount, scene } = data;
|
||||||
|
const isUnanimous = matchType === "unanimous";
|
||||||
|
const verb = scene === "drink" ? "喝" : "吃";
|
||||||
|
const shareUrl =
|
||||||
|
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
|
||||||
|
const accentFrom = isUnanimous ? "#059669" : "#b45309";
|
||||||
|
const accentTo = isUnanimous ? "#34d399" : "#fbbf24";
|
||||||
|
const accentText = isUnanimous ? "#6ee7b7" : "#fcd34d";
|
||||||
|
const accentBg = isUnanimous
|
||||||
|
? "rgba(16, 185, 129, 0.12)"
|
||||||
|
: "rgba(245, 158, 11, 0.12)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
style={{
|
||||||
|
width: 340,
|
||||||
|
padding: 1.5,
|
||||||
|
borderRadius: 20,
|
||||||
|
background: `linear-gradient(160deg, ${accentFrom}, ${accentTo}40, ${accentFrom}30)`,
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 18.5,
|
||||||
|
background: "#08080a",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Decorative glows */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -40,
|
||||||
|
right: -30,
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: `radial-gradient(circle, ${accentFrom}25, transparent 70%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 60,
|
||||||
|
left: -40,
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: `radial-gradient(circle, ${accentTo}12, transparent 70%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Brand header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "14px 20px 12px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 18 }}>⚡</span>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "#ffffff",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
NoWhatever
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "rgba(255,255,255,0.35)",
|
||||||
|
letterSpacing: "0.15em",
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
别说随便 · PANIC MODE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thin accent line */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
margin: "0 20px",
|
||||||
|
background: `linear-gradient(to right, transparent, ${accentTo}30, transparent)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hero section */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 20px 20px",
|
||||||
|
textAlign: "center",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 40, lineHeight: 1 }}>
|
||||||
|
{isUnanimous ? "🎉" : "🏆"}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "#ffffff",
|
||||||
|
marginTop: 12,
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
就去这{verb}!
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 12,
|
||||||
|
padding: "6px 16px",
|
||||||
|
borderRadius: 100,
|
||||||
|
background: accentBg,
|
||||||
|
border: `1px solid ${accentTo}20`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isUnanimous && (
|
||||||
|
<Zap
|
||||||
|
size={12}
|
||||||
|
style={{ color: accentText, fill: accentText }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: accentText,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isUnanimous
|
||||||
|
? `默契度 100% · ${userCount}人全员一致`
|
||||||
|
: `${matchLikes}/${userCount} 人选了这家`}
|
||||||
|
</span>
|
||||||
|
{isUnanimous && (
|
||||||
|
<Zap
|
||||||
|
size={12}
|
||||||
|
style={{ color: accentText, fill: accentText }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Restaurant card */}
|
||||||
|
<div style={{ padding: "0 16px 16px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
background: "rgba(255,255,255,0.04)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageDataUrl && (
|
||||||
|
<img
|
||||||
|
src={imageDataUrl}
|
||||||
|
alt={restaurant.name}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 150,
|
||||||
|
objectFit: "cover",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ padding: "14px 16px 16px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "#ffffff",
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{restaurant.name}
|
||||||
|
</div>
|
||||||
|
{restaurant.category && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: 100,
|
||||||
|
background: accentBg,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: accentText,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{restaurant.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{restaurant.rating > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 3,
|
||||||
|
color: "#e5e7eb",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={13}
|
||||||
|
style={{ color: "#fbbf24", fill: "#fbbf24" }}
|
||||||
|
/>
|
||||||
|
{restaurant.rating}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{restaurant.price && restaurant.price !== "未知" && (
|
||||||
|
<span style={{ fontWeight: 700, color: accentText }}>
|
||||||
|
{restaurant.price}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{restaurant.distance && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 3,
|
||||||
|
color: "#9ca3af",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapPin size={12} style={{ color: "#6b7280" }} />
|
||||||
|
{restaurant.distance}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{restaurant.address && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#6b7280",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📍 {restaurant.address}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{restaurant.tag && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 4,
|
||||||
|
marginTop: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{restaurant.tag
|
||||||
|
.split(",")
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
style={{
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "#9ca3af",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.trim()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR footer */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "14px 20px 16px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 14,
|
||||||
|
borderTop: "1px solid rgba(255,255,255,0.04)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 5,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#ffffff",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QRCodeSVG
|
||||||
|
value={shareUrl}
|
||||||
|
size={52}
|
||||||
|
level="M"
|
||||||
|
bgColor="#ffffff"
|
||||||
|
fgColor="#08080a"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
扫码一起「别说随便」
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: "rgba(255,255,255,0.25)",
|
||||||
|
marginTop: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shareUrl.replace(/^https?:\/\//, "")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,29 +2,23 @@
|
|||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { X, Download, Share2, Loader2, Star, MapPin, Zap } from "lucide-react";
|
import { X, Download, Share2, Loader2 } from "lucide-react";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
|
||||||
import { toPng } from "html-to-image";
|
|
||||||
import type { Restaurant, MatchType, SceneType } from "@/types";
|
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
import { useShare } from "@/hooks/useShare";
|
import { useShare } from "@/hooks/useShare";
|
||||||
|
import {
|
||||||
|
loadImageAsDataUrl,
|
||||||
|
generateImage,
|
||||||
|
downloadDataUrl,
|
||||||
|
dataUrlToFile,
|
||||||
|
} from "@/lib/shareImage";
|
||||||
|
import RestaurantShareCard, {
|
||||||
|
type RestaurantShareData,
|
||||||
|
} from "@/components/RestaurantShareCard";
|
||||||
|
import BlindboxShareCard, {
|
||||||
|
type BlindboxShareData,
|
||||||
|
} from "@/components/BlindboxShareCard";
|
||||||
|
|
||||||
type ShareCardData =
|
export type ShareCardData = RestaurantShareData | BlindboxShareData;
|
||||||
| {
|
|
||||||
type: "restaurant";
|
|
||||||
restaurant: Restaurant;
|
|
||||||
matchType: MatchType;
|
|
||||||
matchLikes: number;
|
|
||||||
userCount: number;
|
|
||||||
scene?: SceneType;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "blindbox";
|
|
||||||
idea: string;
|
|
||||||
submitter?: { avatar: string; username: string };
|
|
||||||
drawer?: { avatar: string; username: string };
|
|
||||||
roomName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ShareCardModalProps {
|
interface ShareCardModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -32,746 +26,6 @@ interface ShareCardModalProps {
|
|||||||
data: ShareCardData;
|
data: ShareCardData;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadImageAsDataUrl(src: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = "anonymous";
|
|
||||||
img.src = src;
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
img.onload = () => resolve();
|
|
||||||
img.onerror = () => reject();
|
|
||||||
});
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = img.naturalWidth;
|
|
||||||
canvas.height = img.naturalHeight;
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
return canvas.toDataURL("image/jpeg", 0.85);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateImage(el: HTMLElement): Promise<string> {
|
|
||||||
return toPng(el, {
|
|
||||||
pixelRatio: 2,
|
|
||||||
quality: 0.95,
|
|
||||||
cacheBust: true,
|
|
||||||
skipAutoScale: true,
|
|
||||||
filter: (node) => {
|
|
||||||
if (node instanceof HTMLElement && node.dataset.shareExclude === "true") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadDataUrl(dataUrl: string, filename: string) {
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.download = filename;
|
|
||||||
link.href = dataUrl;
|
|
||||||
link.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function dataUrlToFile(dataUrl: string, filename: string): File {
|
|
||||||
const arr = dataUrl.split(",");
|
|
||||||
const mime = arr[0].match(/:(.*?);/)?.[1] || "image/png";
|
|
||||||
const bstr = atob(arr[1]);
|
|
||||||
const u8 = new Uint8Array(bstr.length);
|
|
||||||
for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i);
|
|
||||||
return new File([u8], filename, { type: mime });
|
|
||||||
}
|
|
||||||
|
|
||||||
function RestaurantShareCard({
|
|
||||||
data,
|
|
||||||
cardRef,
|
|
||||||
imageDataUrl,
|
|
||||||
}: {
|
|
||||||
data: Extract<ShareCardData, { type: "restaurant" }>;
|
|
||||||
cardRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
imageDataUrl: string | null;
|
|
||||||
}) {
|
|
||||||
const { restaurant, matchType, matchLikes, userCount, scene } = data;
|
|
||||||
const isUnanimous = matchType === "unanimous";
|
|
||||||
const verb = scene === "drink" ? "喝" : "吃";
|
|
||||||
const shareUrl =
|
|
||||||
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
|
|
||||||
const accentFrom = isUnanimous ? "#059669" : "#b45309";
|
|
||||||
const accentTo = isUnanimous ? "#34d399" : "#fbbf24";
|
|
||||||
const accentText = isUnanimous ? "#6ee7b7" : "#fcd34d";
|
|
||||||
const accentBg = isUnanimous
|
|
||||||
? "rgba(16, 185, 129, 0.12)"
|
|
||||||
: "rgba(245, 158, 11, 0.12)";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={cardRef}
|
|
||||||
style={{
|
|
||||||
width: 340,
|
|
||||||
padding: 1.5,
|
|
||||||
borderRadius: 20,
|
|
||||||
background: `linear-gradient(160deg, ${accentFrom}, ${accentTo}40, ${accentFrom}30)`,
|
|
||||||
fontFamily:
|
|
||||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 18.5,
|
|
||||||
background: "#08080a",
|
|
||||||
position: "relative",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Decorative glows */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: -40,
|
|
||||||
right: -30,
|
|
||||||
width: 160,
|
|
||||||
height: 160,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: `radial-gradient(circle, ${accentFrom}25, transparent 70%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 60,
|
|
||||||
left: -40,
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: `radial-gradient(circle, ${accentTo}12, transparent 70%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Brand header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "14px 20px 12px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<span style={{ fontSize: 18 }}>⚡</span>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: "#ffffff",
|
|
||||||
letterSpacing: "0.02em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
NoWhatever
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "rgba(255,255,255,0.35)",
|
|
||||||
letterSpacing: "0.15em",
|
|
||||||
marginTop: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
别说随便 · PANIC MODE
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thin accent line */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 1,
|
|
||||||
margin: "0 20px",
|
|
||||||
background: `linear-gradient(to right, transparent, ${accentTo}30, transparent)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Hero section */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "24px 20px 20px",
|
|
||||||
textAlign: "center",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 40, lineHeight: 1 }}>
|
|
||||||
{isUnanimous ? "🎉" : "🏆"}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 30,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: "#ffffff",
|
|
||||||
marginTop: 12,
|
|
||||||
letterSpacing: "0.08em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
就去这{verb}!
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
marginTop: 12,
|
|
||||||
padding: "6px 16px",
|
|
||||||
borderRadius: 100,
|
|
||||||
background: accentBg,
|
|
||||||
border: `1px solid ${accentTo}20`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isUnanimous && (
|
|
||||||
<Zap
|
|
||||||
size={12}
|
|
||||||
style={{ color: accentText, fill: accentText }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: accentText,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isUnanimous
|
|
||||||
? `默契度 100% · ${userCount}人全员一致`
|
|
||||||
: `${matchLikes}/${userCount} 人选了这家`}
|
|
||||||
</span>
|
|
||||||
{isUnanimous && (
|
|
||||||
<Zap
|
|
||||||
size={12}
|
|
||||||
style={{ color: accentText, fill: accentText }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Restaurant card */}
|
|
||||||
<div style={{ padding: "0 16px 16px" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 14,
|
|
||||||
background: "rgba(255,255,255,0.04)",
|
|
||||||
border: "1px solid rgba(255,255,255,0.06)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{imageDataUrl && (
|
|
||||||
<img
|
|
||||||
src={imageDataUrl}
|
|
||||||
alt={restaurant.name}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: 150,
|
|
||||||
objectFit: "cover",
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div style={{ padding: "14px 16px 16px" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: "#ffffff",
|
|
||||||
lineHeight: 1.3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{restaurant.name}
|
|
||||||
</div>
|
|
||||||
{restaurant.category && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
padding: "3px 8px",
|
|
||||||
borderRadius: 100,
|
|
||||||
background: accentBg,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: accentText,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{restaurant.category}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
marginTop: 10,
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{restaurant.rating > 0 && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 3,
|
|
||||||
color: "#e5e7eb",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
size={13}
|
|
||||||
style={{ color: "#fbbf24", fill: "#fbbf24" }}
|
|
||||||
/>
|
|
||||||
{restaurant.rating}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{restaurant.price && restaurant.price !== "未知" && (
|
|
||||||
<span style={{ fontWeight: 700, color: accentText }}>
|
|
||||||
{restaurant.price}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{restaurant.distance && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 3,
|
|
||||||
color: "#9ca3af",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MapPin size={12} style={{ color: "#6b7280" }} />
|
|
||||||
{restaurant.distance}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{restaurant.address && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
fontSize: 11,
|
|
||||||
color: "#6b7280",
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📍 {restaurant.address}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{restaurant.tag && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 4,
|
|
||||||
marginTop: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{restaurant.tag
|
|
||||||
.split(",")
|
|
||||||
.slice(0, 4)
|
|
||||||
.map((t) => (
|
|
||||||
<span
|
|
||||||
key={t}
|
|
||||||
style={{
|
|
||||||
padding: "2px 8px",
|
|
||||||
borderRadius: 4,
|
|
||||||
background: "rgba(255,255,255,0.05)",
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "#9ca3af",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.trim()}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* QR footer */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "14px 20px 16px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 14,
|
|
||||||
borderTop: "1px solid rgba(255,255,255,0.04)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: 5,
|
|
||||||
borderRadius: 8,
|
|
||||||
background: "#ffffff",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<QRCodeSVG
|
|
||||||
value={shareUrl}
|
|
||||||
size={52}
|
|
||||||
level="M"
|
|
||||||
bgColor="#ffffff"
|
|
||||||
fgColor="#08080a"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "rgba(255,255,255,0.7)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
扫码一起「别说随便」
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
color: "rgba(255,255,255,0.25)",
|
|
||||||
marginTop: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{shareUrl.replace(/^https?:\/\//, "")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BlindboxShareCard({
|
|
||||||
data,
|
|
||||||
cardRef,
|
|
||||||
}: {
|
|
||||||
data: Extract<ShareCardData, { type: "blindbox" }>;
|
|
||||||
cardRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
}) {
|
|
||||||
const { idea, submitter, drawer, roomName } = data;
|
|
||||||
const shareUrl =
|
|
||||||
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={cardRef}
|
|
||||||
style={{
|
|
||||||
width: 340,
|
|
||||||
padding: 1.5,
|
|
||||||
borderRadius: 20,
|
|
||||||
background: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)",
|
|
||||||
fontFamily:
|
|
||||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 18.5,
|
|
||||||
background: "#0a0810",
|
|
||||||
position: "relative",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Decorative glows */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: -30,
|
|
||||||
right: -20,
|
|
||||||
width: 140,
|
|
||||||
height: 140,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "radial-gradient(circle, rgba(124,58,237,0.2), transparent 70%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 80,
|
|
||||||
left: -30,
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "radial-gradient(circle, rgba(99,102,241,0.12), transparent 70%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Brand header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "14px 20px 12px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 18 }}>🎁</span>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: "#ffffff",
|
|
||||||
letterSpacing: "0.02em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
NoWhatever
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "rgba(255,255,255,0.3)",
|
|
||||||
letterSpacing: "0.15em",
|
|
||||||
marginTop: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
别说随便 · ADVENTURE ROULETTE
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thin accent line */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 1,
|
|
||||||
margin: "0 20px",
|
|
||||||
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Room name badge */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
padding: "20px 20px 8px",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: "0.25em",
|
|
||||||
color: "rgba(167,139,250,0.5)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✦ {roomName} ✦
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Idea card */}
|
|
||||||
<div style={{ padding: "0 16px 20px" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 14,
|
|
||||||
background: "rgba(255,255,255,0.04)",
|
|
||||||
border: "1px solid rgba(167,139,250,0.1)",
|
|
||||||
padding: "28px 24px",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Corner decorations */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 10,
|
|
||||||
left: 10,
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
borderLeft: "2px solid rgba(167,139,250,0.25)",
|
|
||||||
borderTop: "2px solid rgba(167,139,250,0.25)",
|
|
||||||
borderTopLeftRadius: 3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 10,
|
|
||||||
right: 10,
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
borderRight: "2px solid rgba(167,139,250,0.25)",
|
|
||||||
borderTop: "2px solid rgba(167,139,250,0.25)",
|
|
||||||
borderTopRightRadius: 3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 10,
|
|
||||||
left: 10,
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
borderLeft: "2px solid rgba(167,139,250,0.25)",
|
|
||||||
borderBottom: "2px solid rgba(167,139,250,0.25)",
|
|
||||||
borderBottomLeftRadius: 3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 10,
|
|
||||||
right: 10,
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
borderRight: "2px solid rgba(167,139,250,0.25)",
|
|
||||||
borderBottom: "2px solid rgba(167,139,250,0.25)",
|
|
||||||
borderBottomRightRadius: 3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: "#ffffff",
|
|
||||||
textAlign: "center",
|
|
||||||
lineHeight: 1.6,
|
|
||||||
letterSpacing: "0.02em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{idea}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 48,
|
|
||||||
height: 1,
|
|
||||||
margin: "16px auto",
|
|
||||||
background:
|
|
||||||
"linear-gradient(to right, transparent, rgba(167,139,250,0.4), transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "rgba(167,139,250,0.35)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
此契约一旦开启,绝不反悔
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Attribution */}
|
|
||||||
{(submitter || drawer) && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 12,
|
|
||||||
padding: "0 20px 16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{submitter && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 5,
|
|
||||||
fontSize: 11,
|
|
||||||
color: "rgba(196,181,253,0.4)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 14 }}>{submitter.avatar}</span>
|
|
||||||
<span>{submitter.username} 投入</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{submitter && drawer && (
|
|
||||||
<span style={{ color: "rgba(196,181,253,0.2)" }}>·</span>
|
|
||||||
)}
|
|
||||||
{drawer && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 5,
|
|
||||||
fontSize: 11,
|
|
||||||
color: "rgba(196,181,253,0.4)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 14 }}>{drawer.avatar}</span>
|
|
||||||
<span>{drawer.username} 抽中</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* QR footer */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "14px 20px 16px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 14,
|
|
||||||
borderTop: "1px solid rgba(255,255,255,0.04)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: 5,
|
|
||||||
borderRadius: 8,
|
|
||||||
background: "#ffffff",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<QRCodeSVG
|
|
||||||
value={shareUrl}
|
|
||||||
size={52}
|
|
||||||
level="M"
|
|
||||||
bgColor="#ffffff"
|
|
||||||
fgColor="#0a0810"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "rgba(255,255,255,0.7)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
扫码一起「别说随便」
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
color: "rgba(255,255,255,0.2)",
|
|
||||||
marginTop: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{shareUrl.replace(/^https?:\/\//, "")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ShareCardModal({
|
export default function ShareCardModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { toPng } from "html-to-image";
|
||||||
|
|
||||||
|
export async function loadImageAsDataUrl(src: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.src = src;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = () => reject();
|
||||||
|
});
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
return canvas.toDataURL("image/jpeg", 0.85);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateImage(el: HTMLElement): Promise<string> {
|
||||||
|
return toPng(el, {
|
||||||
|
pixelRatio: 2,
|
||||||
|
quality: 0.95,
|
||||||
|
cacheBust: true,
|
||||||
|
skipAutoScale: true,
|
||||||
|
filter: (node) => {
|
||||||
|
if (node instanceof HTMLElement && node.dataset.shareExclude === "true") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadDataUrl(dataUrl: string, filename: string) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = filename;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||||
|
const arr = dataUrl.split(",");
|
||||||
|
const mime = arr[0].match(/:(.*?);/)?.[1] || "image/png";
|
||||||
|
const bstr = atob(arr[1]);
|
||||||
|
const u8 = new Uint8Array(bstr.length);
|
||||||
|
for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i);
|
||||||
|
return new File([u8], filename, { type: mime });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user