mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-22 11:08:50 +00:00
added memory cards and chat
This commit is contained in:
parent
ecfe9ddee5
commit
df173d90b0
11 changed files with 983 additions and 8 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
204
apps/web/components/new/chat-sidebar.tsx
Normal file
204
apps/web/components/new/chat-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
101
apps/web/components/new/document-cards/file-preview.tsx
Normal file
101
apps/web/components/new/document-cards/file-preview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
97
apps/web/components/new/document-cards/note-preview.tsx
Normal file
97
apps/web/components/new/document-cards/note-preview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
39
apps/web/components/new/document-cards/tweet-preview.tsx
Normal file
39
apps/web/components/new/document-cards/tweet-preview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
48
apps/web/components/new/document-cards/website-preview.tsx
Normal file
48
apps/web/components/new/document-cards/website-preview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
372
apps/web/components/new/memories-grid.tsx
Normal file
372
apps/web/components/new/memories-grid.tsx
Normal 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} />
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue