added memory cards and chat

This commit is contained in:
Mahesh Sanikommmu 2025-11-06 22:38:31 -08:00
parent ecfe9ddee5
commit df173d90b0
11 changed files with 983 additions and 8 deletions

View file

@ -1,9 +1,27 @@
"use client"
import { Header } from "@/components/new/header"
import { ChatSidebar } from "@/components/new/chat-sidebar"
import { AnimatePresence } from "motion/react"
import { MemoriesGrid } from "@/components/new/memories-grid"
export default function NewPage() {
return (
<div>
<Header />
<div className="min-h-screen bg-black">
<Header />
<main className="relative min-h-screen">
<div className="relative z-10">
<div className="flex flex-row h-[calc(100vh-90px)] relative">
<div className="flex-1 flex flex-col justify-start p-6">
<MemoriesGrid />
</div>
<AnimatePresence mode="popLayout">
<ChatSidebar />
</AnimatePresence>
</div>
</div>
</main>
</div>
)
}

View file

@ -0,0 +1,204 @@
"use client"
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 { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
export function ChatSidebar() {
const [message, setMessage] = useState("")
const [isChatOpen, setIsChatOpen] = useState(true)
// Static placeholder messages for UI display
const messages = [
//{
// message: "Sample memory content",
// type: "memory" as const,
// memories: [
// {
// url: "https://example.com",
// title: "Example Memory",
// description: "This is a sample memory description for UI display purposes.",
// fullContent: "This is a sample memory description for UI display purposes.",
// },
// ],
//},
]
const handleSend = () => {
setMessage("")
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const toggleChat = () => {
setIsChatOpen(!isChatOpen)
}
return (
<AnimatePresence mode="wait">
{!isChatOpen ? (
<motion.div
key="closed"
className={cn(
"absolute top-0 right-0 flex items-start justify-start m-4",
dmSansClassName(),
)}
layoutId="chat-toggle-button"
>
<motion.button
onClick={toggleChat}
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border-[1px] border-[#17181A] text-white cursor-pointer"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<NovaOrb size={24} className="!blur-none z-10" />
Chat with Nova
</motion.button>
</motion.div>
) : (
<motion.div
key="open"
className={cn(
"w-[450px] h-[calc(100vh-110px)] bg-[#0A0E14] backdrop-blur-md flex flex-col rounded-2xl m-4 border border-[#17181AB2]",
dmSansClassName(),
)}
initial={{ x: "100px", opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: "100px", opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
>
<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>
</div>
</div>
<div className="flex-1 flex flex-col px-4 space-y-3 pb-4 justify-end">
{messages.map((msg, i) => (
<div
key={`message-${i}-${msg.message}`}
className="flex items-start gap-2"
>
{msg.type === "waiting" ? (
<div className="flex items-center gap-2 text-white/50">
<NovaOrb size={30} className="!blur-none" />
<span className="text-sm">{msg.message}</span>
</div>
) : (
<>
<div
className={cn(
"flex flex-col items-center justify-center w-[30px] h-full",
i !== 0 && "",
)}
>
{i === 0 && (
<div className="w-3 h-3 bg-[#293952]/40 rounded-full mb-1" />
)}
<div className="w-[1px] flex-1 bg-[#293952]/40" />
</div>
{msg.type === "memory" && (
<div className="space-y-2 w-full max-h-60 overflow-y-auto scrollbar-thin">
{msg.memories?.map((memory) => (
<div
key={memory.url + memory.title}
className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-2"
>
{memory.title && (
<h3
className="text-sm font-medium"
style={{
background:
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
}}
>
{memory.title}
</h3>
)}
{memory.url && (
<a
href={memory.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-400 hover:underline break-all"
>
{memory.url}
</a>
)}
{memory.description && (
<p className="text-xs text-white/50 mt-1">
{memory.description}
</p>
)}
</div>
))}
</div>
)}
</>
)}
</div>
))}
</div>
<div className="p-4">
<form
className="flex flex-col gap-3 bg-[#0D121A] rounded-xl p-2 relative"
onSubmit={(e) => {
e.preventDefault()
if (message.trim()) {
handleSend()
}
}}
>
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Chat with your Supermemory"
className="w-full text-white placeholder:text-white/20 rounded-sm outline-none resize-none text-base leading-relaxed bg-transparent px-2 h-10"
/>
<div className="flex justify-end absolute bottom-3 right-2">
<Button
type="submit"
disabled={!message.trim()}
className="text-white/20 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all"
size="icon"
>
<SendIcon className="size-4" />
</Button>
</div>
</form>
</div>
</motion.div>
)}
</AnimatePresence>
)
}

View file

@ -0,0 +1,101 @@
"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 { PDF } from "@ui/assets/icons"
import { FileText, Image, Video } from "lucide-react"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
function getFileTypeInfo(document: DocumentWithMemories): {
icon: React.ReactNode
extension: string
color?: string
} {
const type = document.type?.toLowerCase()
const mimeType = document.metadata?.mimeType as string | undefined
if (mimeType) {
if (mimeType === "application/pdf") {
return {
icon: <PDF className="w-4 h-4 text-[#DC2626]" />,
extension: ".pdf",
color: "#DC2626",
}
}
if (mimeType.startsWith("image/")) {
const ext = mimeType.split("/")[1] || "jpg"
return {
icon: <Image className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: `.${ext}`,
}
}
if (mimeType.startsWith("video/")) {
const ext = mimeType.split("/")[1] || "mp4"
return {
icon: <Video className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: `.${ext}`,
}
}
}
// Fall back to document type
switch (type) {
case "pdf":
return {
icon: <PDF className="w-4 h-4 text-[#DC2626]" />,
extension: ".pdf",
color: "#DC2626",
}
case "image":
return {
icon: <Image className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: ".jpg",
}
case "video":
return {
icon: <Video className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: ".mp4",
}
default:
return {
icon: <FileText className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: ".file",
}
}
}
export function FilePreview({ document }: { document: DocumentWithMemories }) {
const { icon, extension, color } = getFileTypeInfo(document)
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] gap-3 relative">
{color && (
<div
className="absolute left-0 top-3 bottom-3 w-[2px]"
style={{
background: `linear-gradient(to bottom, transparent, ${color} 10%, ${color} 90%, transparent)`,
}}
/>
)}
<div className="flex items-center gap-1 mb-2">
{icon}
<p
className={cn(dmSansClassName(), "text-[12px] font-semibold")}
style={{ color: color }}
>
{extension}
</p>
</div>
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}
</div>
)
}

View file

@ -0,0 +1,59 @@
"use client"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
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">
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 87.3 78"
aria-label="Google Docs"
>
<title>Google Docs</title>
<path
fill="#0066da"
d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3L27.5 53H0c0 1.55.4 3.1 1.2 4.5z"
/>
<path
fill="#00ac47"
d="M43.65 25 29.9 1.2c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44A9.06 9.06 0 0 0 0 53h27.5z"
/>
<path
fill="#ea4335"
d="M73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75L86.1 57.5c.8-1.4 1.2-2.95 1.2-4.5H59.798l5.852 11.5z"
/>
<path
fill="#00832d"
d="M43.65 25 57.4 1.2C56.05.4 54.5 0 52.9 0H34.4c-1.6 0-3.15.45-4.5 1.2z"
/>
<path
fill="#2684fc"
d="M59.8 53H27.5L13.75 76.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
/>
<path
fill="#ffba00"
d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3L43.65 25 59.8 53h27.45c0-1.55-.4-3.1-1.2-4.5z"
/>
</svg>
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
Google Docs
</p>
</div>
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}
</div>
)
}

