added file previews and mcp card

This commit is contained in:
Mahesh Sanikommmu 2025-11-11 14:38:03 -08:00
parent 5d48097b6f
commit 2a3d3cde58
14 changed files with 550 additions and 253 deletions

View file

@ -24,4 +24,4 @@ export default function NewPage() {
</main>
</div>
)
}
}

View file

@ -4,7 +4,12 @@ import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import NovaOrb from "@/components/nova/nova-orb"
import { Button } from "@ui/components/button"
import { HistoryIcon, PanelRightCloseIcon, SendIcon, SquarePenIcon } from "lucide-react"
import {
HistoryIcon,
PanelRightCloseIcon,
SendIcon,
SquarePenIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
@ -77,26 +82,38 @@ export function ChatSidebar() {
exit={{ x: "100px", opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
>
<div className="flex items-center justify-between p-4 px-6">
<div className="flex items-center justify-between p-4 px-6">
<p>Chat Title</p>
<div className="flex items-center gap-2">
<Button variant="headers" className="rounded-full text-base gap-2 !h-10 border-[#73737333] bg-[#0D121A]">
<HistoryIcon className="size-4 text-[#737373]" />
</Button>
<Button variant="headers" className="rounded-full text-base gap-2 !h-10 border-[#73737333] bg-[#0D121A]">
<SquarePenIcon className="size-4 text-[#737373]" />
<span className={cn("bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center", dmSansClassName())}>T</span>
</Button>
<motion.button
onClick={toggleChat}
className="flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer"
layoutId="chat-toggle-button"
>
<PanelRightCloseIcon className="size-4" />
</motion.button>
<Button
variant="headers"
className="rounded-full text-base gap-2 !h-10 border-[#73737333] bg-[#0D121A]"
>
<HistoryIcon className="size-4 text-[#737373]" />
</Button>
<Button
variant="headers"
className="rounded-full text-base gap-2 !h-10 border-[#73737333] bg-[#0D121A]"
>
<SquarePenIcon className="size-4 text-[#737373]" />
<span
className={cn(
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
dmSansClassName(),
)}
>
T
</span>
</Button>
<motion.button
onClick={toggleChat}
className="flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer"
layoutId="chat-toggle-button"
>
<PanelRightCloseIcon className="size-4" />
</motion.button>
</div>
</div>
</div>
<div className="flex-1 flex flex-col px-4 space-y-3 pb-4 justify-end">
{messages.map((msg, i) => (
<div
@ -201,4 +218,3 @@ export function ChatSidebar() {
</AnimatePresence>
)
}

View file

@ -24,7 +24,7 @@ function getFileTypeInfo(document: DocumentWithMemories): {
return {
icon: <PDF className="w-4 h-4 text-[#DC2626]" />,
extension: ".pdf",
color: "#DC2626",
color: "#DC2626",
}
}
if (mimeType.startsWith("image/")) {
@ -49,7 +49,7 @@ function getFileTypeInfo(document: DocumentWithMemories): {
return {
icon: <PDF className="w-4 h-4 text-[#DC2626]" />,
extension: ".pdf",
color: "#DC2626",
color: "#DC2626",
}
case "image":
return {
@ -72,10 +72,13 @@ function getFileTypeInfo(document: DocumentWithMemories): {
export function FilePreview({ document }: { document: DocumentWithMemories }) {
const [imageError, setImageError] = useState(false)
const { icon, extension, color } = getFileTypeInfo(document)
const type = document.type?.toLowerCase()
const mimeType = document.metadata?.mimeType as string | undefined
const isImage = (mimeType?.startsWith("image/") || type === "image") && document.url && !imageError
const isImage =
(mimeType?.startsWith("image/") || type === "image") &&
document.url &&
!imageError
return (
<div className="bg-[#0B1017] rounded-[18px] gap-3 relative overflow-hidden">
@ -87,25 +90,25 @@ export function FilePreview({ document }: { document: DocumentWithMemories }) {
}}
/>
)}
{isImage ? (
<>
<div className="relative w-full aspect-video bg-gray-100 overflow-hidden">
<img
src={document.url!}
alt={document.title || "Image preview"}
className="w-full h-full object-cover"
onError={() => setImageError(true)}
loading="lazy"
/>
</div>
{document.content && (
<div className="p-3 pt-2">
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
</div>
)}
</>
{isImage && document.url ? (
<div className="relative w-full overflow-hidden flex items-center justify-center">
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `url(${document.url})`,
filter: "blur(10px)",
transform: "scale(1.1)",
}}
/>
<div className="absolute inset-0 bg-black/20" />
<img
src={document.url}
alt={document.title || "Image preview"}
className="relative max-w-full max-h-full w-auto h-auto object-contain z-10"
onError={() => setImageError(true)}
loading="lazy"
/>
</div>
) : (
<div className="p-3">
<div className="flex items-center gap-1 mb-2">
@ -127,4 +130,3 @@ export function FilePreview({ document }: { document: DocumentWithMemories }) {
</div>
)
}

View file

@ -8,7 +8,11 @@ import { cn } from "@lib/utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export function GoogleDocsPreview({ document }: { document: DocumentWithMemories }) {
export function GoogleDocsPreview({
document,
}: {
document: DocumentWithMemories
}) {
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] gap-3">
<div className="flex items-center gap-2 mb-2">
@ -56,4 +60,3 @@ export function GoogleDocsPreview({ document }: { document: DocumentWithMemories
</div>
)
}

View file

@ -0,0 +1,36 @@
"use client"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
import { ClaudeDesktopIcon, MCPIcon } from "@ui/assets/icons"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export function McpPreview({ document }: { document: DocumentWithMemories }) {
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center justify-between gap-1">
<p className={cn(dmSansClassName(), "text-[12px] font-semibold flex items-center gap-1")}>
<ClaudeDesktopIcon className="size-3" />
Claude Desktop
</p>
<MCPIcon className="size-6" />
</div>
<div>
{document.title && (
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
{document.title}
</p>
)}
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}
</div>
</div>
)
}

View file

@ -17,6 +17,7 @@ function NoteIcon() {
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Note Icon</title>
<mask
id="mask0_344_4970"
style={{ maskType: "alpha" }}
@ -87,21 +88,17 @@ export function NotePreview({ document }: { document: DocumentWithMemories }) {
</p>
</div>
<div>
{
document.title && (
{document.title && (
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
{document.title}
</p>
)
}
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}
)}
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}
</div>
</div>
)
}

View file

@ -36,4 +36,3 @@ export function TweetPreview({ data }: { data: Tweet }) {
</div>
)
}

View file

@ -9,9 +9,14 @@ import { cn } from "@lib/utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export function WebsitePreview({ document }: { document: DocumentWithMemories }) {
export function WebsitePreview({
document,
}: {
document: DocumentWithMemories
}) {
const [imageError, setImageError] = useState(false)
const ogImage = (document as DocumentWithMemories & { ogImage?: string }).ogImage
const ogImage = (document as DocumentWithMemories & { ogImage?: string })
.ogImage
return (
<div className="bg-[#0B1017] rounded-[18px] overflow-hidden">
@ -45,4 +50,3 @@ export function WebsitePreview({ document }: { document: DocumentWithMemories })
</div>
)
}

View file

@ -4,12 +4,16 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
import { extractYouTubeVideoId } from "./youtube-utils"
import { extractYouTubeVideoId } from "../utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export function YoutubePreview({ document }: { document: DocumentWithMemories }) {
export function YoutubePreview({
document,
}: {
document: DocumentWithMemories
}) {
const videoId = extractYouTubeVideoId(document.url)
if (!videoId) {
@ -46,4 +50,3 @@ export function YoutubePreview({ document }: { document: DocumentWithMemories })
</div>
)
}

View file

@ -4,71 +4,195 @@ import { Logo } from "@ui/assets/Logo"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import { useAuth } from "@lib/auth-context"
import { useEffect, useState } from "react"
import { ChevronsLeftRight, LayoutGridIcon, Plus, SearchIcon } from "lucide-react"
import {
ChevronsLeftRight,
LayoutGridIcon,
Plus,
SearchIcon,
FolderIcon,
} from "lucide-react"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs"
import { useProjectName } from "@/hooks/use-project-name"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { useQuery } from "@tanstack/react-query"
import { $fetch } from "@repo/lib/api"
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { useProject } from "@/stores"
interface Project {
id: string
name: string
containerTag: string
createdAt: string
updatedAt: string
isExperimental?: boolean
}
export function Header() {
const { user } = useAuth()
const [name, setName] = useState<string>("")
const { user } = useAuth()
const [name, setName] = useState<string>("")
const projectName = useProjectName()
const { selectedProject } = useProject()
const { switchProject } = useProjectMutations()
useEffect(() => {
const storedName =
localStorage.getItem("username") || localStorage.getItem("userName") || ""
setName(storedName)
}, [])
const { data: projects = [] } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await $fetch("@get/projects")
if (response.error) {
throw new Error(response.error?.message || "Failed to load projects")
}
return response.data?.projects || []
},
staleTime: 30 * 1000,
})
useEffect(() => {
const storedName =
localStorage.getItem("username") || localStorage.getItem("userName") || ""
setName(storedName)
}, [])
return (
<div className="flex p-4 justify-between items-center">
<div className="flex items-center justify-center gap-4">
<div className="flex items-center">
<Logo className="h-7" />
{name && (
<div className="flex flex-col items-start justify-center ml-2">
<p className="text-[#8B8B8B] text-sm leading-tight">{name}'s</p>
<p className="text-white font-bold text-xl leading-none -mt-1">
supermemory
</p>
</div>
)}
</div>
<div className="self-stretch w-[1px] bg-[#FFFFFF33]" />
<div className="flex items-center gap-2">
<p>📁 My Space</p>
<ChevronsLeftRight className="size-4 rotate-90" />
</div>
</div>
<Tabs defaultValue="grid">
<TabsList className="rounded-full border border-[#161F2C] !h-11">
<TabsTrigger value="grid" className={cn("rounded-full data-[state=active]:!bg-[#00173C] dark:data-[state=active]:!border-[#2261CA33] px-4 py-4", dmSansClassName())}><LayoutGridIcon className="size-4" />Grid</TabsTrigger>
<TabsTrigger value="graph" className={cn("rounded-full dark:data-[state=active]:!bg-[#00173C] dark:data-[state=active]:!border-[#2261CA33] px-4 py-4", dmSansClassName())}><LayoutGridIcon className="size-4" />Graph</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-2">
<Button variant="headers" className="rounded-full text-base gap-2 !h-10">
<div className="flex items-center gap-2">
<Plus className="size-4" />
Add memory
</div>
<span className={cn("bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center", dmSansClassName())}>C</span>
</Button>
<Button variant="headers" className="rounded-full text-base gap-2 !h-10">
<SearchIcon className="size-4" />
<span className="bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm text-[10px] flex items-center justify-center gap-0.5 px-1">
<svg className="size-[7.5px]" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z" stroke="#737373" strokeWidth="0.833333" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span className={cn(dmSansClassName())}>K</span>
</span>
</Button>
{user && (
<Avatar className="border border-border h-8 w-8 md:h-10 md:w-10">
<AvatarImage src={user?.image ?? ""} />
<AvatarFallback>{user?.name?.charAt(0)}</AvatarFallback>
</Avatar>
)}
</div>
<div className="flex items-center justify-center gap-4">
<div className="flex items-center">
<Logo className="h-7" />
{name && (
<div className="flex flex-col items-start justify-center ml-2">
<p className="text-[#8B8B8B] text-sm leading-tight">{name}'s</p>
<p className="text-white font-bold text-xl leading-none -mt-1">
supermemory
</p>
</div>
)}
</div>
<div className="self-stretch w-[1px] bg-[#FFFFFF33]" />
<div className="flex items-center gap-2">
<p>📁 {projectName}</p>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="cursor-pointer hover:opacity-70 transition-opacity"
aria-label="Change project"
>
<ChevronsLeftRight className="size-4 rotate-90" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem
onClick={() => switchProject(DEFAULT_PROJECT_ID)}
className={cn(
"cursor-pointer",
selectedProject === DEFAULT_PROJECT_ID && "bg-accent",
)}
>
<FolderIcon className="h-3.5 w-3.5 mr-2" />
<span className="text-sm">Default Project</span>
</DropdownMenuItem>
{projects
.filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID)
.map((project: Project) => (
<DropdownMenuItem
key={project.id}
onClick={() => switchProject(project.containerTag)}
className={cn(
"cursor-pointer",
selectedProject === project.containerTag && "bg-accent",
)}
>
<FolderIcon className="h-3.5 w-3.5 mr-2 opacity-70" />
<span className="text-sm truncate">{project.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Tabs defaultValue="grid">
<TabsList className="rounded-full border border-[#161F2C] !h-11">
<TabsTrigger
value="grid"
className={cn(
"rounded-full data-[state=active]:!bg-[#00173C] dark:data-[state=active]:!border-[#2261CA33] px-4 py-4",
dmSansClassName(),
)}
>
<LayoutGridIcon className="size-4" />
Grid
</TabsTrigger>
<TabsTrigger
value="graph"
className={cn(
"rounded-full dark:data-[state=active]:!bg-[#00173C] dark:data-[state=active]:!border-[#2261CA33] px-4 py-4",
dmSansClassName(),
)}
>
<LayoutGridIcon className="size-4" />
Graph
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-2">
<Button
variant="headers"
className="rounded-full text-base gap-2 !h-10"
>
<div className="flex items-center gap-2">
<Plus className="size-4" />
Add memory
</div>
<span
className={cn(
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
dmSansClassName(),
)}
>
C
</span>
</Button>
<Button
variant="headers"
className="rounded-full text-base gap-2 !h-10"
>
<SearchIcon className="size-4" />
<span className="bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm text-[10px] flex items-center justify-center gap-0.5 px-1">
<svg
className="size-[7.5px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Search Icon</title>
<path
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className={cn(dmSansClassName())}>K</span>
</span>
</Button>
{user && (
<Avatar className="border border-border h-8 w-8 md:h-10 md:w-10">
<AvatarImage src={user?.image ?? ""} />
<AvatarFallback>{user?.name?.charAt(0)}</AvatarFallback>
</Avatar>
)}
</div>
</div>
)
}

View file

@ -20,10 +20,9 @@ import { GoogleDocsPreview } from "./document-cards/google-docs-preview"
import { FilePreview } from "./document-cards/file-preview"
import { NotePreview } from "./document-cards/note-preview"
import { YoutubePreview } from "./document-cards/youtube-preview"
import {
isYouTubeUrl,
useYouTubeChannelName,
} from "./document-cards/youtube-utils"
import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils"
import { SyncLogoIcon } from "@ui/assets/icons"
import { McpPreview } from "./document-cards/mcp-preview"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -83,7 +82,9 @@ export function MemoriesGrid() {
})
const documents = useMemo(() => {
return data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? []
return (
data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? []
)
}, [data])
const hasMore = hasNextPage
@ -119,13 +120,7 @@ export function MemoriesGrid() {
index: number
data: DocumentWithMemories
width: number
}) => (
<DocumentCard
index={index}
data={data}
width={width}
/>
),
}) => <DocumentCard index={index} data={data} width={width} />,
[],
)
@ -234,54 +229,71 @@ const DocumentCard = memo(
width: number
}) => {
return (
<div className="rounded-[22px] bg-[#1B1F24] px-1 space-y-2 pt-1" style={{ width }}>
<div
className={cn(
"rounded-[22px] bg-[#1B1F24] px-1 space-y-2 pt-1",
document.type === "image" ||
document.metadata?.mimeType?.toString().startsWith("image/")
? "pb-1"
: "",
)}
style={{ width }}
>
<ContentPreview document={document} />
<div className="pb-[10px] space-y-1">
{document.url && !document.url.includes("x.com") && !document.url.includes("twitter.com") && !document.url.includes("files.supermemory.ai") && (
<div className="px-3">
<p
className={cn(
dmSansClassName(),
"text-[12px] text-[#E5E5E5] line-clamp-1 font-semibold",
{!(
document.type === "image" ||
document.metadata?.mimeType?.toString().startsWith("image/")
) && (
<div className="pb-[10px] space-y-1">
{document.url &&
!document.url.includes("x.com") &&
!document.url.includes("twitter.com") &&
!document.url.includes("files.supermemory.ai") && (
<div className="px-3">
<p
className={cn(
dmSansClassName(),
"text-[12px] text-[#E5E5E5] line-clamp-1 font-semibold",
)}
>
{document.title}
</p>
<DocumentUrlDisplay url={document.url} />
</div>
)}
>
{document.title}
</p>
<DocumentUrlDisplay url={document.url} />
<div className="flex items-center justify-between px-3">
<p
className={cn(
dmSansClassName(),
"text-[10px] text-[#369BFD] line-clamp-1 font-semibold flex items-center gap-1",
)}
style={{
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
<SyncLogoIcon className="size-2" />
{document.memoryEntries.length} memories
</p>
<p
className={cn(
dmSansClassName(),
"text-[10px] text-[#737373] line-clamp-1",
)}
>
{new Date(document.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
</div>
</div>
)}
<div className="flex items-center justify-between px-3">
<p
className={cn(
dmSansClassName(),
"text-[10px] text-[#369BFD] line-clamp-1 font-semibold flex items-center gap-1",
)}
style={{
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
<SyncLogoIcon className="size-2" />
{document.memoryEntries.length} memories
</p>
<p
className={cn(
dmSansClassName(),
"text-[10px] text-[#737373] line-clamp-1",
)}
>
{new Date(document.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
</div>
</div>
)}
</div>
)
},
@ -289,72 +301,7 @@ const DocumentCard = memo(
DocumentCard.displayName = "DocumentCard"
function getAbsoluteUrl(url: string): string {
try {
const urlObj = new URL(url)
return urlObj.host.replace(/^www\./, "")
} catch {
const match = url.match(/^https?:\/\/([^\/]+)/)
const host = match?.[1] ?? url.replace(/^https?:\/\//, "")
return host.replace(/^www\./, "")
}
}
function SyncLogoIcon({ className }: { className?: string }) {
return (
<svg
width="11"
height="9"
viewBox="0 0 11 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g clipPath="url(#clip0_344_4856)">
<path
d="M10.596 3.41884H6.66329V0.000488281H5.39264V3.70946C5.39264 4.10339 5.54815 4.48172 5.82456 4.76047L9.03576 7.99894L9.9342 7.09287L7.56245 4.70102H10.5968V3.41959L10.596 3.41884Z"
fill="url(#paint0_linear_344_4856)"
/>
<path
d="M0.662587 1.57476L3.03434 3.96665H0V5.24807H3.93276V8.66641H5.20341V4.95745C5.20341 4.56349 5.0479 4.18516 4.77149 3.90644L1.56102 0.668701L0.662587 1.57476Z"
fill="url(#paint1_linear_344_4856)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_344_4856"
x1="5.65905"
y1="-0.00784643"
x2="15.4099"
y2="0.406611"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#369BFD" />
<stop offset="0.41" stopColor="#36FDFD" />
<stop offset="0.79" stopColor="#36FDB5" />
</linearGradient>
<linearGradient
id="paint1_linear_344_4856"
x1="0.266373"
y1="0.660367"
x2="10.0159"
y2="1.07475"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#369BFD" />
<stop offset="0.41" stopColor="#36FDFD" />
<stop offset="0.79" stopColor="#36FDB5" />
</linearGradient>
<clipPath id="clip0_344_4856">
<rect width="10.6889" height="8.66667" fill="white" />
</clipPath>
</defs>
</svg>
)
}
function ContentPreview({ document }: { document: DocumentWithMemories }) {
// Check for Google Docs
if (
document.url?.includes("https://docs.googleapis.com/v1/documents") ||
document.url?.includes("docs.google.com/document") ||
@ -363,7 +310,6 @@ function ContentPreview({ document }: { document: DocumentWithMemories }) {
return <GoogleDocsPreview document={document} />
}
// Check for Twitter
if (
document.url?.includes("x.com/") &&
document.metadata?.sm_internal_twitter_metadata
@ -377,6 +323,10 @@ function ContentPreview({ document }: { document: DocumentWithMemories }) {
)
}
if (document.source === "mcp") {
return <McpPreview document={document} />
}
if (isYouTubeUrl(document.url)) {
return <YoutubePreview document={document} />
}
@ -390,7 +340,6 @@ function ContentPreview({ document }: { document: DocumentWithMemories }) {
return <FilePreview document={document} />
}
// Check for Website
if (document.url?.includes("https://")) {
return <WebsitePreview document={document} />
}

View file

@ -11,7 +11,9 @@ export function isYouTubeUrl(url: string | undefined | null): boolean {
)
}
export function extractYouTubeVideoId(url: string | undefined | null): string | null {
export function extractYouTubeVideoId(
url: string | undefined | null,
): string | null {
if (!url) return null
// Handle youtu.be format
@ -27,7 +29,9 @@ export function extractYouTubeVideoId(url: string | undefined | null): string |
if (embedMatch?.[1]) return embedMatch[1]
// Handle m.youtube.com format
const mobileMatch = url.match(/(?:m\.youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/)
const mobileMatch = url.match(
/(?:m\.youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/,
)
if (mobileMatch?.[1]) return mobileMatch[1]
return null
@ -61,3 +65,14 @@ export function useYouTubeChannelName(url: string | undefined | null) {
})
}
export function getAbsoluteUrl(url: string): string {
try {
const urlObj = new URL(url)
return urlObj.host.replace(/^www\./, "")
} catch {
const match = url.match(/^https?:\/\/([^\/]+)/)
const host = match?.[1] ?? url.replace(/^https?:\/\//, "")
return host.replace(/^www\./, "")
}
}

View file

@ -81,13 +81,6 @@
"useNumberNamespace": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error"
},
"suspicious": {
"noUnknownAtRules": {
"options": {
"ignore": ["source", "theme", "apply"]
}
}
}
}
},

File diff suppressed because one or more lines are too long