mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-19 05:24:00 +00:00
<!-- VORFLUX_AGENT_PR_BODY_BEGIN --> Company Brain share snapshots now use the active organization’s branding instead of the signed-in user’s personal name. ## Changes - Detect Company Brain workspaces through the canonical hook. - Render the normalized organization name with `Company Brain`, preserving personal share branding for other workspaces. - Handle missing, organization-suffixed, and already-possessive organization names. ## Testing - **Passed:** `node_modules/.bin/biome ci apps/web/components/share-modal.tsx` - **Passed:** `git diff --check -- apps/web/components/share-modal.tsx` - **Baseline failure:** `node_modules/.bin/tsc --noEmit --incremental false -p apps/web/tsconfig.json` reports existing workspace diagnostics. The only diagnostic in `share-modal.tsx` is present on `HEAD`; none reference the new branding code. - **Blocked:** authenticated visual verification was not performed because no authenticated browser state was available and no login retry was requested. - Blocker screenshot: - Blocked-flow recording: --- **Attached Images and Videos**  🎥 [View recording: share-branding-walkthrough.webm](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo3NDI1.PLlwgZToeGPRdV49tEQESgrTiADIUq5pHQcQjzfGMQ0.mp4) <!-- VORFLUX_AGENT_PR_BODY_END --> --- **Session Details** - Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/22b72a15-9127-44be-b15b-3d341e26d016) - Requested by: Dhravya Shah (dhravya@supermemory.com) - Address comments on this PR. Add `(aside)` to your comment to have me ignore it. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Copy-only UI in the share preview with no auth, API, or data-handling changes. > > **Overview** > Share snapshot previews now switch branding when the workspace is **Company Brain**, instead of always showing the signed-in user’s name and **supermemory**. > > The modal uses `useHasCompanyBrain` and org data from auth to set the preview header: a normalized org possessive label (with fallbacks for missing names, trailing “organization”, and names that already end in `'s`) plus **Company Brain** as the product line. Non–Company Brain workspaces keep the existing personal **supermemory** branding. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 087019f357b8c9c028bd2e9e915a550bcce24573. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
552 lines
15 KiB
TypeScript
552 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 { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||
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, org } = useAuth()
|
||
const isCompanyBrain = useHasCompanyBrain()
|
||
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 orgLabel = org?.name.replace(/\s*organizations?\s*$/i, "").trim()
|
||
const ownerLabel = isCompanyBrain
|
||
? orgLabel
|
||
? /['’]s$/i.test(orgLabel)
|
||
? orgLabel
|
||
: `${orgLabel}'s`
|
||
: "Your company's"
|
||
: userName
|
||
const productName = isCompanyBrain ? "Company Brain" : "supermemory"
|
||
|
||
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">
|
||
{ownerLabel}
|
||
</span>
|
||
<span className="text-sm text-white font-semibold leading-tight">
|
||
{productName}
|
||
</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>
|
||
)
|
||
}
|