View file

@ -0,0 +1,97 @@
"use client"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
function NoteIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_344_4970"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="14"
height="14"
>
<rect width="14" height="14" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_344_4970)">
<g filter="url(#filter0_i_344_4970)">
<path
d="M3.50002 8.75008H7.58335L8.75002 7.58341H3.50002V8.75008ZM3.50002 6.41675H7.00002V5.25008H3.50002V6.41675ZM2.33335 4.08341V9.91675H6.41669L5.25002 11.0834H1.16669V2.91675H12.8334V4.66675H11.6667V4.08341H2.33335ZM13.3584 7.17508C13.407 7.22369 13.4313 7.27716 13.4313 7.3355C13.4313 7.39383 13.407 7.4473 13.3584 7.49591L12.8334 8.02091L11.8125 7.00008L12.3375 6.47508C12.3861 6.42647 12.4396 6.40216 12.4979 6.40216C12.5563 6.40216 12.6097 6.42647 12.6584 6.47508L13.3584 7.17508ZM7.58335 12.2501V11.2292L11.4625 7.35008L12.4834 8.37091L8.60419 12.2501H7.58335Z"
fill="#FAFAFA"
/>
</g>
</g>
<defs>
<filter
id="filter0_i_344_4970"
x="1.16669"
y="2.91675"
width="12.6176"
height="9.68628"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.353028" dy="0.353028" />
<feGaussianBlur stdDeviation="0.706055" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.0431373 0 0 0 0 0.0588235 0 0 0 0 0.0823529 0 0 0 0.4 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_344_4970"
/>
</filter>
</defs>
</svg>
)
}
export function NotePreview({ document }: { document: DocumentWithMemories }) {
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] gap-3">
<div className="flex items-center gap-1">
<NoteIcon />
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
Note
</p>
</div>
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}
</div>
)
}

