mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-09 22:31:10 +00:00
feat(shortcuts): merge use-case modal with shortcut actions (#1091)
Remove intermediate use-case screen; Apple Shortcuts card now opens a single modal with use cases + Add/Search memory shortcut buttons inline. Fixes res.data.key bug (was reading res.key which is always undefined). Buttons stack on mobile, no 'I'm good' dismiss.
This commit is contained in:
parent
a2a1b065a2
commit
f1c98c161c
2 changed files with 269 additions and 163 deletions
|
|
@ -73,6 +73,10 @@ import {
|
|||
type InstallStep,
|
||||
} from "@/lib/plugin-catalog"
|
||||
import { INSET, InstallSteps, PillButton } from "./integrations/install-steps"
|
||||
import {
|
||||
ShortcutsConnectButtons,
|
||||
useShortcutsConnect,
|
||||
} from "./integrations/shortcuts-detail"
|
||||
import { MCPSteps } from "./mcp-modal/mcp-detail-view"
|
||||
import { GranolaConnectModal } from "./granola-connect-modal"
|
||||
import { detectPluginSpace, detectPluginSource } from "@/lib/plugin-space"
|
||||
|
|
@ -804,6 +808,7 @@ function ItemInfoButton({
|
|||
function ItemInfoDialog({
|
||||
actionSlot,
|
||||
docsUrl,
|
||||
hideDismiss,
|
||||
icon,
|
||||
id,
|
||||
kind,
|
||||
|
|
@ -813,6 +818,7 @@ function ItemInfoDialog({
|
|||
}: {
|
||||
actionSlot: ReactNode
|
||||
docsUrl?: string
|
||||
hideDismiss?: boolean
|
||||
icon: ReactNode
|
||||
id: string
|
||||
kind: ItemKind
|
||||
|
|
@ -942,17 +948,19 @@ function ItemInfoDialog({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWithReason("im_good")}
|
||||
className={cn(
|
||||
"px-3 py-2 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
I'm good
|
||||
</button>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 pt-1">
|
||||
{!hideDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWithReason("im_good")}
|
||||
className={cn(
|
||||
"px-3 py-2 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
I'm good
|
||||
</button>
|
||||
)}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: closes the info dialog after the nested action button runs. */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: keyboard handling stays on the nested real button. */}
|
||||
<div onClick={() => closeWithReason("action")}>{actionSlot}</div>
|
||||
|
|
@ -1766,6 +1774,7 @@ function ItemCard({
|
|||
<ItemInfoDialog
|
||||
actionSlot={infoActionSlot ?? actionSlot}
|
||||
docsUrl={docsUrl}
|
||||
hideDismiss={id === "shortcuts"}
|
||||
icon={icon}
|
||||
id={id}
|
||||
kind={kind}
|
||||
|
|
@ -2211,6 +2220,7 @@ export function IntegrationsView({
|
|||
const queryClient = useQueryClient()
|
||||
const { org } = useAuth()
|
||||
const { allProjects } = useContainerTags()
|
||||
const shortcutsConnect = useShortcutsConnect()
|
||||
const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
|
||||
const hasProProduct =
|
||||
!publicMode && hasActivePlan(autumn.data?.subscriptions, "api_pro")
|
||||
|
|
@ -2973,6 +2983,9 @@ export function IntegrationsView({
|
|||
</PillButton>
|
||||
)
|
||||
}
|
||||
if (item.id === "shortcuts") {
|
||||
return <ShortcutsConnectButtons controller={shortcutsConnect} />
|
||||
}
|
||||
return (
|
||||
<PillButton
|
||||
onClick={() => {
|
||||
|
|
@ -3062,6 +3075,12 @@ export function IntegrationsView({
|
|||
}
|
||||
return renderRight(item)
|
||||
}
|
||||
case "client": {
|
||||
if (item.id === "shortcuts") {
|
||||
return <ShortcutsConnectButtons controller={shortcutsConnect} />
|
||||
}
|
||||
return renderRight(item)
|
||||
}
|
||||
default:
|
||||
return renderRight(item)
|
||||
}
|
||||
|
|
@ -3155,6 +3174,7 @@ export function IntegrationsView({
|
|||
|
||||
return (
|
||||
<div className="flex-1 p-4 md:p-6 pt-2">
|
||||
{shortcutsConnect.dialog}
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full",
|
||||
|
|
|
|||
|
|
@ -23,14 +23,18 @@ import Image from "next/image"
|
|||
import { useId, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type ShortcutType = "add" | "search"
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
|
|
@ -45,6 +49,7 @@ function PillButton({
|
|||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
dmSans125ClassName(),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -52,17 +57,14 @@ function PillButton({
|
|||
)
|
||||
}
|
||||
|
||||
export function ShortcutsDetail() {
|
||||
export function useShortcutsConnect() {
|
||||
const { org } = useAuth()
|
||||
const [showApiKeyModal, setShowApiKeyModal] = useState(false)
|
||||
const [showSetupDialog, setShowSetupDialog] = useState(false)
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [selectedShortcutType, setSelectedShortcutType] = useState<
|
||||
"add" | "search" | null
|
||||
>(null)
|
||||
const apiKeyId = useId()
|
||||
const [shortcutType, setShortcutType] = useState<ShortcutType | null>(null)
|
||||
|
||||
const handleCopyApiKey = async (key: string) => {
|
||||
const copyApiKey = async (key: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key)
|
||||
setCopied(true)
|
||||
|
|
@ -73,20 +75,24 @@ export function ShortcutsDetail() {
|
|||
}
|
||||
}
|
||||
|
||||
const createApiKeyMutation = useMutation({
|
||||
const createKeyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!org?.id) throw new Error("Organization ID is required")
|
||||
const res = await authClient.apiKey.create({
|
||||
metadata: { organizationId: org?.id, type: "ios-shortcut" },
|
||||
metadata: { organizationId: org.id, type: "ios-shortcut" },
|
||||
name: `ios-${generateId().slice(0, 8)}`,
|
||||
prefix: `sm_${org?.id}_`,
|
||||
prefix: `sm_${org.id}_`,
|
||||
})
|
||||
return res.key
|
||||
if (res.error)
|
||||
throw new Error(res.error.message ?? "Failed to create API key")
|
||||
if (!res.data?.key) throw new Error("API key missing from response")
|
||||
return res.data.key
|
||||
},
|
||||
onSuccess: (key) => {
|
||||
setApiKey(key)
|
||||
setShowApiKeyModal(true)
|
||||
setShowSetupDialog(true)
|
||||
setCopied(false)
|
||||
handleCopyApiKey(key)
|
||||
copyApiKey(key)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to create API key", {
|
||||
|
|
@ -95,19 +101,224 @@ export function ShortcutsDetail() {
|
|||
},
|
||||
})
|
||||
|
||||
const handleShortcutClick = (type: "add" | "search") => {
|
||||
setSelectedShortcutType(type)
|
||||
createApiKeyMutation.mutate()
|
||||
const connect = (type: ShortcutType) => {
|
||||
if (createKeyMutation.isPending) return
|
||||
setShortcutType(type)
|
||||
createKeyMutation.mutate()
|
||||
}
|
||||
|
||||
const handleOpenShortcut = () => {
|
||||
if (selectedShortcutType === "add") {
|
||||
const openShortcut = () => {
|
||||
if (shortcutType === "add") {
|
||||
window.open(ADD_MEMORY_SHORTCUT_URL, "_blank")
|
||||
} else if (selectedShortcutType === "search") {
|
||||
} else if (shortcutType === "search") {
|
||||
window.open(SEARCH_MEMORY_SHORTCUT_URL, "_blank")
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = (
|
||||
<ShortcutsSetupDialog
|
||||
apiKey={apiKey}
|
||||
copied={copied}
|
||||
onCopy={() => copyApiKey(apiKey)}
|
||||
onOpenChange={(open) => {
|
||||
setShowSetupDialog(open)
|
||||
if (!open) {
|
||||
setShortcutType(null)
|
||||
setApiKey("")
|
||||
setCopied(false)
|
||||
}
|
||||
}}
|
||||
onOpenShortcut={openShortcut}
|
||||
open={showSetupDialog}
|
||||
shortcutType={shortcutType}
|
||||
/>
|
||||
)
|
||||
|
||||
return {
|
||||
connect,
|
||||
dialog,
|
||||
isPending: createKeyMutation.isPending,
|
||||
pendingType: createKeyMutation.isPending ? shortcutType : null,
|
||||
}
|
||||
}
|
||||
|
||||
export type ShortcutsConnectController = ReturnType<typeof useShortcutsConnect>
|
||||
|
||||
export function ShortcutsConnectButtons({
|
||||
controller,
|
||||
}: {
|
||||
controller: ShortcutsConnectController
|
||||
}) {
|
||||
const { connect, isPending, pendingType } = controller
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<PillButton
|
||||
className="h-9 flex-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
connect("add")
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
{pendingType === "add" ? (
|
||||
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
|
||||
) : (
|
||||
<Plus className="size-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span className="text-[13px] text-[#FAFAFA] font-medium whitespace-nowrap">
|
||||
{pendingType === "add" ? "Creating..." : "Add memory shortcut"}
|
||||
</span>
|
||||
</PillButton>
|
||||
<PillButton
|
||||
className="h-9 flex-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
connect("search")
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
{pendingType === "search" ? (
|
||||
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
|
||||
) : (
|
||||
<Search className="size-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span className="text-[13px] text-[#FAFAFA] font-medium whitespace-nowrap">
|
||||
{pendingType === "search" ? "Creating..." : "Search memory shortcut"}
|
||||
</span>
|
||||
</PillButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShortcutsSetupDialog({
|
||||
apiKey,
|
||||
copied,
|
||||
onCopy,
|
||||
onOpenChange,
|
||||
onOpenShortcut,
|
||||
open,
|
||||
shortcutType,
|
||||
}: {
|
||||
apiKey: string
|
||||
copied: boolean
|
||||
onCopy: () => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
onOpenShortcut: () => void
|
||||
open: boolean
|
||||
shortcutType: ShortcutType | null
|
||||
}) {
|
||||
const apiKeyId = useId()
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogContent className="bg-[#14161A] border border-white/10 text-[#FAFAFA] md:max-w-md z-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[#FAFAFA] text-lg font-semibold",
|
||||
)}
|
||||
>
|
||||
Setup Apple Shortcut
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={apiKeyId}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Your API Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={apiKeyId}
|
||||
type="text"
|
||||
value={apiKey}
|
||||
readOnly
|
||||
className={cn(
|
||||
"flex-1 bg-[#0D121A] border border-white/10 rounded-lg px-3 py-2 text-sm text-[#FAFAFA] font-mono",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h4
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Follow these steps:
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
'Click "Add to Shortcuts" below to open the shortcut',
|
||||
"Paste your API key when prompted",
|
||||
"Start using your shortcut!",
|
||||
].map((text, i) => (
|
||||
<div key={text} className="flex items-start gap-3">
|
||||
<div className="shrink-0 size-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
{i + 1}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenShortcut}
|
||||
disabled={!shortcutType}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2",
|
||||
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
|
||||
"rounded-lg h-11 px-4 font-medium text-sm",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed transition-colors",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src="/images/ios-shortcuts.png"
|
||||
alt="iOS Shortcuts"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Add to Shortcuts
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShortcutsDetail() {
|
||||
const controller = useShortcutsConnect()
|
||||
const { connect, dialog, isPending, pendingType } = controller
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -140,36 +351,24 @@ export function ShortcutsDetail() {
|
|||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<PillButton
|
||||
onClick={() => handleShortcutClick("add")}
|
||||
disabled={createApiKeyMutation.isPending}
|
||||
>
|
||||
{createApiKeyMutation.isPending &&
|
||||
selectedShortcutType === "add" ? (
|
||||
<PillButton onClick={() => connect("add")} disabled={isPending}>
|
||||
{pendingType === "add" ? (
|
||||
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
|
||||
) : (
|
||||
<Plus className="size-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span className="text-[14px] text-[#FAFAFA] font-medium">
|
||||
{createApiKeyMutation.isPending &&
|
||||
selectedShortcutType === "add"
|
||||
? "Creating..."
|
||||
: "Add memory shortcut"}
|
||||
{pendingType === "add" ? "Creating..." : "Add memory shortcut"}
|
||||
</span>
|
||||
</PillButton>
|
||||
<PillButton
|
||||
onClick={() => handleShortcutClick("search")}
|
||||
disabled={createApiKeyMutation.isPending}
|
||||
>
|
||||
{createApiKeyMutation.isPending &&
|
||||
selectedShortcutType === "search" ? (
|
||||
<PillButton onClick={() => connect("search")} disabled={isPending}>
|
||||
{pendingType === "search" ? (
|
||||
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
|
||||
) : (
|
||||
<Search className="size-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span className="text-[14px] text-[#FAFAFA] font-medium">
|
||||
{createApiKeyMutation.isPending &&
|
||||
selectedShortcutType === "search"
|
||||
{pendingType === "search"
|
||||
? "Creating..."
|
||||
: "Search memory shortcut"}
|
||||
</span>
|
||||
|
|
@ -177,120 +376,7 @@ export function ShortcutsDetail() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={showApiKeyModal}
|
||||
onOpenChange={(open: boolean) => {
|
||||
setShowApiKeyModal(open)
|
||||
if (!open) {
|
||||
setSelectedShortcutType(null)
|
||||
setApiKey("")
|
||||
setCopied(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogPortal>
|
||||
<DialogContent className="bg-[#14161A] border border-white/10 text-[#FAFAFA] md:max-w-md z-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[#FAFAFA] text-lg font-semibold",
|
||||
)}
|
||||
>
|
||||
Setup Apple Shortcut
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={apiKeyId}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Your API Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={apiKeyId}
|
||||
type="text"
|
||||
value={apiKey}
|
||||
readOnly
|
||||
className={cn(
|
||||
"flex-1 bg-[#0D121A] border border-white/10 rounded-lg px-3 py-2 text-sm text-[#FAFAFA] font-mono",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopyApiKey(apiKey)}
|
||||
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h4
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Follow these steps:
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
'Click "Add to Shortcuts" below to open the shortcut',
|
||||
"Paste your API key when prompted",
|
||||
"Start using your shortcut!",
|
||||
].map((text, i) => (
|
||||
<div key={text} className="flex items-start gap-3">
|
||||
<div className="shrink-0 size-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
{i + 1}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenShortcut}
|
||||
disabled={!selectedShortcutType}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2",
|
||||
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
|
||||
"rounded-lg h-11 px-4 font-medium text-sm",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed transition-colors",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src="/images/ios-shortcuts.png"
|
||||
alt="iOS Shortcuts"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Add to Shortcuts
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
{dialog}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue