supermemory/apps/web/app/components/Reminders.tsx
Dhravya Shah 05434b1c57 redepl
2025-01-30 21:18:18 -07:00

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;