View file

@ -0,0 +1,39 @@
"use client"
import { Suspense } from "react"
import type { Tweet } from "react-tweet/api"
import {
TweetContainer,
TweetHeader,
TweetBody,
TweetMedia,
enrichTweet,
TweetSkeleton,
} from "react-tweet"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/utils/fonts"
export function TweetPreview({ data }: { data: Tweet }) {
const parsedTweet = typeof data === "string" ? JSON.parse(data) : data
const tweet = enrichTweet(parsedTweet)
return (
<div className="p-3 rounded-[18px] !bg-[#0B1017] sm-tweet-theme w-full min-w-0">
<Suspense fallback={<TweetSkeleton />}>
<TweetContainer
className={cn(
"!pb-0 !my-0 !bg-transparent !border-none !w-full !min-w-0",
dmSansClassName(),
)}
>
<TweetHeader tweet={tweet} components={{}} />
<TweetBody tweet={tweet} />
{tweet.mediaDetails?.length ? (
<TweetMedia tweet={tweet} components={{}} />
) : null}
</TweetContainer>
</Suspense>
</div>
)
}

View file

@ -0,0 +1,48 @@
"use client"
import { useState } from "react"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export function WebsitePreview({ document }: { document: DocumentWithMemories }) {
const [imageError, setImageError] = useState(false)
const ogImage = (document as DocumentWithMemories & { ogImage?: string }).ogImage
return (
<div className="bg-[#0B1017] rounded-[18px] overflow-hidden">
{ogImage && !imageError ? (
<div className="relative w-full aspect-video bg-gray-100 overflow-hidden">
<img
src={ogImage}
alt={document.title || "Website preview"}
className="w-full h-full object-cover"
onError={() => setImageError(true)}
loading="lazy"
/>
</div>
) : (
<div className="p-3 gap-2">
<p
className={cn(
dmSansClassName(),
"text-[12px] font-semibold text-[#E5E5E5] line-clamp-2 mb-1",
)}
>
{document.title || "Untitled Document"}
</p>
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-3">
{document.content}
</p>
)}
</div>
)}
</div>
)
}

View file

@ -4,8 +4,11 @@ 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, Plus } from "lucide-react"
import { ChevronsLeftRight, LayoutGridIcon, Plus, SearchIcon } 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"
export function Header() {
const { user } = useAuth()
@ -30,19 +33,34 @@ export function Header() {
</div>
)}
</div>
<div className="self-stretch w-[2px] bg-[#FFFFFF33]" />
<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="bg-[#21212180] border border-[#73737333] text-[#737373] rounded-md px-1 py-0.5 size-6 text-xs">c</span>
<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">

View file

