mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(web): redesign login with split layout and context network panel (#1010)
Consolidate login routes, add animated tool context visualization on the hero panel, and polish loading states across onboarding, settings, and workspace bootstrap.
This commit is contained in:
parent
9229c60a61
commit
31d416b04a
16 changed files with 1541 additions and 689 deletions
|
|
@ -555,6 +555,7 @@ export default function OnboardingPage() {
|
|||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const skippingRef = useRef(false)
|
||||
const [isSkipping, setIsSkipping] = useState(false)
|
||||
const [spotlightCategory, setSpotlightCategory] =
|
||||
useState<SpotlightCategoryId>("productivity")
|
||||
|
||||
|
|
@ -617,6 +618,7 @@ export default function OnboardingPage() {
|
|||
const handleSkip = useCallback(async () => {
|
||||
if (skippingRef.current) return
|
||||
skippingRef.current = true
|
||||
setIsSkipping(true)
|
||||
try {
|
||||
await ensureOrg()
|
||||
const pendingPath = consumePendingConnectUrl()
|
||||
|
|
@ -624,6 +626,7 @@ export default function OnboardingPage() {
|
|||
} catch (err) {
|
||||
console.error(err)
|
||||
skippingRef.current = false
|
||||
setIsSkipping(false)
|
||||
}
|
||||
}, [ensureOrg, router])
|
||||
|
||||
|
|
@ -798,9 +801,22 @@ export default function OnboardingPage() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="text-[#525966] text-sm hover:text-white transition-colors cursor-pointer"
|
||||
disabled={isSkipping}
|
||||
className={cn(
|
||||
"text-sm transition-colors cursor-pointer flex items-center gap-1.5",
|
||||
isSkipping
|
||||
? "text-[#525966] cursor-not-allowed"
|
||||
: "text-[#525966] hover:text-white",
|
||||
)}
|
||||
>
|
||||
Skip for now →
|
||||
{isSkipping ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Skipping…
|
||||
</>
|
||||
) : (
|
||||
"Skip for now →"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -870,34 +886,36 @@ export default function OnboardingPage() {
|
|||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{isCheckingAccount && (
|
||||
{hasDetectedAccount && detected !== "resume" && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-3 flex items-center pointer-events-none"
|
||||
className="absolute right-1 flex size-8 items-center justify-center"
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin text-[#6BB0FF]" />
|
||||
{isCheckingAccount ? (
|
||||
<Loader2 className="size-4 animate-spin text-[#6BB0FF]" />
|
||||
) : canSubmit ? (
|
||||
<motion.button
|
||||
type="button"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
onClick={() =>
|
||||
handleSubmit(detected as "x" | "linkedin")
|
||||
}
|
||||
className="rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}}
|
||||
>
|
||||
<SubmitArrow />
|
||||
</motion.button>
|
||||
) : null}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{canSubmit && (
|
||||
<motion.button
|
||||
type="button"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
onClick={() => handleSubmit(detected as "x" | "linkedin")}
|
||||
className="absolute right-1 rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}}
|
||||
>
|
||||
<SubmitArrow />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
|
|
@ -929,9 +947,6 @@ export default function OnboardingPage() {
|
|||
{currentAccountLookup?.status === "error" && (
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
)}
|
||||
{isCheckingAccount && (
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||
)}
|
||||
<span>
|
||||
{currentAccountLookup?.message ??
|
||||
SOURCE_LABEL[detected as "x" | "linkedin"]}
|
||||
|
|
|
|||
|
|
@ -298,92 +298,99 @@ export default function SettingsPage() {
|
|||
<span className="text-white/55 font-medium shrink-0">Settings</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
|
||||
{!isMobile && (
|
||||
<Popover
|
||||
open={orgSwitcherOpen && canSwitchOrg}
|
||||
onOpenChange={(open) => {
|
||||
if (canSwitchOrg) setOrgSwitcherOpen(open)
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSwitchOrg}
|
||||
{!isMobile &&
|
||||
(canSwitchOrg ? (
|
||||
<Popover open={orgSwitcherOpen} onOpenChange={setOrgSwitcherOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5 transition-colors",
|
||||
"bg-white/[0.03] border border-white/[0.06]",
|
||||
"cursor-pointer hover:bg-white/[0.06]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
|
||||
<Building2 className="size-[13px]" />
|
||||
</span>
|
||||
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<OrgPlanBadge plan={activeOrgPlan} />
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5 transition-colors",
|
||||
"bg-white/[0.03] border border-white/[0.06]",
|
||||
canSwitchOrg
|
||||
? "cursor-pointer hover:bg-white/[0.06]"
|
||||
: "cursor-default",
|
||||
"w-[260px] max-h-80 overflow-y-auto p-1.5",
|
||||
"bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
|
||||
<Building2 className="size-[13px]" />
|
||||
</span>
|
||||
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<OrgPlanBadge plan={activeOrgPlan} />
|
||||
{canSwitchOrg && (
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
{[...(organizations ?? [])]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((organization) => {
|
||||
const isCurrent = organization.id === org?.id
|
||||
const isSwitching = switchingOrgId === organization.id
|
||||
const plan = resolveOrgPlan(
|
||||
organization.id,
|
||||
isCurrent,
|
||||
currentPlan,
|
||||
planByOrgId,
|
||||
)
|
||||
return (
|
||||
<button
|
||||
key={organization.id}
|
||||
type="button"
|
||||
disabled={isCurrent || isSwitching}
|
||||
onClick={() =>
|
||||
handleOrgSwitch(organization.slug, organization.id)
|
||||
}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors",
|
||||
isCurrent
|
||||
? "bg-white/5"
|
||||
: "hover:bg-white/5 cursor-pointer",
|
||||
"disabled:cursor-default",
|
||||
)}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0 text-white/40" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
|
||||
{organization.name}
|
||||
</span>
|
||||
{isSwitching ? (
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
) : null}
|
||||
<OrgPlanBadge plan={plan} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[260px] max-h-80 overflow-y-auto p-1.5",
|
||||
"bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
"flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5",
|
||||
"bg-white/[0.03] border border-white/[0.06]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{[...(organizations ?? [])]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((organization) => {
|
||||
const isCurrent = organization.id === org?.id
|
||||
const isSwitching = switchingOrgId === organization.id
|
||||
const plan = resolveOrgPlan(
|
||||
organization.id,
|
||||
isCurrent,
|
||||
currentPlan,
|
||||
planByOrgId,
|
||||
)
|
||||
return (
|
||||
<button
|
||||
key={organization.id}
|
||||
type="button"
|
||||
disabled={isCurrent || isSwitching}
|
||||
onClick={() =>
|
||||
handleOrgSwitch(organization.slug, organization.id)
|
||||
}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors",
|
||||
isCurrent
|
||||
? "bg-white/5"
|
||||
: "hover:bg-white/5 cursor-pointer",
|
||||
"disabled:cursor-default",
|
||||
)}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0 text-white/40" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
|
||||
{organization.name}
|
||||
</span>
|
||||
{isSwitching ? (
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
) : null}
|
||||
<OrgPlanBadge plan={plan} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
|
||||
<Building2 className="size-[13px]" />
|
||||
</span>
|
||||
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<OrgPlanBadge plan={activeOrgPlan} />
|
||||
</div>
|
||||
))}
|
||||
<UserProfileMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -639,6 +646,11 @@ export default function SettingsPage() {
|
|||
resetConfirmation !== confirmText ||
|
||||
resetOrganization.isPending
|
||||
}
|
||||
title={
|
||||
!confirmText || resetConfirmation !== confirmText
|
||||
? `Type "${confirmText || "your name"}" exactly to enable`
|
||||
: undefined
|
||||
}
|
||||
onClick={() =>
|
||||
resetOrganization.mutate(
|
||||
{ confirmation: confirmText },
|
||||
|
|
@ -719,6 +731,11 @@ export default function SettingsPage() {
|
|||
deleteEmailConfirm !== user?.email ||
|
||||
deleteUserAccount.isPending
|
||||
}
|
||||
title={
|
||||
deleteEmailConfirm !== user?.email
|
||||
? `Type ${user?.email ?? "your email"} exactly to confirm`
|
||||
: undefined
|
||||
}
|
||||
onClick={handleDeleteAccount}
|
||||
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,531 +1,26 @@
|
|||
"use client"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { signIn, useSession } from "@lib/auth"
|
||||
import { usePostHog } from "@lib/posthog"
|
||||
import { TextSeparator } from "@ui/components/text-separator"
|
||||
import { ExternalAuthButton } from "@ui/button/external-auth"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Badge } from "@ui/components/badge"
|
||||
import { LabeledInput } from "@ui/input/labeled-input"
|
||||
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
|
||||
import { Label1Regular } from "@ui/text/label/label-1-regular"
|
||||
import { Title1Bold } from "@ui/text/title/title-1-bold"
|
||||
import { InitialHeader } from "@/components/initial-header"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { resolveAuthRedirectUrl } from "@/lib/url-helpers"
|
||||
|
||||
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
|
||||
return sp.get("response_type") === "code" && Boolean(sp.get("client_id"))
|
||||
}
|
||||
|
||||
function buildMcpAuthorizeResumeUrl(
|
||||
sp: Pick<URLSearchParams, "toString">,
|
||||
function serializeSearchParams(
|
||||
sp: Record<string, string | string[] | undefined>,
|
||||
): string {
|
||||
const backend =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const p = new URLSearchParams(sp.toString())
|
||||
p.delete("redirect")
|
||||
p.delete("error")
|
||||
return `${backend}/api/auth/mcp/authorize?${p.toString()}`
|
||||
const q = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(sp)) {
|
||||
if (value === undefined) continue
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) q.append(key, v)
|
||||
} else {
|
||||
q.set(key, value)
|
||||
}
|
||||
}
|
||||
return q.toString()
|
||||
}
|
||||
|
||||
function AnimatedGradientBackground() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-0 overflow-hidden bg-[#030912]">
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-0.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{
|
||||
y: 0,
|
||||
opacity: [1, 0, 1],
|
||||
}}
|
||||
transition={{
|
||||
y: { duration: 0.75, ease: "easeOut" },
|
||||
opacity: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{
|
||||
y: 0,
|
||||
opacity: [0, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
y: { duration: 0.75, ease: "easeOut" },
|
||||
opacity: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat"
|
||||
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
|
||||
style={{
|
||||
mixBlendMode: "soft-light",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="flex py-8 px-11 flex-col items-start gap-2 rounded-[22px] bg-linear-to-b from-[#06101F] to-[#030912] shadow-[1.5px_1.5px_20px_0_rgba(0,0,0,0.65),1px_1.5px_2px_0_rgba(128,189,255,0.07)_inset,-0.5px_-1.5px_4px_0_rgba(0,35,73,0.40)_inset]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.75, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const posthog = usePostHog()
|
||||
|
||||
const params = useSearchParams()
|
||||
const { data: sessionData, isPending: sessionPending } = useSession()
|
||||
|
||||
const oauthQueryForResume = params.toString()
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionPending) return
|
||||
if (!sessionData?.session) return
|
||||
const sp = new URLSearchParams(oauthQueryForResume)
|
||||
if (isMcpOAuthAuthorizeContext(sp)) {
|
||||
window.location.assign(buildMcpAuthorizeResumeUrl(sp))
|
||||
return
|
||||
}
|
||||
const redirectUrl = params.get("redirect")
|
||||
if (redirectUrl) {
|
||||
window.location.assign(
|
||||
resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(),
|
||||
)
|
||||
return
|
||||
}
|
||||
router.replace("/")
|
||||
}, [
|
||||
sessionPending,
|
||||
sessionData?.session,
|
||||
oauthQueryForResume,
|
||||
params,
|
||||
router,
|
||||
])
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectUrl = params.get("redirect")
|
||||
|
||||
// Create callback URL that includes redirect parameter if provided
|
||||
const getCallbackURL = () => {
|
||||
const origin = window.location.origin
|
||||
|
||||
if (isMcpOAuthAuthorizeContext(params)) {
|
||||
return buildMcpAuthorizeResumeUrl(params)
|
||||
}
|
||||
|
||||
const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin)
|
||||
|
||||
finalUrl.searchParams.set("extension-auth-success", "true")
|
||||
return finalUrl.toString()
|
||||
}
|
||||
|
||||
// Load last used method from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedMethod = localStorage.getItem("supermemory-last-login-method")
|
||||
setLastUsedMethod(savedMethod)
|
||||
}, [])
|
||||
|
||||
// Record the pending login method (will be committed after successful auth)
|
||||
function setPendingLoginMethod(method: string) {
|
||||
try {
|
||||
localStorage.setItem("supermemory-pending-login-method", method)
|
||||
localStorage.setItem(
|
||||
"supermemory-pending-login-timestamp",
|
||||
String(Date.now()),
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isNetworkError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
const message = error.message.toLowerCase()
|
||||
return (
|
||||
message.includes("load failed") ||
|
||||
message.includes("networkerror") ||
|
||||
message.includes("failed to fetch") ||
|
||||
message.includes("network request failed")
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (isNetworkError(error)) {
|
||||
return "Network error. Please check your connection and try again."
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return "An unexpected error occurred. Please try again."
|
||||
}
|
||||
|
||||
// If we land back on this page with an error, clear any pending marker
|
||||
useEffect(() => {
|
||||
if (params.get("error")) {
|
||||
try {
|
||||
localStorage.removeItem("supermemory-pending-login-method")
|
||||
localStorage.removeItem("supermemory-pending-login-timestamp")
|
||||
} catch {}
|
||||
}
|
||||
}, [params])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setIsLoadingEmail(true)
|
||||
setError(null)
|
||||
|
||||
// Track login attempt
|
||||
posthog.capture("login_attempt", {
|
||||
method: "magic_link",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
const { error } = await signIn.magicLink({
|
||||
callbackURL: getCallbackURL(),
|
||||
email,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
|
||||
// Track login failure
|
||||
posthog.capture("login_failed", {
|
||||
method: "magic_link",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
is_network_error: isNetworkError(error),
|
||||
})
|
||||
|
||||
setError(getErrorMessage(error))
|
||||
setIsLoading(false)
|
||||
setIsLoadingEmail(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmittedEmail(email)
|
||||
setPendingLoginMethod("magic_link")
|
||||
posthog.capture("login_magic_link_sent", {
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
setIsLoadingEmail(false)
|
||||
}
|
||||
|
||||
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const token = formData.get("token") as string
|
||||
const callbackURL = getCallbackURL()
|
||||
router.push(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative h-screen overflow-hidden">
|
||||
<AnimatedGradientBackground />
|
||||
<div className="relative z-10">
|
||||
<InitialHeader />
|
||||
<section className="flex flex-col items-center justify-center p-4 space-y-12 sm:p-6 md:p-8 lg:px-20 lg:py-12.5 min-h-[calc(100vh-80px)]">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-medium">
|
||||
Never forget anything, anywhere
|
||||
</div>
|
||||
<div className="text-5xl font-medium">with supermemory</div>
|
||||
</div>
|
||||
{submittedEmail ? (
|
||||
<LoginCard>
|
||||
<div className="w-[360px] flex flex-col gap-4 lg:gap-6 min-h-2/3">
|
||||
<div className="flex flex-col gap-2 text-center lg:text-left">
|
||||
<Title1Bold className="text-foreground">
|
||||
Almost there!
|
||||
</Title1Bold>
|
||||
<HeadingH3Medium className="text-muted-foreground">
|
||||
Click the magic link we've sent to{" "}
|
||||
<span className="text-foreground">{submittedEmail}</span>.
|
||||
</HeadingH3Medium>
|
||||
</div>
|
||||
|
||||
<TextSeparator text="OR" className={cn(dmSansClassName())} />
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-4 lg:gap-6"
|
||||
onSubmit={handleSubmitToken}
|
||||
>
|
||||
<LabeledInput
|
||||
inputPlaceholder="your temporary login code"
|
||||
inputProps={{
|
||||
name: "token",
|
||||
required: true,
|
||||
disabled: isLoading,
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
}}
|
||||
inputType="text"
|
||||
label="Enter code"
|
||||
/>
|
||||
|
||||
<Button disabled={isLoading} id="verify-token" type="submit">
|
||||
Verify Token
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</LoginCard>
|
||||
) : (
|
||||
<LoginCard>
|
||||
<div className="w-[360px] flex flex-col" style={{ gap: "12px" }}>
|
||||
{params.get("error") && (
|
||||
<div className="text-red-500">
|
||||
Error: {params.get("error")}. Please try again!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
|
||||
<div className="relative grow">
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 24 25"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Google</title>
|
||||
<path
|
||||
d="M21.81 10.26H21V10.21H12V14.21H17.65C16.83 16.54 14.61 18.21 12 18.21C8.69 18.21 6 15.53 6 12.21C6 8.9 8.69 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C6.48 2.21 2 6.69 2 12.21C2 17.74 6.48 22.21 12 22.21C17.52 22.21 22 17.74 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
|
||||
fill="#FFC107"
|
||||
/>
|
||||
<path
|
||||
d="M3.15 7.56L6.44 9.97C7.33 7.77 9.48 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C8.16 2.21 4.83 4.38 3.15 7.56Z"
|
||||
fill="#FF3D00"
|
||||
/>
|
||||
<path
|
||||
d="M12 22.22C14.58 22.22 16.93 21.23 18.7 19.62L15.61 17C14.57 17.79 13.3 18.22 12 18.22C9.4 18.22 7.19 16.56 6.36 14.24L3.1 16.75C4.75 19.99 8.11 22.22 12 22.22Z"
|
||||
fill="#4CAF50"
|
||||
/>
|
||||
<path
|
||||
d="M21.81 10.26H21V10.21H12V14.21H17.65C17.26 15.32 16.55 16.29 15.61 17L15.61 17L18.7 19.62C18.49 19.82 22 17.21 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
|
||||
fill="#1976D2"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
authProvider="Google"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (isLoading) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "google",
|
||||
})
|
||||
setPendingLoginMethod("google")
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "google",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{lastUsedMethod === "google" && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
|
||||
<div className="relative grow">
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5 text-foreground"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 26 25"
|
||||
width="26"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Github</title>
|
||||
<g clipPath="url(#clip0_2579_3356)">
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.96 0.21C6.21 0.21 0.75 5.71 0.75 12.52C0.75 17.96 4.25 22.56 9.1 24.19C9.71 24.31 9.93 23.93 9.93 23.6C9.93 23.32 9.91 22.34 9.91 21.32C6.51 22.05 5.81 19.85 5.81 19.85C5.26 18.43 4.45 18.06 4.45 18.06C3.34 17.31 4.53 17.31 4.53 17.31C5.76 17.39 6.41 18.57 6.41 18.57C7.5 20.44 9.26 19.91 9.97 19.59C10.07 18.79 10.4 18.24 10.74 17.94C8.03 17.65 5.18 16.59 5.18 11.87C5.18 10.52 5.66 9.42 6.43 8.57C6.31 8.26 5.89 7 6.55 5.31C6.55 5.31 7.58 4.98 9.91 6.57C10.91 6.3 11.93 6.16 12.96 6.16C13.99 6.16 15.05 6.31 16.02 6.57C18.34 4.98 19.37 5.31 19.37 5.31C20.04 7 19.62 8.26 19.49 8.57C20.28 9.42 20.75 10.52 20.75 11.87C20.75 16.59 17.9 17.63 15.17 17.94C15.61 18.32 16 19.06 16 20.22C16 21.87 15.98 23.19 15.98 23.6C15.98 23.93 16.2 24.31 16.81 24.19C21.66 22.56 25.16 17.96 25.16 12.52C25.18 5.71 19.7 0.21 12.96 0.21Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2579_3356">
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
transform="translate(0.75 0.21)"
|
||||
width="24.5"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
}
|
||||
authProvider="Github"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (isLoading) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "github",
|
||||
})
|
||||
setPendingLoginMethod("github")
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "github",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{lastUsedMethod === "github" && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TextSeparator text="OR" className={cn(dmSansClassName())} />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||
<LabeledInput
|
||||
error={error}
|
||||
inputPlaceholder="your@email.com"
|
||||
inputProps={{
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
disabled: isLoading,
|
||||
id: "email",
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.target.value)
|
||||
error && setError(null)
|
||||
},
|
||||
required: true,
|
||||
value: email,
|
||||
}}
|
||||
inputType="email"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
className="flex justify-center items-center w-full h-[44px] relative gap-3 p-2 rounded-xl"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
|
||||
boxShadow:
|
||||
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
|
||||
}}
|
||||
disabled={isLoading}
|
||||
type="submit"
|
||||
>
|
||||
<Logo className="size-4" />
|
||||
{isLoadingEmail
|
||||
? "Sending login link..."
|
||||
: "Log in with Supermemory"}
|
||||
</Button>
|
||||
{lastUsedMethod === "magic_link" && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Label1Regular
|
||||
className={cn(
|
||||
"text-center text-xs! text-[#737373B2]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
By continuing, you agree to our{" "}
|
||||
<span className="inline-block">
|
||||
<a
|
||||
className="underline"
|
||||
href="https://supermemory.ai/terms/"
|
||||
>
|
||||
Terms
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://supermemory.ai/privacy/"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</Label1Regular>
|
||||
</div>
|
||||
</div>
|
||||
</LoginCard>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const query = serializeSearchParams(sp)
|
||||
redirect(query ? `/login?${query}` : "/login")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,548 @@
|
|||
import { redirect } from "next/navigation"
|
||||
"use client"
|
||||
|
||||
function serializeSearchParams(
|
||||
sp: Record<string, string | string[] | undefined>,
|
||||
import { signIn, useSession } from "@lib/auth"
|
||||
import { usePostHog } from "@lib/posthog"
|
||||
import { TextSeparator } from "@ui/components/text-separator"
|
||||
import { ExternalAuthButton } from "@ui/button/external-auth"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Badge } from "@ui/components/badge"
|
||||
import { LabeledInput } from "@ui/input/labeled-input"
|
||||
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
|
||||
import { Label1Regular } from "@ui/text/label/label-1-regular"
|
||||
import { Title1Bold } from "@ui/text/title/title-1-bold"
|
||||
import { InitialHeader } from "@/components/initial-header"
|
||||
import { LoginToolsPanel } from "@/components/login-tools-panel"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { resolveAuthRedirectUrl } from "@/lib/url-helpers"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
|
||||
return sp.get("response_type") === "code" && Boolean(sp.get("client_id"))
|
||||
}
|
||||
|
||||
function buildMcpAuthorizeResumeUrl(
|
||||
sp: Pick<URLSearchParams, "toString">,
|
||||
): string {
|
||||
const q = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(sp)) {
|
||||
if (value === undefined) continue
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) q.append(key, v)
|
||||
} else {
|
||||
q.set(key, value)
|
||||
}
|
||||
}
|
||||
return q.toString()
|
||||
const backend =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const p = new URLSearchParams(sp.toString())
|
||||
p.delete("redirect")
|
||||
p.delete("error")
|
||||
return `${backend}/api/auth/mcp/authorize?${p.toString()}`
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const query = serializeSearchParams(sp)
|
||||
redirect(query ? `/login/new?${query}` : "/login/new")
|
||||
function LoginHeadline({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("max-w-sm text-center", className)}>
|
||||
<h1 className="text-balance text-lg font-medium leading-tight tracking-tight sm:text-xl lg:text-[1.375rem] xl:text-2xl">
|
||||
Never forget anything, anywhere
|
||||
<span className="block text-brand-accent">with supermemory</span>
|
||||
</h1>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-xs leading-snug text-muted-foreground/50 lg:mt-2",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Save from Chrome, Notion, X — search it all in one place.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LastUsedBadge({ show }: { show: boolean }) {
|
||||
if (!show) return null
|
||||
return (
|
||||
<div className="mb-1 flex justify-end">
|
||||
<Badge variant="default" className="h-5 px-1.5 text-[10px]">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function LoginCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="flex w-full max-w-[400px] shrink-0 flex-col items-center gap-2 rounded-[22px] py-5 px-5 sm:w-auto sm:max-w-none sm:items-start sm:py-8 sm:px-11 bg-linear-to-b from-[#06101F] to-[#030912] shadow-[1.5px_1.5px_20px_0_rgba(0,0,0,0.65),1px_1.5px_2px_0_rgba(128,189,255,0.07)_inset,-0.5px_-1.5px_4px_0_rgba(0,35,73,0.40)_inset]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.75, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginCardBody({
|
||||
children,
|
||||
loadingMessage,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
loadingMessage?: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-full max-w-[360px] sm:w-[360px]">
|
||||
<div className={loadingMessage ? "invisible" : undefined}>{children}</div>
|
||||
{loadingMessage ? (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-[14px] bg-[#030912]/90 backdrop-blur-sm">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{loadingMessage}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const posthog = usePostHog()
|
||||
|
||||
const params = useSearchParams()
|
||||
const { data: sessionData, isPending: sessionPending } = useSession()
|
||||
|
||||
const oauthQueryForResume = params.toString()
|
||||
const isRedirecting = !sessionPending && Boolean(sessionData?.session)
|
||||
const isAuthResolving = sessionPending || isRedirecting
|
||||
const loadingMessage = isAuthResolving
|
||||
? sessionPending
|
||||
? "Checking session…"
|
||||
: "Redirecting…"
|
||||
: isLoading
|
||||
? isLoadingEmail
|
||||
? "Sending login link…"
|
||||
: "Redirecting…"
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionPending) return
|
||||
if (!sessionData?.session) return
|
||||
const sp = new URLSearchParams(oauthQueryForResume)
|
||||
if (isMcpOAuthAuthorizeContext(sp)) {
|
||||
window.location.assign(buildMcpAuthorizeResumeUrl(sp))
|
||||
return
|
||||
}
|
||||
const redirectUrl = params.get("redirect")
|
||||
if (redirectUrl) {
|
||||
window.location.assign(
|
||||
resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(),
|
||||
)
|
||||
return
|
||||
}
|
||||
router.replace("/")
|
||||
}, [
|
||||
sessionPending,
|
||||
sessionData?.session,
|
||||
oauthQueryForResume,
|
||||
params,
|
||||
router,
|
||||
])
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectUrl = params.get("redirect")
|
||||
|
||||
// Create callback URL that includes redirect parameter if provided
|
||||
const getCallbackURL = () => {
|
||||
const origin = window.location.origin
|
||||
|
||||
if (isMcpOAuthAuthorizeContext(params)) {
|
||||
return buildMcpAuthorizeResumeUrl(params)
|
||||
}
|
||||
|
||||
const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin)
|
||||
|
||||
finalUrl.searchParams.set("extension-auth-success", "true")
|
||||
return finalUrl.toString()
|
||||
}
|
||||
|
||||
// Load last used method from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedMethod = localStorage.getItem("supermemory-last-login-method")
|
||||
setLastUsedMethod(savedMethod)
|
||||
}, [])
|
||||
|
||||
// Record the pending login method (will be committed after successful auth)
|
||||
function setPendingLoginMethod(method: string) {
|
||||
try {
|
||||
localStorage.setItem("supermemory-pending-login-method", method)
|
||||
localStorage.setItem(
|
||||
"supermemory-pending-login-timestamp",
|
||||
String(Date.now()),
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isNetworkError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
const message = error.message.toLowerCase()
|
||||
return (
|
||||
message.includes("load failed") ||
|
||||
message.includes("networkerror") ||
|
||||
message.includes("failed to fetch") ||
|
||||
message.includes("network request failed")
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (isNetworkError(error)) {
|
||||
return "Network error. Please check your connection and try again."
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return "An unexpected error occurred. Please try again."
|
||||
}
|
||||
|
||||
// If we land back on this page with an error, clear any pending marker
|
||||
useEffect(() => {
|
||||
if (params.get("error")) {
|
||||
try {
|
||||
localStorage.removeItem("supermemory-pending-login-method")
|
||||
localStorage.removeItem("supermemory-pending-login-timestamp")
|
||||
} catch {}
|
||||
}
|
||||
}, [params])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setIsLoadingEmail(true)
|
||||
setError(null)
|
||||
|
||||
// Track login attempt
|
||||
posthog.capture("login_attempt", {
|
||||
method: "magic_link",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
const { error } = await signIn.magicLink({
|
||||
callbackURL: getCallbackURL(),
|
||||
email,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
|
||||
// Track login failure
|
||||
posthog.capture("login_failed", {
|
||||
method: "magic_link",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
is_network_error: isNetworkError(error),
|
||||
})
|
||||
|
||||
setError(getErrorMessage(error))
|
||||
setIsLoading(false)
|
||||
setIsLoadingEmail(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmittedEmail(email)
|
||||
setPendingLoginMethod("magic_link")
|
||||
posthog.capture("login_magic_link_sent", {
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
setIsLoadingEmail(false)
|
||||
}
|
||||
|
||||
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const token = formData.get("token") as string
|
||||
const callbackURL = getCallbackURL()
|
||||
router.push(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page-grid relative bg-[#030912]">
|
||||
<div className="login-page-mobile-bg" aria-hidden>
|
||||
<div className="login-panel-orb h-full" />
|
||||
<div className="login-panel-orb-image h-full" />
|
||||
</div>
|
||||
<div className="relative z-20 lg:hidden">
|
||||
<InitialHeader />
|
||||
</div>
|
||||
<LoginToolsPanel />
|
||||
<div className="relative z-10 flex min-h-0 min-w-0 flex-col lg:col-start-2 lg:row-start-1 lg:h-full">
|
||||
<div className="hidden lg:block">
|
||||
<InitialHeader />
|
||||
</div>
|
||||
<section className="flex min-h-0 flex-1 flex-col items-center px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-1 lg:justify-center lg:px-10 lg:py-10">
|
||||
<div className="mt-auto flex w-full flex-col items-center lg:mt-0">
|
||||
<LoginHeadline className="mb-3 sm:mb-4 lg:mb-8" />
|
||||
<LoginCard>
|
||||
<LoginCardBody loadingMessage={loadingMessage}>
|
||||
{submittedEmail ? (
|
||||
<div className="flex flex-col gap-4 lg:gap-6">
|
||||
<div className="flex flex-col gap-2 text-center lg:text-left">
|
||||
<Title1Bold className="text-foreground">
|
||||
Almost there!
|
||||
</Title1Bold>
|
||||
<HeadingH3Medium className="text-muted-foreground">
|
||||
Click the magic link we've sent to{" "}
|
||||
<span className="text-foreground">
|
||||
{submittedEmail}
|
||||
</span>
|
||||
.
|
||||
</HeadingH3Medium>
|
||||
</div>
|
||||
|
||||
<TextSeparator
|
||||
text="OR"
|
||||
className={cn(dmSansClassName())}
|
||||
/>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-4 lg:gap-6"
|
||||
onSubmit={handleSubmitToken}
|
||||
>
|
||||
<LabeledInput
|
||||
inputPlaceholder="your temporary login code"
|
||||
inputProps={{
|
||||
name: "token",
|
||||
required: true,
|
||||
disabled: isLoading,
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
}}
|
||||
inputType="text"
|
||||
label="Enter code"
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
id="verify-token"
|
||||
type="submit"
|
||||
>
|
||||
Verify Token
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col" style={{ gap: "12px" }}>
|
||||
{params.get("error") && (
|
||||
<div className="text-red-500">
|
||||
Error: {params.get("error")}. Please try again!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
|
||||
<div className="w-full">
|
||||
<LastUsedBadge show={lastUsedMethod === "google"} />
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 24 25"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Google</title>
|
||||
<path
|
||||
d="M21.81 10.26H21V10.21H12V14.21H17.65C16.83 16.54 14.61 18.21 12 18.21C8.69 18.21 6 15.53 6 12.21C6 8.9 8.69 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C6.48 2.21 2 6.69 2 12.21C2 17.74 6.48 22.21 12 22.21C17.52 22.21 22 17.74 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
|
||||
fill="#FFC107"
|
||||
/>
|
||||
<path
|
||||
d="M3.15 7.56L6.44 9.97C7.33 7.77 9.48 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C8.16 2.21 4.83 4.38 3.15 7.56Z"
|
||||
fill="#FF3D00"
|
||||
/>
|
||||
<path
|
||||
d="M12 22.22C14.58 22.22 16.93 21.23 18.7 19.62L15.61 17C14.57 17.79 13.3 18.22 12 18.22C9.4 18.22 7.19 16.56 6.36 14.24L3.1 16.75C4.75 19.99 8.11 22.22 12 22.22Z"
|
||||
fill="#4CAF50"
|
||||
/>
|
||||
<path
|
||||
d="M21.81 10.26H21V10.21H12V14.21H17.65C17.26 15.32 16.55 16.29 15.61 17L15.61 17L18.7 19.62C18.49 19.82 22 17.21 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
|
||||
fill="#1976D2"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
authProvider="Google"
|
||||
className="w-full"
|
||||
disabled={Boolean(loadingMessage)}
|
||||
onClick={() => {
|
||||
if (loadingMessage) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "google",
|
||||
})
|
||||
setPendingLoginMethod("google")
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "google",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
|
||||
<div className="w-full">
|
||||
<LastUsedBadge show={lastUsedMethod === "github"} />
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5 text-foreground"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 26 25"
|
||||
width="26"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Github</title>
|
||||
<g clipPath="url(#clip0_2579_3356)">
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.96 0.21C6.21 0.21 0.75 5.71 0.75 12.52C0.75 17.96 4.25 22.56 9.1 24.19C9.71 24.31 9.93 23.93 9.93 23.6C9.93 23.32 9.91 22.34 9.91 21.32C6.51 22.05 5.81 19.85 5.81 19.85C5.26 18.43 4.45 18.06 4.45 18.06C3.34 17.31 4.53 17.31 4.53 17.31C5.76 17.39 6.41 18.57 6.41 18.57C7.5 20.44 9.26 19.91 9.97 19.59C10.07 18.79 10.4 18.24 10.74 17.94C8.03 17.65 5.18 16.59 5.18 11.87C5.18 10.52 5.66 9.42 6.43 8.57C6.31 8.26 5.89 7 6.55 5.31C6.55 5.31 7.58 4.98 9.91 6.57C10.91 6.3 11.93 6.16 12.96 6.16C13.99 6.16 15.05 6.31 16.02 6.57C18.34 4.98 19.37 5.31 19.37 5.31C20.04 7 19.62 8.26 19.49 8.57C20.28 9.42 20.75 10.52 20.75 11.87C20.75 16.59 17.9 17.63 15.17 17.94C15.61 18.32 16 19.06 16 20.22C16 21.87 15.98 23.19 15.98 23.6C15.98 23.93 16.2 24.31 16.81 24.19C21.66 22.56 25.16 17.96 25.16 12.52C25.18 5.71 19.7 0.21 12.96 0.21Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2579_3356">
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
transform="translate(0.75 0.21)"
|
||||
width="24.5"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
}
|
||||
authProvider="Github"
|
||||
className="w-full"
|
||||
disabled={Boolean(loadingMessage)}
|
||||
onClick={() => {
|
||||
if (loadingMessage) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "github",
|
||||
})
|
||||
setPendingLoginMethod("github")
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "github",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TextSeparator
|
||||
text="OR"
|
||||
className={cn(dmSansClassName())}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<LabeledInput
|
||||
error={error}
|
||||
inputPlaceholder="your@email.com"
|
||||
inputProps={{
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
disabled: Boolean(loadingMessage),
|
||||
id: "email",
|
||||
onChange: (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
setEmail(e.target.value)
|
||||
error && setError(null)
|
||||
},
|
||||
required: true,
|
||||
value: email,
|
||||
}}
|
||||
inputType="email"
|
||||
/>
|
||||
|
||||
<div className="w-full">
|
||||
<LastUsedBadge
|
||||
show={lastUsedMethod === "magic_link"}
|
||||
/>
|
||||
<Button
|
||||
className="flex justify-center items-center w-full h-[44px] relative gap-3 p-2 rounded-xl"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
|
||||
boxShadow:
|
||||
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
|
||||
}}
|
||||
disabled={Boolean(loadingMessage)}
|
||||
type="submit"
|
||||
>
|
||||
<Logo className="size-4" />
|
||||
Log in with Supermemory
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Label1Regular
|
||||
className={cn(
|
||||
"text-center text-xs! text-[#737373B2]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
By continuing, you agree to our{" "}
|
||||
<span className="inline-block">
|
||||
<a
|
||||
className="underline"
|
||||
href="https://supermemory.ai/terms/"
|
||||
>
|
||||
Terms
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://supermemory.ai/privacy/"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</Label1Regular>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</LoginCardBody>
|
||||
</LoginCard>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { cn } from "@lib/utils"
|
||||
import { SquareIcon } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
|
||||
export function SendButton({
|
||||
onClick,
|
||||
|
|
@ -8,7 +9,7 @@ export function SendButton({
|
|||
onClick: () => void
|
||||
disabled: boolean
|
||||
}) {
|
||||
return (
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
|
|
@ -35,6 +36,19 @@ export function SendButton({
|
|||
</svg>
|
||||
</button>
|
||||
)
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">{button}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Type a message to send</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
export function StopButton({ onClick }: { onClick: () => void }) {
|
||||
|
|
|
|||
|
|
@ -1361,6 +1361,7 @@ export function DashboardView({
|
|||
onChat={onHighlightsChat}
|
||||
onShowRelated={onHighlightsShowRelated}
|
||||
isLoading={isLoadingHighlights}
|
||||
onAddMemory={() => onAddMemory("note")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-[2] hidden sm:block min-w-0">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,18 @@
|
|||
import { useEffect } from "react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
function WorkspaceLoadingShell() {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-[#05080D]">
|
||||
<Loader2
|
||||
className="size-6 animate-spin text-white/40"
|
||||
aria-label="Loading workspace"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
|
|
@ -11,6 +23,7 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
|||
const { session, organizations, isRestoring } = useAuth()
|
||||
|
||||
const isMcpPublicPage = searchParams.get("view") === "mcp"
|
||||
const isOnboarding = pathname.startsWith("/onboarding")
|
||||
|
||||
useEffect(() => {
|
||||
if (isMcpPublicPage) return
|
||||
|
|
@ -23,9 +36,30 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
if (organizations === null) return
|
||||
if (organizations.length > 0) return
|
||||
if (pathname.startsWith("/onboarding")) return
|
||||
if (isOnboarding) return
|
||||
router.replace("/onboarding")
|
||||
}, [session, organizations, isRestoring, pathname, router, isMcpPublicPage])
|
||||
}, [
|
||||
session,
|
||||
organizations,
|
||||
isRestoring,
|
||||
isOnboarding,
|
||||
router,
|
||||
isMcpPublicPage,
|
||||
])
|
||||
|
||||
const showLoading =
|
||||
!isMcpPublicPage &&
|
||||
(isRestoring ||
|
||||
(!session && !isRestoring) ||
|
||||
(session && organizations === null) ||
|
||||
(session &&
|
||||
organizations !== null &&
|
||||
organizations.length === 0 &&
|
||||
!isOnboarding))
|
||||
|
||||
if (showLoading) {
|
||||
return <WorkspaceLoadingShell />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
|
|
|||
|
|
@ -508,7 +508,7 @@ export function PublicHeader() {
|
|||
Sign in
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/login/new">
|
||||
<Link href="/login">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ interface HighlightsCardProps {
|
|||
onChat: (highlightContent: string, userReply: string) => void
|
||||
onShowRelated: (query: string) => void
|
||||
isLoading?: boolean
|
||||
onAddMemory?: () => void
|
||||
}
|
||||
|
||||
function renderContent(content: string, format: HighlightFormat) {
|
||||
|
|
@ -69,6 +70,7 @@ export function HighlightsCard({
|
|||
onChat,
|
||||
onShowRelated,
|
||||
isLoading = false,
|
||||
onAddMemory,
|
||||
}: HighlightsCardProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [isReplyOpen, setIsReplyOpen] = useState(false)
|
||||
|
|
@ -186,10 +188,19 @@ export function HighlightsCard({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3">
|
||||
<p className="text-[11px] text-fg-muted text-center">
|
||||
Add some documents to see highlights here
|
||||
</p>
|
||||
{onAddMemory ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddMemory}
|
||||
className="text-[11px] font-medium text-brand-accent hover:text-brand-accent/80 transition-colors cursor-pointer"
|
||||
>
|
||||
Add memory →
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"
|
|||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { consumePendingConnectUrl } from "@/lib/constants"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
export function InitialHeader({
|
||||
showUserSupermemory,
|
||||
|
|
@ -28,10 +29,10 @@ export function InitialHeader({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex p-6 justify-between items-center">
|
||||
<div className="flex items-center z-10!">
|
||||
<Logo className="h-7" />
|
||||
{showUserSupermemory && (
|
||||
<div className="flex items-center justify-between p-4 sm:p-6">
|
||||
<div className="flex items-center z-10! min-w-0">
|
||||
<Logo className="h-6 sm:h-7 shrink-0" />
|
||||
{showUserSupermemory ? (
|
||||
<div className="flex flex-col items-start justify-center ml-2">
|
||||
<p className="text-[#8B8B8B] text-[11px] leading-tight">
|
||||
{userName}
|
||||
|
|
@ -40,9 +41,13 @@ export function InitialHeader({
|
|||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="ml-2 truncate text-xl leading-none font-semibold text-white/90">
|
||||
supermemory
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 z-10!">
|
||||
<div className="flex items-center gap-2 sm:gap-3 z-10! shrink-0">
|
||||
{showSkipOnboarding && !isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -54,10 +59,21 @@ export function InitialHeader({
|
|||
)}
|
||||
<Button
|
||||
variant="newDefault"
|
||||
className="rounded-2xl text-base gap-1 h-11!"
|
||||
className={cn(
|
||||
"rounded-xl sm:rounded-2xl gap-1 h-9 sm:h-11 px-3 sm:px-4 text-sm sm:text-base",
|
||||
)}
|
||||
size="lg"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://console.supermemory.ai",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
>
|
||||
Memory API <span className="text-xs mt-[4px]">↗</span>
|
||||
<span className="sm:hidden">API</span>
|
||||
<span className="hidden sm:inline">Memory API</span>{" "}
|
||||
<span className="text-xs mt-[4px]">↗</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
399
apps/web/components/login-tools-panel.tsx
Normal file
399
apps/web/components/login-tools-panel.tsx
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { useEffect, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
|
||||
import {
|
||||
ClaudeDesktopIcon,
|
||||
GoogleDrive,
|
||||
MCPIcon,
|
||||
Notion,
|
||||
} from "@ui/assets/icons"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
|
||||
type ToolNode = {
|
||||
id: string
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
iconSrc?: string
|
||||
}
|
||||
|
||||
type ContextConnection = {
|
||||
from: ToolNode
|
||||
to: ToolNode
|
||||
}
|
||||
|
||||
type ContextPhase = "idle" | "capture" | "hold" | "recall"
|
||||
|
||||
const CENTER = { x: 50, y: 50 }
|
||||
const IN_MS = 1100
|
||||
const HOLD_MS = 700
|
||||
const OUT_MS = 1100
|
||||
const TOTAL_MS = IN_MS + HOLD_MS + OUT_MS
|
||||
|
||||
const TOOL_NODES: ToolNode[] = [
|
||||
{ id: "chrome", name: "Chrome", x: 14, y: 20, icon: ChromeIcon },
|
||||
{ id: "notion", name: "Notion", x: 84, y: 16, icon: Notion },
|
||||
{ id: "drive", name: "Google Drive", x: 10, y: 52, icon: GoogleDrive },
|
||||
{ id: "claude", name: "Claude", x: 90, y: 44, icon: ClaudeDesktopIcon },
|
||||
{ id: "raycast", name: "Raycast", x: 76, y: 76, icon: RaycastIcon },
|
||||
{ id: "mcp", name: "MCP", x: 22, y: 84, icon: MCPIcon },
|
||||
{
|
||||
id: "claude-code",
|
||||
name: "Claude Code",
|
||||
x: 20,
|
||||
y: 36,
|
||||
iconSrc: "/images/plugins/claude-code.svg",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
x: 92,
|
||||
y: 28,
|
||||
iconSrc: "/images/plugins/codex.png",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
x: 58,
|
||||
y: 10,
|
||||
iconSrc: "/images/plugins/opencode.svg",
|
||||
},
|
||||
{
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
x: 36,
|
||||
y: 90,
|
||||
iconSrc: "/images/plugins/hermes.svg",
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
name: "OpenClaw",
|
||||
x: 68,
|
||||
y: 68,
|
||||
iconSrc: "/images/plugins/openclaw.svg",
|
||||
},
|
||||
]
|
||||
|
||||
const CONTEXT_FLOWS: [string, string][] = [
|
||||
["chrome", "claude"],
|
||||
["notion", "raycast"],
|
||||
["drive", "claude-code"],
|
||||
["opencode", "codex"],
|
||||
["claude-code", "mcp"],
|
||||
["hermes", "openclaw"],
|
||||
["claude", "mcp"],
|
||||
["notion", "claude"],
|
||||
]
|
||||
|
||||
function nodeById(id: string) {
|
||||
return TOOL_NODES.find((node) => node.id === id)
|
||||
}
|
||||
|
||||
function pickContextFlow(): ContextConnection | null {
|
||||
const flow = CONTEXT_FLOWS[Math.floor(Math.random() * CONTEXT_FLOWS.length)]
|
||||
if (!flow) return null
|
||||
const [fromId, toId] = flow
|
||||
const from = nodeById(fromId)
|
||||
const to = nodeById(toId)
|
||||
if (!from || !to) return null
|
||||
return { from, to }
|
||||
}
|
||||
|
||||
function ToolNodeIcon({
|
||||
node,
|
||||
role,
|
||||
}: {
|
||||
node: ToolNode
|
||||
role?: "source" | "destination"
|
||||
}) {
|
||||
const Icon = node.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
className="login-tool-node-wrap"
|
||||
style={{ left: `${node.x}%`, top: `${node.y}%` }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"login-tool-node",
|
||||
role === "source" && "login-tool-node-source",
|
||||
role === "destination" && "login-tool-node-destination",
|
||||
)}
|
||||
title={node.name}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className="login-tool-node-icon shrink-0" />
|
||||
) : node.iconSrc ? (
|
||||
<Image
|
||||
src={node.iconSrc}
|
||||
alt=""
|
||||
width={22}
|
||||
height={22}
|
||||
className="login-tool-node-icon shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"login-tool-node-label",
|
||||
dmSansClassName(),
|
||||
role && "login-tool-node-label-visible",
|
||||
)}
|
||||
aria-hidden={!role}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemoryChip() {
|
||||
return (
|
||||
<div className="login-context-chip">
|
||||
<div className="login-context-chip-lines" aria-hidden>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AnimatedContextFlow({
|
||||
connection,
|
||||
phase,
|
||||
}: {
|
||||
connection: ContextConnection
|
||||
phase: ContextPhase
|
||||
}) {
|
||||
const { from, to } = connection
|
||||
const dIn = `M ${from.x} ${from.y} L ${CENTER.x} ${CENTER.y}`
|
||||
const dOut = `M ${CENTER.x} ${CENTER.y} L ${to.x} ${to.y}`
|
||||
|
||||
const chipLeft =
|
||||
phase === "recall"
|
||||
? [`${CENTER.x}%`, `${to.x}%`]
|
||||
: phase === "hold"
|
||||
? `${CENTER.x}%`
|
||||
: [`${from.x}%`, `${CENTER.x}%`]
|
||||
|
||||
const chipTop =
|
||||
phase === "recall"
|
||||
? [`${CENTER.y}%`, `${to.y}%`]
|
||||
: phase === "hold"
|
||||
? `${CENTER.y}%`
|
||||
: [`${from.y}%`, `${CENTER.y}%`]
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-[1]">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d={dIn}
|
||||
fill="none"
|
||||
stroke="rgb(75 160 250 / 0.12)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
d={dOut}
|
||||
fill="none"
|
||||
stroke="rgb(75 160 250 / 0.12)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{(phase === "capture" || phase === "hold") && (
|
||||
<motion.path
|
||||
key={`in-${from.id}`}
|
||||
d={dIn}
|
||||
fill="none"
|
||||
stroke="rgb(140 205 255 / 0.75)"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: IN_MS / 1000, ease: [0.33, 0, 0.2, 1] }}
|
||||
/>
|
||||
)}
|
||||
{phase === "recall" && (
|
||||
<motion.path
|
||||
key={`out-${to.id}`}
|
||||
d={dOut}
|
||||
fill="none"
|
||||
stroke="rgb(160 220 255 / 0.9)"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: OUT_MS / 1000, ease: [0.33, 0, 0.2, 1] }}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{phase !== "idle" && (
|
||||
<motion.div
|
||||
className="absolute z-[2] -translate-x-1/2 -translate-y-1/2"
|
||||
initial={false}
|
||||
animate={{
|
||||
left: chipLeft,
|
||||
top: chipTop,
|
||||
opacity: phase === "hold" ? 1 : [1, 1],
|
||||
scale: phase === "hold" ? 1.05 : 1,
|
||||
}}
|
||||
transition={{
|
||||
left: {
|
||||
duration: phase === "recall" ? OUT_MS / 1000 : IN_MS / 1000,
|
||||
ease: [0.35, 0, 0.15, 1],
|
||||
},
|
||||
top: {
|
||||
duration: phase === "recall" ? OUT_MS / 1000 : IN_MS / 1000,
|
||||
ease: [0.35, 0, 0.15, 1],
|
||||
},
|
||||
scale: { duration: 0.3 },
|
||||
}}
|
||||
>
|
||||
<MemoryChip />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolsContextNetwork() {
|
||||
const [connection, setConnection] = useState<ContextConnection | null>(null)
|
||||
const [pulseId, setPulseId] = useState(0)
|
||||
const [phase, setPhase] = useState<ContextPhase>("idle")
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let pulseTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
const runPulse = () => {
|
||||
if (cancelled) return
|
||||
const next = pickContextFlow()
|
||||
if (!next) return
|
||||
setConnection(next)
|
||||
setPulseId((id) => id + 1)
|
||||
pulseTimeout = setTimeout(runPulse, TOTAL_MS + 900 + Math.random() * 500)
|
||||
}
|
||||
|
||||
runPulse()
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(pulseTimeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return
|
||||
|
||||
setPhase("capture")
|
||||
const holdTimer = setTimeout(() => setPhase("hold"), IN_MS)
|
||||
const recallTimer = setTimeout(() => setPhase("recall"), IN_MS + HOLD_MS)
|
||||
const idleTimer = setTimeout(() => setPhase("idle"), TOTAL_MS)
|
||||
|
||||
return () => {
|
||||
clearTimeout(holdTimer)
|
||||
clearTimeout(recallTimer)
|
||||
clearTimeout(idleTimer)
|
||||
}
|
||||
}, [connection])
|
||||
|
||||
const sourceRole = (nodeId: string) =>
|
||||
connection &&
|
||||
connection.from.id === nodeId &&
|
||||
(phase === "capture" || phase === "hold")
|
||||
? ("source" as const)
|
||||
: undefined
|
||||
|
||||
const destRole = (nodeId: string) =>
|
||||
connection && connection.to.id === nodeId && phase === "recall"
|
||||
? ("destination" as const)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
className="login-tools-network relative h-full min-h-[240px] w-full lg:min-h-0"
|
||||
aria-hidden
|
||||
>
|
||||
{connection && phase !== "idle" ? (
|
||||
<AnimatedContextFlow
|
||||
key={pulseId}
|
||||
connection={connection}
|
||||
phase={phase}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{TOOL_NODES.map((node) => (
|
||||
<ToolNodeIcon
|
||||
key={node.id}
|
||||
node={node}
|
||||
role={sourceRole(node.id) ?? destRole(node.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 z-[3] -translate-x-1/2 -translate-y-1/2">
|
||||
<motion.div
|
||||
className="relative flex size-24 items-center justify-center sm:size-28 lg:size-36"
|
||||
animate={{ scale: phase === "hold" ? [1, 1.08, 1] : 1 }}
|
||||
transition={{ duration: 0.55, ease: "easeOut" }}
|
||||
>
|
||||
<NovaOrb size={112} className="blur-[2px]!" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo className="size-6 opacity-80 sm:size-7 lg:size-8" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginPanelBackground() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 bg-[#030912]"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="login-panel-orb pointer-events-none" aria-hidden />
|
||||
<div className="login-panel-orb-image" aria-hidden />
|
||||
<div className="login-panel-orb-image-alt" aria-hidden />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoginToolsPanel() {
|
||||
return (
|
||||
<aside className="relative hidden min-h-0 flex-col overflow-hidden border-white/[0.06] lg:col-start-1 lg:row-start-1 lg:flex lg:h-full lg:border-r">
|
||||
<LoginPanelBackground />
|
||||
|
||||
<div className="login-tools-panel-inner relative z-10 flex flex-col justify-center px-4 py-8 sm:px-8 lg:px-10">
|
||||
<ToolsContextNetwork />
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"relative z-10 shrink-0 px-4 pb-4 text-center text-[11px] leading-snug text-white/40 sm:px-8 sm:text-xs lg:absolute lg:bottom-6 lg:left-1/2 lg:max-w-none lg:-translate-x-1/2 lg:px-0 lg:pb-0 lg:text-sm lg:whitespace-nowrap lg:text-white/45",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
One memory layer — context from any tool, everywhere you need it.
|
||||
</p>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
|
@ -438,35 +438,25 @@ export default function Account() {
|
|||
>
|
||||
Organization
|
||||
</p>
|
||||
<Popover
|
||||
open={orgMenuOpen && canSwitchOrg}
|
||||
onOpenChange={(open) => {
|
||||
if (canSwitchOrg) setOrgMenuOpen(open)
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
disabled={!canSwitchOrg}
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full items-center gap-2 transition-opacity",
|
||||
canSwitchOrg
|
||||
? "cursor-pointer hover:opacity-90"
|
||||
: "cursor-default",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<span
|
||||
{canSwitchOrg ? (
|
||||
<Popover open={orgMenuOpen} onOpenChange={setOrgMenuOpen}>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full items-center gap-2 transition-opacity",
|
||||
"cursor-pointer hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
{canSwitchOrg && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-[#737373]" />
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
{canSwitchOrg && (
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-80 max-h-80 overflow-y-auto bg-[#1B1F24] rounded-[12px] border-white/10 p-1.5 shadow-[0px_4px_16px_rgba(0,0,0,0.4)]"
|
||||
|
|
@ -516,8 +506,17 @@ export default function Account() {
|
|||
)
|
||||
})}
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
</Popover>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<p
|
||||
|
|
@ -1179,6 +1178,13 @@ export default function Account() {
|
|||
inviteMemberMutation.isPending ||
|
||||
(showTagPicker && inviteAssignments.length === 0)
|
||||
}
|
||||
title={
|
||||
showTagPicker && inviteAssignments.length === 0
|
||||
? "Select at least one space for restricted access"
|
||||
: !inviteEmail.trim()
|
||||
? "Enter an email address to send an invite"
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-full bg-[#14161A] px-4 text-[13px] font-semibold text-[#FAFAFA] shadow-inside-out transition-colors hover:bg-[#121820] disabled:cursor-not-allowed disabled:opacity-45",
|
||||
|
|
|
|||
|
|
@ -674,6 +674,11 @@ export default function Billing() {
|
|||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
disabled={isUpgrading || isCheckingStatus || autumn.isLoading}
|
||||
title={
|
||||
autumn.isLoading && !isUpgrading && !isCheckingStatus
|
||||
? "Loading billing details…"
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] bg-[#0054AD] text-[14px] font-semibold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export function UserProfileMenu({
|
|||
} catch {
|
||||
// still navigate if the request fails (offline, etc.)
|
||||
}
|
||||
router.push("/login/new")
|
||||
router.push("/login")
|
||||
})()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
|
||||
--animate-file-upload-grow: file-upload-grow 6s cubic-bezier(0.22, 1, 0.36, 1)
|
||||
forwards;
|
||||
--animate-login-marquee: login-marquee 40s linear infinite;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
|
@ -236,3 +237,319 @@
|
|||
width: 92%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes login-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-grid {
|
||||
display: grid;
|
||||
min-height: 100dvh;
|
||||
height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.login-page-grid {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
grid-template-columns: 65% 35%;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-mobile-bg {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.login-page-mobile-bg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-tools-panel-inner {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.login-panel-orb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 130% 100% at 50% 45%,
|
||||
rgba(70, 155, 255, 0.24) 0%,
|
||||
rgba(35, 100, 210, 0.14) 38%,
|
||||
rgba(8, 22, 45, 0.55) 68%,
|
||||
#030912 100%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 90% 70% at 50% 100%,
|
||||
rgba(50, 130, 240, 0.16) 0%,
|
||||
transparent 65%
|
||||
);
|
||||
}
|
||||
|
||||
.login-panel-orb-image {
|
||||
position: absolute;
|
||||
inset: -10% -15% -5% -15%;
|
||||
background-image: url("/onboarding/bg-gradient-0.png");
|
||||
background-size: cover;
|
||||
background-position: center 35%;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.22;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-panel-orb-image-alt {
|
||||
position: absolute;
|
||||
inset: -10% -15% -5% -15%;
|
||||
background-image: url("/onboarding/bg-gradient-1.png");
|
||||
background-size: cover;
|
||||
background-position: center 35%;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.11;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-tools-section {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
padding: 1.5rem 0 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.login-tools-section {
|
||||
padding: 1.75rem 0 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.login-tools-section::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 10%;
|
||||
top: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
rgb(255 255 255 / 0.12),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.login-tools-marquee {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 10%,
|
||||
black 90%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.login-tools-marquee-track {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
gap: 0.625rem;
|
||||
animation: login-marquee var(--marquee-duration, 40s) linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.login-tools-marquee-track-reverse {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
.login-tools-marquee:hover .login-tools-marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.login-tools-chip {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
padding: 0.375rem 0.875rem 0.375rem 0.375rem;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.login-tools-chip-icon {
|
||||
display: flex;
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.07);
|
||||
}
|
||||
|
||||
.login-tools-chip-icon svg,
|
||||
.login-tools-chip-icon img {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.login-tools-chip-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.82);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.login-tool-node-wrap {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.login-tool-node-label {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4rem);
|
||||
left: 50%;
|
||||
max-width: 5.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(200 225 255 / 0.95);
|
||||
text-shadow: 0 1px 8px rgb(0 0 0 / 0.65);
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-tool-node-label-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.login-tool-node {
|
||||
display: flex;
|
||||
width: 2.625rem;
|
||||
height: 2.625rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
background: rgb(12 22 40 / 0.82);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow:
|
||||
0 1px 0 0 rgb(255 255 255 / 0.1) inset,
|
||||
0 6px 20px rgb(0 0 0 / 0.22);
|
||||
transition:
|
||||
border-color 0.35s ease,
|
||||
background 0.35s ease,
|
||||
box-shadow 0.35s ease,
|
||||
transform 0.35s cubic-bezier(0.34, 1.2, 0.64, 1);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.login-tool-node {
|
||||
width: 2.875rem;
|
||||
height: 2.875rem;
|
||||
border-radius: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.login-tool-node-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
opacity: 0.88;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.login-tool-node-icon {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.login-tool-node-source {
|
||||
border-color: rgb(100 180 255 / 0.55);
|
||||
background: rgb(14 28 50 / 0.92);
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(100 180 255 / 0.25),
|
||||
0 0 24px rgb(75 160 250 / 0.28),
|
||||
0 1px 0 0 rgb(255 255 255 / 0.1) inset;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.login-tool-node-source .login-tool-node-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.login-tool-node-destination {
|
||||
border-color: rgb(130 210 255 / 0.65);
|
||||
background: rgb(16 32 58 / 0.95);
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(130 210 255 / 0.3),
|
||||
0 0 32px rgb(100 180 255 / 0.35),
|
||||
0 1px 0 0 rgb(255 255 255 / 0.12) inset;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.login-tool-node-destination .login-tool-node-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.login-context-chip {
|
||||
display: flex;
|
||||
width: 2.75rem;
|
||||
height: 1.625rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgb(180 220 255 / 0.35);
|
||||
background: rgb(12 24 44 / 0.95);
|
||||
box-shadow:
|
||||
0 0 16px rgb(100 180 255 / 0.4),
|
||||
0 1px 0 0 rgb(255 255 255 / 0.12) inset;
|
||||
}
|
||||
|
||||
.login-context-chip-lines {
|
||||
display: flex;
|
||||
width: 1.5rem;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.login-context-chip-lines span {
|
||||
display: block;
|
||||
height: 0.125rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(180 220 255 / 0.55);
|
||||
}
|
||||
|
||||
.login-context-chip-lines span:nth-child(1) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-context-chip-lines span:nth-child(2) {
|
||||
width: 78%;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.login-context-chip-lines span:nth-child(3) {
|
||||
width: 52%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
|
|
|||
2
bun.lock
2
bun.lock
|
|
@ -295,7 +295,7 @@
|
|||
},
|
||||
"packages/memory-graph": {
|
||||
"name": "@supermemory/memory-graph",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"d3-force": "^3.0.0",
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue