supermemory/apps/web/components/share-modal.tsx
Ishaan Gupta 5065d66989
add responsivness in share graph, bookmark section, navigation controls (#940)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-15 08:37:01 -07:00

541 lines
15 KiB
TypeScript

"use client"
import { useState, useRef, useCallback } from "react"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@ui/components/dialog"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon, Download, Copy, Check } from "lucide-react"
import { GradientLogo } from "@ui/assets/Logo"
import { useAuth } from "@lib/auth-context"
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
import { toast } from "sonner"
import * as htmlToImage from "html-to-image"
type BackgroundTheme = "gradient" | "dark-gradient" | "black"
interface ShareModalProps {
isOpen: boolean
onClose: () => void
graphCanvasRef?: React.RefObject<HTMLCanvasElement | null>
}
// X/Twitter icon
const XIcon2 = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12.6 0.75H15.05L9.69 6.89L16 15.25H11.06L7.2 10.18L2.77 15.25H0.32L6.05 8.68L0 0.75H5.06L8.56 5.39L12.6 0.75ZM11.74 13.77H13.1L4.32 2.15H2.87L11.74 13.77Z"
fill="#737373"
/>
</svg>
)
// LinkedIn icon
const LinkedInIcon = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M3.58 4.89H0V16H3.58V4.89ZM1.79 0C0.8 0 0 0.8 0 1.79C0 2.78 0.8 3.58 1.79 3.58C2.78 3.58 3.58 2.78 3.58 1.79C3.58 0.8 2.78 0 1.79 0ZM8.72 4.89H5.34V16H8.72V10.06C8.72 8.47 9.12 6.94 11.16 6.94C13.18 6.94 13.21 8.76 13.21 10.16V16H16V9.48C16 6.74 15.39 4.65 12.19 4.65C10.65 4.65 9.6 5.48 9.16 6.27H9.13V4.89H8.72Z"
fill="#737373"
/>
</svg>
)
// Instagram icon
const InstagramIcon = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 13 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect
x="1.08"
y="1.08"
width="10.8333"
height="10.8333"
rx="2.58333"
stroke="#737373"
strokeWidth="1.08333"
/>
<circle cx="6.5" cy="6.5" r="2.17" stroke="#737373" strokeWidth="1.08333" />
<circle cx="9.75" cy="3.25" r="0.81" fill="#737373" />
</svg>
)
// Background gradient overlay component
const BackgroundGradient = ({
theme,
className,
}: {
theme: BackgroundTheme
className?: string
}) => {
if (theme === "black") {
return (
<div className={cn("absolute inset-0 bg-black", className)}>
{/* Dotted pattern */}
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage:
"radial-gradient(circle, rgba(255,255,255,0.15) 1px, transparent 1px)",
backgroundSize: "12px 12px",
}}
/>
</div>
)
}
if (theme === "dark-gradient") {
return (
<div className={cn("absolute inset-0", className)}>
{/* Base dark background */}
<div className="absolute inset-0 bg-[#030710]" />
{/* Blue glow effect */}
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 100%, rgba(30, 90, 200, 0.3) 0%, transparent 70%)",
}}
/>
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(ellipse 40% 30% at 30% 80%, rgba(60, 120, 255, 0.15) 0%, transparent 70%)",
}}
/>
{/* Dotted pattern */}
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage:
"radial-gradient(circle, rgba(255,255,255,0.15) 1px, transparent 1px)",
backgroundSize: "12px 12px",
}}
/>
</div>
)
}
// Default: gradient theme (first screenshot)
return (
<div className={cn("absolute inset-0", className)}>
{/* Base dark blue gradient */}
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(180deg, #030B1A 0%, #0A1A35 50%, #1A3A6A 100%)",
}}
/>
{/* Blue glow from bottom */}
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(ellipse 80% 50% at 50% 100%, rgba(30, 100, 220, 0.4) 0%, transparent 70%)",
}}
/>
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(ellipse 50% 30% at 20% 90%, rgba(60, 140, 255, 0.2) 0%, transparent 60%)",
}}
/>
{/* Dotted pattern */}
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage:
"radial-gradient(circle, rgba(255,255,255,0.15) 1px, transparent 1px)",
backgroundSize: "12px 12px",
}}
/>
</div>
)
}
// Theme selector button
const ThemeButton = ({
theme,
isSelected,
onClick,
}: {
theme: BackgroundTheme
isSelected: boolean
onClick: () => void
}) => {
const getPreviewContent = () => {
if (theme === "black") {
return <div className="size-full bg-black rounded" />
}
if (theme === "dark-gradient") {
return (
<div className="size-full rounded relative overflow-hidden">
<div className="absolute inset-0 bg-[#030710]" />
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 100%, rgba(30, 90, 200, 0.4) 0%, transparent 70%)",
}}
/>
</div>
)
}
return (
<div className="size-full rounded relative overflow-hidden">
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(180deg, #030B1A 0%, #0A1A35 50%, #1A3A6A 100%)",
}}
/>
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(ellipse 80% 50% at 50% 100%, rgba(30, 100, 220, 0.5) 0%, transparent 70%)",
}}
/>
</div>
)
}
return (
<button
type="button"
onClick={onClick}
className={cn(
"w-10 h-8 rounded overflow-hidden transition-all",
isSelected
? "ring-2 ring-[#4BA0FA] ring-offset-1 ring-offset-transparent"
: "border border-[rgba(115,115,115,0.2)]",
)}
style={{
background: "#0D121A",
boxShadow: "inset 1.3125px 1.3125px 3.9375px rgba(0, 0, 0, 0.7)",
}}
>
{getPreviewContent()}
</button>
)
}
// Social button component
const SocialButton = ({
icon,
onClick,
label,
}: {
icon: React.ReactNode
onClick: () => void
label: string
}) => (
<button
type="button"
onClick={onClick}
className="size-8 shrink-0 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
style={{
background: "#0D121A",
border: "0.875px solid rgba(115, 115, 115, 0.2)",
boxShadow: "inset 1.3125px 1.3125px 3.9375px rgba(0, 0, 0, 0.7)",
}}
title={label}
aria-label={label}
>
{icon}
</button>
)
export function ShareModal({
isOpen,
onClose,
graphCanvasRef,
}: ShareModalProps) {
const { user } = useAuth()
const [selectedTheme, setSelectedTheme] =
useState<BackgroundTheme>("gradient")
const [isCopying, setIsCopying] = useState(false)
const [copied, setCopied] = useState(false)
const previewRef = useRef<HTMLDivElement>(null)
const localStorageUsername = useLocalStorageUsername()
const displayName =
user?.displayUsername ||
localStorageUsername ||
user?.name ||
user?.email?.split("@")[0] ||
""
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "Your"
const capturePreview = useCallback(async (): Promise<Blob | null> => {
if (!previewRef.current) return null
try {
const dataUrl = await htmlToImage.toPng(previewRef.current, {
pixelRatio: 2,
quality: 1,
})
// Convert data URL to blob
const response = await fetch(dataUrl)
const blob = await response.blob()
return blob
} catch (error) {
console.error("Failed to capture preview:", error)
return null
}
}, [])
const handleCopySnapshot = useCallback(async () => {
setIsCopying(true)
try {
const blob = await capturePreview()
if (!blob) {
throw new Error("Failed to capture image")
}
await navigator.clipboard.write([
new ClipboardItem({
"image/png": blob,
}),
])
setCopied(true)
toast.success("Snapshot copied to clipboard!")
setTimeout(() => setCopied(false), 2000)
} catch (error) {
console.error("Failed to copy:", error)
toast.error("Failed to copy snapshot. Try downloading instead.")
} finally {
setIsCopying(false)
}
}, [capturePreview])
const handleDownload = useCallback(async () => {
try {
const blob = await capturePreview()
if (!blob) {
throw new Error("Failed to capture image")
}
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `supermemory-graph-${Date.now()}.png`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success("Snapshot downloaded!")
} catch (error) {
console.error("Failed to download:", error)
toast.error("Failed to download snapshot")
}
}, [capturePreview])
const handleShareTwitter = useCallback(async () => {
const text = encodeURIComponent(
"Check out my knowledge graph on supermemory! 🧠\n\nhttps://supermemory.ai",
)
window.open(`https://twitter.com/intent/tweet?text=${text}`, "_blank")
}, [])
const handleShareLinkedIn = useCallback(async () => {
const url = encodeURIComponent("https://supermemory.ai")
window.open(
`https://www.linkedin.com/sharing/share-offsite/?url=${url}`,
"_blank",
)
}, [])
const handleShareInstagram = useCallback(async () => {
// Instagram doesn't have a direct share URL, so we'll download and show a message
await handleDownload()
toast.info("Image downloaded! You can now share it on Instagram.")
}, [handleDownload])
const handleClose = () => {
onClose()
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent
className={cn(
"w-[calc(100vw-20px)]! max-w-[706px]! max-h-[min(92dvh,720px)] overflow-y-auto border-none bg-[#1B1F24] flex flex-col p-3 sm:p-4 gap-3 sm:gap-4 rounded-[20px] sm:rounded-[22px]",
dmSansClassName(),
)}
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
showCloseButton={false}
>
<div className="flex flex-col gap-3 min-w-0">
{/* Header */}
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem] items-center gap-2 px-1 sm:px-2">
<div aria-hidden />
<DialogHeader className="min-w-0">
<DialogTitle
className={cn(
"font-semibold text-[#fafafa] text-sm sm:text-base text-center leading-tight text-balance",
dmSans125ClassName(),
)}
>
Share snapshot of your supermemory
</DialogTitle>
</DialogHeader>
<DialogPrimitive.Close
onClick={handleClose}
className="bg-[#0D121A] size-7 justify-self-end flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow:
"0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<XIcon className="size-4 text-[#737373]" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
{/* Preview area */}
<div
ref={previewRef}
className="relative w-full aspect-[674/505] max-h-[48dvh] rounded-[14px] overflow-hidden"
style={{
boxShadow: "inset 2.42px 2.42px 4.26316px rgba(11, 15, 21, 0.7)",
}}
>
<BackgroundGradient theme={selectedTheme} />
{/* Branding header */}
<div className="absolute top-4 left-4 flex items-center gap-2 z-10">
<GradientLogo className="w-7 h-6" />
<div className="flex flex-col">
<span className="text-[10px] text-white/70 leading-tight">
{userName}
</span>
<span className="text-sm text-white font-semibold leading-tight">
supermemory
</span>
</div>
</div>
{/* Graph canvas placeholder - will show the actual graph */}
<div className="absolute inset-0 flex items-center justify-center">
{graphCanvasRef?.current ? (
<img
src={graphCanvasRef.current.toDataURL("image/png")}
alt="Memory graph"
className="max-w-full max-h-full object-contain"
/>
) : (
<div className="text-white/30 text-sm">
Graph preview will appear here
</div>
)}
</div>
</div>
{/* Bottom controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Theme selectors */}
<div className="flex items-center justify-center gap-2 sm:justify-start">
<ThemeButton
theme="gradient"
isSelected={selectedTheme === "gradient"}
onClick={() => setSelectedTheme("gradient")}
/>
<ThemeButton
theme="dark-gradient"
isSelected={selectedTheme === "dark-gradient"}
onClick={() => setSelectedTheme("dark-gradient")}
/>
<ThemeButton
theme="black"
isSelected={selectedTheme === "black"}
onClick={() => setSelectedTheme("black")}
/>
</div>
{/* Action buttons */}
<div className="grid w-full grid-cols-[minmax(0,1fr)_2rem_2rem_2rem_2rem] items-center gap-2 sm:flex sm:w-auto">
<Button
onClick={handleCopySnapshot}
disabled={isCopying}
className={cn(
"h-8 min-w-0 justify-center px-3 rounded-full text-sm font-normal gap-1.5",
dmSansClassName(),
)}
style={{
background: "#0D121A",
border: "1px solid rgba(115, 115, 115, 0.2)",
boxShadow: "inset 1.5px 1.5px 4.5px rgba(0, 0, 0, 0.7)",
}}
>
{copied ? (
<>
<Check className="size-4 text-green-500" />
<span className="truncate text-green-500">Copied!</span>
</>
) : (
<>
<span className="truncate text-white">Copy snapshot</span>
<Copy className="size-4 text-[#737373]" />
</>
)}
</Button>
<SocialButton
icon={<Download className="size-4 text-[#737373]" />}
onClick={handleDownload}
label="Download"
/>
<SocialButton
icon={<XIcon2 className="size-4" />}
onClick={handleShareTwitter}
label="Share on X"
/>
<SocialButton
icon={<LinkedInIcon className="size-4" />}
onClick={handleShareLinkedIn}
label="Share on LinkedIn"
/>
<SocialButton
icon={<InstagramIcon className="size-[13px]" />}
onClick={handleShareInstagram}
label="Share on Instagram"
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}