@ -0,0 +1,372 @@
"use client"
import { useAuth } from "@lib/auth-context"
import { $fetch } from "@repo/lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useCallback, memo, useMemo } from "react"
import type { z } from "zod"
import { Masonry, useInfiniteLoader } from "masonic"
import { Sparkles } from "lucide-react"
import { dmSansClassName } from "@/utils/fonts"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { useProject } from "@/stores"
import { useIsMobile } from "@hooks/use-mobile"
import type { Tweet } from "react-tweet/api"
import { TweetPreview } from "./document-cards/tweet-preview"
import { WebsitePreview } from "./document-cards/website-preview"
import { GoogleDocsPreview } from "./document-cards/google-docs-preview"
import { FilePreview } from "./document-cards/file-preview"
import { NotePreview } from "./document-cards/note-preview"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
const IS_DEV = process.env.NODE_ENV === "development"
const PAGE_SIZE = IS_DEV ? 100 : 100
const MAX_TOTAL = 1000
export function MemoriesGrid() {
const { user } = useAuth()
const { selectedProject } = useProject()
const isMobile = useIsMobile()
const {
data,
error,
isPending,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<DocumentsResponse, Error>({
queryKey: ["documents-with-memories", selectedProject],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
const response = await $fetch("@post/documents/documents", {
body: {
page: pageParam as number,
limit: (pageParam as number) === 1 ? (IS_DEV ? 500 : 500) : PAGE_SIZE,
sort: "createdAt",
order: "desc",
containerTags: selectedProject ? [selectedProject] : undefined,
},
disableValidation: true,
})
if (response.error) {
throw new Error(response.error?.message || "Failed to fetch documents")
}
return response.data
},
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce(
(acc, p) => acc + (p.documents?.length ?? 0),
0,
)
if (loaded >= MAX_TOTAL) return undefined
const { currentPage, totalPages } = lastPage.pagination
if (currentPage < totalPages) {
return currentPage + 1
}
return undefined
},
staleTime: 5 * 60 * 1000,
enabled: !!user,
})
const documents = useMemo(() => {
return data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? []
}, [data])
const hasMore = hasNextPage
const isLoadingMore = isFetchingNextPage
const loadMoreDocuments = useCallback(async (): Promise<void> => {
if (hasNextPage && !isFetchingNextPage) {
await fetchNextPage()
return
}
return
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
const maybeLoadMore = useInfiniteLoader(
async (_startIndex, _stopIndex, _currentItems) => {
if (hasMore && !isLoadingMore) {
await loadMoreDocuments()
}
},
{
isItemLoaded: (index, items) => !!items[index],
minimumBatchSize: 10,
threshold: 5,
},
)
const renderDocumentCard = useCallback(
({
index,
data,
width,
}: {
index: number
data: DocumentWithMemories
width: number
}) => (
<DocumentCard
index={index}
data={data}
width={width}
/>
),
[],
)
if (!user) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center text-muted-foreground">
<p>Please log in to view your memories</p>
</div>
</div>
)
}
return (
<div className="h-full">
<Button
className={cn(
dmSansClassName(),
"rounded-full border border-[#161F2C] bg-[#0D121A] px-4 py-2 data-[state=active]:bg-[#00173C] data-[state=active]:border-[#2261CA33] mb-4",
)}
data-state="active"
>
All
</Button>
{error ? (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
Error loading documents: {error.message}
</div>
</div>
) : isPending ? (
<div className="h-full flex items-center justify-center p-4">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 animate-spin text-blue-400" />
<span>Loading memory list...</span>
</div>
</div>
) : documents.length === 0 && !isPending ? (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
No memories found
</div>
</div>
) : (
<div className="h-full overflow-auto custom-scrollbar">
<Masonry
key={`masonry-${documents.length}-${documents.map((d) => d.id).join(",")}`}
items={documents}
render={renderDocumentCard}
columnGutter={16}
rowGutter={16}
columnWidth={216}
maxColumnCount={isMobile ? 1 : undefined}
itemHeightEstimate={200}
overscanBy={3}
onRender={maybeLoadMore}
/>
{isLoadingMore && (
<div className="py-8 flex items-center justify-center">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 animate-spin text-blue-400" />
<span>Loading more memories...</span>
</div>
</div>
)}
</div>
)}
</div>
)
}
const DocumentCard = memo(
({
index: _index,
data: document,
width,
}: {
index: number
data: DocumentWithMemories
width: number
}) => {
return (
<div className="rounded-[22px] bg-[#1B1F24] px-1 space-y-2 pt-1" style={{ width }}>
<ContentPreview document={document} />
<div className="pb-[10px] space-y-1">
<div className="px-3">
<p
className={cn(
dmSansClassName(),
"text-[12px] text-[#E5E5E5] line-clamp-1 font-semibold",
)}
>
{document.title}
</p>
{document.url && (
<p
className={cn(
dmSansClassName(),
"text-[10px] text-[#737373] line-clamp-1",
)}
>
{getAbsoluteUrl(document.url)}
</p>
)}
</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>
)
},
)
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") ||
document.type === "google_doc"
) {
return <GoogleDocsPreview document={document} />
}
// Check for Twitter
if (
document.url?.includes("x.com/") &&
document.metadata?.sm_internal_twitter_metadata
) {
return (
<TweetPreview
data={
document.metadata?.sm_internal_twitter_metadata as unknown as Tweet
}
/>
)
}
if (
document.type === "pdf" ||
document.type === "image" ||
document.type === "video" ||
document.metadata?.mimeType
) {
return <FilePreview document={document} />
}
// Check for Website
if (document.url?.includes("https://")) {
return <WebsitePreview document={document} />
}
// Default to Note
return <NotePreview document={document} />
}

