mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-19 07:42:43 +00:00
354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
|
import Markdown from "react-markdown";
|
|
|
|
import { useNavigate } from "@remix-run/react";
|
|
|
|
import image from "./gradients/gradient1.png";
|
|
import { AddMemoryModal } from "./memories/AddMemory";
|
|
import { Button } from "./ui/button";
|
|
|
|
import { MemoryIcon, SpaceIcon } from "@supermemory/shared/icons";
|
|
import { motion } from "framer-motion";
|
|
import { BookIcon, BookOpen, ChevronDownIcon, SparkleIcon } from "lucide-react";
|
|
import { Theme, useTheme } from "~/lib/theme-provider";
|
|
|
|
function Reminder({ content, contentId }: { content: string; contentId: string }) {
|
|
const [theme] = useTheme();
|
|
const navigate = useNavigate();
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const [isOverflowing, setIsOverflowing] = useState(false);
|
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (contentRef.current) {
|
|
setIsOverflowing(contentRef.current.scrollHeight > contentRef.current.clientHeight);
|
|
}
|
|
}, [content]);
|
|
|
|
if (typeof window === "undefined") return null;
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
backgroundImage:
|
|
theme === Theme.DARK ? "linear-gradient(to right, #414345, #232526)" : `url(${image})`,
|
|
backgroundSize: "cover",
|
|
backgroundPosition: "right",
|
|
}}
|
|
className="border border-slate-500 rounded-2xl md:rounded-3xl flex flex-col gap-2 p-4 md:p-6 md:px-8 dark:bg-opacity-50 h-[500px]"
|
|
>
|
|
<div className="flex items-center gap-2 text-neutral-600 dark:text-neutral-300">
|
|
<SparkleIcon className="w-3 h-3 md:w-4 md:h-4 flex-shrink-0" />
|
|
<h2 className="text-xs md:text-sm truncate">Remember this? A topic you forgot.</h2>
|
|
</div>
|
|
|
|
<div ref={contentRef} className={`relative ${isExpanded ? "flex-1" : "h-[400px]"}`}>
|
|
<div className={`${!isExpanded && "absolute inset-0"} overflow-hidden`}>
|
|
<Markdown className="mt-2 prose prose-lg dark:prose-invert leading-tight break-words line-clamp-[15] prose-h1:text-2xl prose-h1:font-semibold prose-h1:tracking-tight prose-h2:text-lg prose-h3:text-base">
|
|
{content}
|
|
</Markdown>
|
|
</div>
|
|
{!isExpanded && isOverflowing && (
|
|
<div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-white dark:from-gray-900 to-transparent" />
|
|
)}
|
|
</div>
|
|
|
|
{isOverflowing && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="w-full text-xs flex items-center gap-1 py-1"
|
|
>
|
|
{isExpanded ? "Show less" : "Show more"}
|
|
<ChevronDownIcon
|
|
className={`w-4 h-4 transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
|
/>
|
|
</Button>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-2 md:gap-4 mt-auto">
|
|
<Button
|
|
onClick={() => navigate(`/content/${contentId}`)}
|
|
className="dark flex items-center justify-center gap-1 md:gap-2 border text-xs md:text-sm py-2 min-h-[36px]"
|
|
>
|
|
<BookIcon
|
|
fill="var(--gray-10)"
|
|
stroke="var(--gray-5)"
|
|
className="w-3 h-3 md:w-4 md:h-4 flex-shrink-0"
|
|
/>
|
|
<span className="truncate">
|
|
View <span className="hidden md:inline">content</span>
|
|
</span>
|
|
</Button>
|
|
<Button className="dark flex items-center justify-center gap-1 md:gap-2 border text-xs md:text-sm py-2 min-h-[36px]">
|
|
<SpaceIcon className="w-3 h-3 md:w-4 md:h-4 flex-shrink-0" />
|
|
<span className="truncate">
|
|
Show <span className="hidden md:inline">related</span>
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LoadingReminder() {
|
|
return (
|
|
<div className="border border-slate-500 rounded-2xl md:rounded-3xl flex flex-col gap-2 p-4 md:p-6 md:px-8 dark:bg-opacity-50 h-[500px] animate-pulse bg-gray-100 dark:bg-gray-800">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
|
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-48" />
|
|
</div>
|
|
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-lg mt-2" />
|
|
<div className="grid grid-cols-2 gap-2 md:gap-4">
|
|
<div className="h-9 bg-gray-300 dark:bg-gray-600 rounded" />
|
|
<div className="h-9 bg-gray-300 dark:bg-gray-600 rounded" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Reminders() {
|
|
const [suggestedLearnings, setSuggestedLearnings] = useState<Array<Record<string, string>>>([]);
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
fetch(`/backend/v1/suggested-learnings`, {
|
|
credentials: "include",
|
|
})
|
|
.then((res) => res.json() as Promise<{ suggestedLearnings: Array<Record<string, string>> }>)
|
|
.then((data) => {
|
|
setSuggestedLearnings(data.suggestedLearnings);
|
|
setIsLoading(false);
|
|
})
|
|
.catch((err) => {
|
|
console.error("Failed to fetch suggested learnings:", err);
|
|
setIsLoading(false);
|
|
});
|
|
}, []);
|
|
|
|
if (typeof window === "undefined") return null;
|
|
|
|
const handleDragEnd = (event: any, info: any) => {
|
|
const DRAG_THRESHOLD = window?.innerWidth * 0.15;
|
|
|
|
if (Math.abs(info.offset.x) > DRAG_THRESHOLD) {
|
|
const direction = info.offset.x > 0 ? -1 : 1;
|
|
handleNavigate(direction);
|
|
}
|
|
};
|
|
|
|
const handleNavigate = (direction: number) => {
|
|
setCurrentIndex((prevIndex) => {
|
|
const newIndex = prevIndex + direction;
|
|
if (newIndex < 0) return suggestedLearnings.length - 1;
|
|
if (newIndex >= suggestedLearnings.length) return 0;
|
|
return newIndex;
|
|
});
|
|
};
|
|
|
|
if (!isLoading && (!suggestedLearnings || suggestedLearnings.length === 0)) {
|
|
return (
|
|
<div className="relative h-[600px] w-full flex items-center justify-center">
|
|
<div className="text-center max-w-lg mx-auto px-4">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.5 }}
|
|
>
|
|
<SparkleIcon className="w-12 h-12 mx-auto mb-6 text-blue-500" />
|
|
<h2 className="text-2xl font-bold mb-4 bg-gradient-to-r from-blue-500 to-purple-500 bg-clip-text text-transparent">
|
|
Your Memory Hub Awaits
|
|
</h2>
|
|
<p className="text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
|
|
Start saving content to get bite-sized memory snippets that keep you on top of your
|
|
game. We'll transform your notes into smart reminders that help you retain knowledge
|
|
effortlessly.
|
|
</p>
|
|
<AddMemoryModal>
|
|
<Button className="bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:from-blue-600 hover:to-purple-600 transition-all">
|
|
<BookOpen className="w-4 h-4 mr-2" />
|
|
Add Your First Memory
|
|
</Button>
|
|
</AddMemoryModal>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="relative h-[600px] w-full overflow-x-hidden md:overflow-visible overflow-y-hidden"
|
|
role="region"
|
|
aria-label="Memory reminders stack"
|
|
>
|
|
<div className="relative w-full h-full translate-y-16">
|
|
{isLoading
|
|
? // Show 3 loading cards when loading
|
|
[...Array(3)]?.map((_, index) => {
|
|
const xOffset = index * (window?.innerWidth < 768 ? 12 : 16);
|
|
const yOffset = index * (window?.innerWidth < 768 ? 12 : 16);
|
|
const opacity = Math.max(1 - index * 0.15, 0.4);
|
|
const scale = 1 - index * (window?.innerWidth < 768 ? 0.02 : 0.03);
|
|
|
|
return (
|
|
<motion.div
|
|
key={`loading-${index}`}
|
|
className="absolute w-full left-0 top-0 px-4 md:px-0"
|
|
style={{ zIndex: 3 - index }}
|
|
animate={{
|
|
x: xOffset,
|
|
y: yOffset,
|
|
opacity,
|
|
scale,
|
|
}}
|
|
>
|
|
<LoadingReminder />
|
|
</motion.div>
|
|
);
|
|
})
|
|
: suggestedLearnings?.map((learning, index) => {
|
|
const position =
|
|
(index - currentIndex + suggestedLearnings.length) % suggestedLearnings.length;
|
|
|
|
if (position >= 3) return null;
|
|
|
|
if (typeof window === "undefined") return null;
|
|
|
|
const contentId = Object.keys(learning)[0];
|
|
const content = learning[contentId];
|
|
|
|
const zIndex = suggestedLearnings.length - position;
|
|
const xOffset = position * (window?.innerWidth < 768 ? 12 : 16);
|
|
const yOffset = position * (window?.innerWidth < 768 ? 12 : 16);
|
|
const opacity = Math.max(1 - position * 0.15, 0.4);
|
|
const scale = 1 - position * (window?.innerWidth < 768 ? 0.02 : 0.03);
|
|
|
|
return (
|
|
<motion.div
|
|
key={index}
|
|
className="absolute w-full left-0 top-0 cursor-grab active:cursor-grabbing px-4 md:px-0"
|
|
style={{ zIndex }}
|
|
animate={{
|
|
x: xOffset,
|
|
y: yOffset,
|
|
opacity,
|
|
scale,
|
|
}}
|
|
transition={{
|
|
type: "spring",
|
|
stiffness: 200,
|
|
damping: 25,
|
|
mass: 1.4,
|
|
restDelta: 0.0001,
|
|
restSpeed: 0.0001,
|
|
}}
|
|
drag={position === 0 ? "x" : false}
|
|
dragConstraints={{ left: 0, right: 0 }}
|
|
dragElastic={0.7}
|
|
dragTransition={{
|
|
bounceStiffness: 200,
|
|
bounceDamping: 25,
|
|
power: 0.3,
|
|
}}
|
|
onDragEnd={position === 0 ? handleDragEnd : undefined}
|
|
whileHover={
|
|
position === 0 && window?.innerWidth >= 768
|
|
? {
|
|
transition: {
|
|
duration: 0.3,
|
|
ease: [0.4, 0, 0.2, 1],
|
|
},
|
|
}
|
|
: undefined
|
|
}
|
|
role="group"
|
|
aria-label={`Memory card ${index + 1} of ${suggestedLearnings.length}`}
|
|
>
|
|
<Reminder content={content} contentId={contentId} />
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Navigation controls - Hidden on mobile since we use swipe */}
|
|
{!isLoading && suggestedLearnings.length > 0 && (
|
|
<div className="hidden md:flex absolute bottom-[-48px] left-1/2 transform -translate-x-1/2 items-center gap-4 z-50">
|
|
<motion.button
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
|
onClick={() => handleNavigate(-1)}
|
|
className="p-2 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-800 dark:hover:bg-gray-700"
|
|
aria-label="Previous card"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="m15 18-6-6 6-6" />
|
|
</svg>
|
|
</motion.button>
|
|
|
|
<div className="flex gap-2">
|
|
{suggestedLearnings?.map((_, index) => (
|
|
<motion.button
|
|
key={index}
|
|
whileHover={{ scale: 1.2 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
|
onClick={() => handleNavigate(index - currentIndex)}
|
|
className={`w-1.5 h-1.5 rounded-full transition-colors ${
|
|
index === currentIndex ? "bg-blue-500" : "bg-gray-300"
|
|
} hover:bg-blue-400`}
|
|
aria-label={`Go to card ${index + 1}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<motion.button
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
|
onClick={() => handleNavigate(1)}
|
|
className="p-2 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-800 dark:hover:bg-gray-700"
|
|
aria-label="Next card"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="m9 18 6-6-6-6" />
|
|
</svg>
|
|
</motion.button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile swipe indicator */}
|
|
{!isLoading && suggestedLearnings.length > 0 && (
|
|
<div className="md:hidden absolute bottom-[-24px] left-1/2 transform -translate-x-1/2 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
|
Swipe to navigate • {currentIndex + 1}/{suggestedLearnings.length}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Reminders;
|