View file

@ -1,3 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap');
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@ -11,12 +12,30 @@
.sm-tweet-theme .react-tweet-theme {
--tweet-container-margin: 0px;
font-size: inherit !important;
}
.sm-tweet-theme .react-tweet-theme * {
min-width: 0 !important;
}
.sm-tweet-theme .tweet-header-module__A9EVQG__authorFollow {
display: none;
}
.tweet-container-module__CmFQMq__article {
padding: 0 !important;
font-family: "DM Sans", sans-serif !important;
font-size: 10px !important;
--tweet-header-font-size: 10px !important;
--tweet-header-line-height: 1.25rem !important;
}
.tweet-body-module__ZNRZja__root > * {
font-size: 10px !important;
line-height: 0.25rem !important;
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #293952 transparent;

View file

@ -194,15 +194,15 @@ export const PDF = ({ className }: { className?: string }) => (
<title>PDF</title>
<path
d="M3 13h.86v-.9h.39c.62 0 1.14-.45 1.14-1.06s-.5-1.05-1.14-1.05H3v3Zm.86-1.59v-.72h.3c.2 0 .37.13.37.35s-.16.36-.37.36h-.3ZM6.19 13h1.19c1 0 1.62-.59 1.62-1.52C9 10.61 8.38 10 7.38 10H6.19zm.86-.71V10.7h.29c.33 0 .78.16.78.78c0 .65-.45.81-.78.81zM10 13h.86v-1.07h1.06v-.69h-1.06v-.54h1.21v-.69h-2.06v3Z"
fill="currentColor"
fill="#DC2626"
/>
<path
d="M12.5 16h-10c-.83 0-1.5-.67-1.5-1.5v-13C1 .67 1.67 0 2.5 0h7.09c.4 0 .78.16 1.06.44l2.91 2.91c.28.28.44.66.44 1.06V14.5c0 .83-.67 1.5-1.5 1.5M2.5 1c-.28 0-.5.22-.5.5v13c0 .28.22.5.5.5h10c.28 0 .5-.22.5-.5V4.41a.47.47 0 0 0-.15-.35L9.94 1.15A.5.5 0 0 0 9.59 1z"
fill="currentColor"
fill="#DC2626"
/>
<path
d="M13.38 5h-2.91C9.66 5 9 4.34 9 3.53V.62c0-.28.22-.5.5-.5s.5.22.5.5v2.91c0 .26.21.47.47.47h2.91c.28 0 .5.22.5.5s-.22.5-.5.5"
fill="currentColor"
fill="#DC2626"
/>
</svg>
);