mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
merged latest changes with queue branch and ready for prod
This commit is contained in:
commit
be3e13b4bf
28 changed files with 1311 additions and 298 deletions
|
|
@ -21,7 +21,7 @@ Interested in helping build the best second brain for everyone? Join the discord
|
|||
|
||||
## 👀 What is this?
|
||||
|
||||
Build your own second brain with supermemory. It's a ChatGPT for your bookmarks. Import tweets or save websites and content using the [chrome extension](https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc?hl=en-GB&authuser=0)
|
||||
Build your own second brain with supermemory. It's a ChatGPT for your bookmarks. Import tweets or save websites and content using the [Chrome extension](https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc?hl=en-GB&authuser=0)
|
||||
|
||||
Well, here's the thing - me and @yxshv save a _lot_ of content on the internet.
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ To use the chrome extension,
|
|||
<img width="480" alt="image" src="https://github.com/MaheshtheDev/supermemory/assets/38828053/2efb06a5-912a-48e7-ad1c-d527e7ffbc94">
|
||||
|
||||
3. Click on save button and give it 10 - 20 secs, where supermemory extension will sync all your twitter bookmarks to supermemory.ai
|
||||
4. Volia! Now your second brain has all your twitter bookmarks.
|
||||
4. Voila! Now your second brain has all your twitter bookmarks.
|
||||
|
||||
## 👨💻 The Stack
|
||||
|
||||
|
|
@ -88,11 +88,11 @@ The database, auth etc logic is here
|
|||
|
||||
Built with:
|
||||
|
||||
- Nextjs 14
|
||||
- [Nextjs 14](https://nextjs.org/)
|
||||
- [Next Auth](https://next-auth.js.org/)
|
||||
- [Drizzle ORM](https://drizzle.team/)
|
||||
- [Cloudflare D1 database](https://developers.cloudflare.com/d1/get-started/)
|
||||
- Cloudflare ratelimiter
|
||||
- [Cloudflare ratelimiter](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/)
|
||||
- [TailwindCSS](https://tailwindcss.com)
|
||||
- [shadcn-ui](https://ui.shadcn.com)
|
||||
- And some other amazing open source projects like [Novel](https://novel.sh) and [vaul](https://vaul.emilkowal.ski/)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Self Hosting Guide
|
||||
|
||||
This guide will help you set up your own instance of Supermemory. This is neccessary if you want to contribute to the project or if you want to self host the project. You can read more about the stack [here](https://github.com/supermemoryai/supermemory/?tab=readme-ov-file#-the-stack).
|
||||
This guide will help you set up your own instance of Supermemory. This is necessary if you want to contribute to the project or if you want to self host the project. You can read more about the stack [here](https://github.com/supermemoryai/supermemory/?tab=readme-ov-file#-the-stack).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@
|
|||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/zod-validator": "^0.2.1",
|
||||
"hono": "^4.5.1"
|
||||
"hono": "^4.5.1",
|
||||
"honox": "^0.1.23",
|
||||
"vite": "^5.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240614.0"
|
||||
}
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240614.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ export async function batchCreateChunksAndEmbeddings({
|
|||
vectors.push(...batchVectors);
|
||||
}
|
||||
console.log(
|
||||
"vector Id list: ",
|
||||
vectors.map((vector) => {
|
||||
return vector.id;
|
||||
}),
|
||||
|
|
@ -185,6 +186,7 @@ export async function batchCreateChunksAndEmbeddings({
|
|||
const results = [];
|
||||
for (let i = 0; i < newVectors.length; i += 20) {
|
||||
results.push(newVectors.slice(i, i + 20));
|
||||
console.log(newVectors);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
|
|
|
|||
|
|
@ -710,6 +710,47 @@ app.get(
|
|||
},
|
||||
);
|
||||
|
||||
app.get("/howFuckedAreWe", async (c) => {
|
||||
let keys = 0;
|
||||
const concurrencyLimit = 5; // Adjust this based on your system's capability
|
||||
const queue: string[] = [undefined]; // Start with an undefined cursor
|
||||
|
||||
async function fetchKeys(cursor?: string): Promise<void> {
|
||||
const response = await c.env.KV.list({ cursor });
|
||||
keys += response.keys.length;
|
||||
|
||||
// @ts-ignore
|
||||
if (response.cursor) {
|
||||
// @ts-ignore
|
||||
queue.push(response.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllKeys(): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
while (promises.length < concurrencyLimit && queue.length > 0) {
|
||||
const cursor = queue.shift();
|
||||
promises.push(fetchKeys(cursor));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
promises.length = 0; // Clear the promises array
|
||||
}
|
||||
}
|
||||
|
||||
await getAllKeys();
|
||||
|
||||
console.log(`Total number of keys: ${keys}`);
|
||||
|
||||
// on a scale of 200,000
|
||||
// what % are we there?
|
||||
const fuckedPercent = (keys / 200000) * 100;
|
||||
|
||||
return c.json({ fuckedPercent });
|
||||
});
|
||||
|
||||
export default {
|
||||
fetch: app.fetch,
|
||||
queue,
|
||||
|
|
|
|||
|
|
@ -22,16 +22,26 @@ export interface ThreadTweetData {
|
|||
}
|
||||
|
||||
export function chunkThread(threadText: string): TweetChunks {
|
||||
const thread = JSON.parse(threadText);
|
||||
if (typeof thread == "string") {
|
||||
console.log("DA WORKER FAILED DO SOMEHTING FIX DA WROKER");
|
||||
let thread = threadText;
|
||||
|
||||
try {
|
||||
thread = JSON.parse(threadText);
|
||||
} catch (e) {
|
||||
console.log("error: thread is not json.", e);
|
||||
}
|
||||
|
||||
if (typeof threadText == "string") {
|
||||
console.log("DA WORKER FAILED DO SOMEHTING FIX DA WROKER", thread);
|
||||
const rawTweet = getRawTweet(thread);
|
||||
console.log(rawTweet);
|
||||
const parsedTweet: any = JSON.parse(rawTweet);
|
||||
|
||||
const chunkedTweet = chunkText(parsedTweet.text, 1536);
|
||||
const metadata: Metadata = {
|
||||
tweetId: parsedTweet.id_str,
|
||||
tweetLinks: parsedTweet.entities.urls.map((url: any) => url.expanded_url),
|
||||
tweetLinks: parsedTweet.entities?.urls.map(
|
||||
(url: any) => url.expanded_url,
|
||||
),
|
||||
tweetVids:
|
||||
parsedTweet.extended_entities?.media
|
||||
.filter((media: any) => media.type === "video")
|
||||
|
|
@ -46,8 +56,8 @@ export function chunkThread(threadText: string): TweetChunks {
|
|||
|
||||
return { type: "tweet", chunks };
|
||||
} else {
|
||||
console.log(JSON.stringify(thread));
|
||||
const chunkedTweets = thread.map((tweet: Tweet) => {
|
||||
console.log("thread in else statement", JSON.stringify(thread));
|
||||
const chunkedTweets = (thread as any).map((tweet: Tweet) => {
|
||||
const chunkedTweet = chunkText(tweet.text, 1536);
|
||||
|
||||
const metadata = {
|
||||
|
|
|
|||
|
|
@ -131,6 +131,13 @@ export default function ContentApp({
|
|||
});
|
||||
}
|
||||
});
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isPopoverOpen) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown, true);
|
||||
|
||||
const portalDiv = document.createElement("div");
|
||||
portalDiv.id = "popover-portal";
|
||||
|
|
@ -139,6 +146,7 @@ export default function ContentApp({
|
|||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", () => {});
|
||||
document.removeEventListener("keydown", handleKeyDown, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function transformTweetData(input: any): Tweet | null {
|
|||
display_text_range: tweet.legacy.display_text_range,
|
||||
entities: {
|
||||
hashtags: tweet.legacy.entities.hashtags,
|
||||
urls: tweet.legacy.entities.urls,
|
||||
urls: tweet.legacy.entities?.urls,
|
||||
user_mentions: tweet.legacy.entities.user_mentions,
|
||||
symbols: tweet.legacy.entities.symbols,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -392,6 +392,24 @@ function Navbar() {
|
|||
const router = useRouter();
|
||||
const handleSkip = async () => {
|
||||
await completeOnboarding();
|
||||
toast.info("Creating memory...", {
|
||||
icon: <PlusCircleIcon className="w-4 h-4 text-white animate-spin" />,
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
const cont = await createMemory({
|
||||
content: "https://supermemory.ai",
|
||||
spaces: undefined,
|
||||
});
|
||||
|
||||
if (cont.success) {
|
||||
toast.success("Memory created", {
|
||||
richColors: true,
|
||||
});
|
||||
} else {
|
||||
toast.error(`Memory creation failed: ${cont.error}`);
|
||||
}
|
||||
|
||||
router.push("/home?q=what%20is%20supermemory");
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@ async function Signin({
|
|||
}: {
|
||||
searchParams: Record<string, string>;
|
||||
}) {
|
||||
const telegramUser = searchParams.telegramUser;
|
||||
const user = await auth();
|
||||
|
||||
if (user) {
|
||||
redirect("/home");
|
||||
redirect(`/home` + (telegramUser ? `?telegramUser=${telegramUser}` : ""));
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import Masonry from "react-layout-masonry";
|
||||
import { getRawTweet } from "@repo/shared-types/utils";
|
||||
import { MyTweet } from "../../../components/twitter/render-tweet";
|
||||
|
|
@ -20,16 +20,19 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@repo/ui/shadcn/dropdown-menu";
|
||||
import { Button } from "@repo/ui/shadcn/button";
|
||||
import { addUserToSpace, deleteItem, moveItem } from "@/app/actions/doers";
|
||||
import {
|
||||
addUserToSpace,
|
||||
deleteItem,
|
||||
deleteSpace,
|
||||
moveItem,
|
||||
} from "@/app/actions/doers";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "@repo/ui/shadcn/input";
|
||||
import { motion } from "framer-motion";
|
||||
|
|
@ -59,6 +62,39 @@ export function MemoriesPage({
|
|||
}, [tab]);
|
||||
|
||||
const [filter, setFilter] = useState(initialFilter);
|
||||
const [spaces, setSpaces] = useState<StoredSpace[]>(memoriesAndSpaces.spaces);
|
||||
|
||||
// to delete a space
|
||||
const handleDeleteSpace = async (id: number) => {
|
||||
const response = await deleteSpace(id);
|
||||
|
||||
if (response?.success) {
|
||||
setSpaces(spaces.filter((space) => space.id !== id));
|
||||
toast.success("Space deleted");
|
||||
} else {
|
||||
toast.error("Failed to delete space");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const dataToExport = sortedItems.map((item) => ({
|
||||
type: item.item,
|
||||
date: new Date(item.date).toISOString(),
|
||||
data: item.data,
|
||||
}));
|
||||
|
||||
const json = JSON.stringify(dataToExport, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "memories_and_spaces.json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Sort Both memories and spaces by their savedAt and createdAt dates respectfully.
|
||||
// The output should be just one single list of items
|
||||
|
|
@ -71,7 +107,7 @@ export function MemoriesPage({
|
|||
date: new Date(memory.savedAt), // Assuming savedAt is a string date
|
||||
data: memory,
|
||||
})),
|
||||
...memoriesAndSpaces.spaces.map((space) => ({
|
||||
...spaces.map((space) => ({
|
||||
item: "space",
|
||||
date: new Date(space.createdAt), // Assuming createdAt is a string date
|
||||
data: space,
|
||||
|
|
@ -103,7 +139,7 @@ export function MemoriesPage({
|
|||
return false;
|
||||
})
|
||||
.sort((a, b) => b.date - a.date);
|
||||
}, [memoriesAndSpaces.memories, memoriesAndSpaces.spaces, filter]);
|
||||
}, [memoriesAndSpaces.memories, spaces, filter]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -172,13 +208,21 @@ export function MemoriesPage({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<Filters
|
||||
setFilter={setFilter}
|
||||
filter={filter}
|
||||
filterMethods={
|
||||
currentSpace ? SpaceFilterMethods : MemoriesFilterMethods
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-between w-full">
|
||||
<Filters
|
||||
setFilter={setFilter}
|
||||
filter={filter}
|
||||
filterMethods={
|
||||
currentSpace ? SpaceFilterMethods : MemoriesFilterMethods
|
||||
}
|
||||
/>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className={`transition px-6 py-2 rounded-xl hover:text-[#369DFD]" text-[#B3BCC5] bg-secondary hover:bg-secondary hover:text-[#76a3cc]`}
|
||||
>
|
||||
JSON Export
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Masonry
|
||||
className="mt-6 relative"
|
||||
|
|
@ -216,6 +260,7 @@ export function MemoriesPage({
|
|||
title={(item.data as StoredSpace).name}
|
||||
description={`${(item.data as StoredSpace).numItems} memories`}
|
||||
id={(item.data as StoredSpace).id}
|
||||
handleDeleteSpace={handleDeleteSpace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -231,34 +276,45 @@ function TabComponent({
|
|||
title,
|
||||
description,
|
||||
id,
|
||||
handleDeleteSpace,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
id: number;
|
||||
handleDeleteSpace: (id: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/space/${id}`}
|
||||
className="flex flex-col gap-4 bg-[#161f2a]/30 backdrop-blur-md border-2 border-border w-full rounded-xl p-4"
|
||||
>
|
||||
<div className="flex group flex-col gap-4 bg-[#161f2a]/30 backdrop-blur-md border-2 border-border w-full rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Image alt="Spaces icon" src={MemoriesIcon} className="size-3" /> Space
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<div className="h-12 w-12 flex justify-center items-center rounded-md">
|
||||
{title.slice(0, 2).toUpperCase()} {id}
|
||||
|
||||
<div>
|
||||
<Link
|
||||
href={`/space/${id}`}
|
||||
className="flex items-center justify-between w-full"
|
||||
>
|
||||
<div>
|
||||
<div className="h-12 w-12 flex justify-center items-center rounded-md">
|
||||
{title.slice(0, 2).toUpperCase()} {id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow px-4">
|
||||
<div className="text-lg text-[#fff] line-clamp-2">{title}</div>
|
||||
<div>{description}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Image src={NextIcon} alt="Search icon" />
|
||||
<div className="grow px-2">
|
||||
<div className="text-lg text-[#fff] line-clamp-2">{title}</div>
|
||||
<div>{description}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Image src={NextIcon} alt="Search icon" />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="absolute z-40 right-3 top-3 opacity-0 group-hover:opacity-100 hover:text-red-600">
|
||||
<TrashIcon
|
||||
onClick={() => handleDeleteSpace(id)}
|
||||
className="w-4 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import QueryInput from "./chatQueryInput";
|
|||
import { cn } from "@repo/ui/lib/utils";
|
||||
import { motion } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChatHistory, sourcesZod } from "@repo/shared-types";
|
||||
import { type ChatHistory, sourcesZod } from "@repo/shared-types";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
|
|
@ -23,7 +23,11 @@ import { codeLanguageSubset } from "@/lib/constants";
|
|||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import { createChatObject } from "@/app/actions/doers";
|
||||
import { ClipboardIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ClipboardIcon,
|
||||
SpeakerWaveIcon,
|
||||
SpeakerXMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
function ChatWindow({
|
||||
q,
|
||||
|
|
@ -51,6 +55,8 @@ function ChatWindow({
|
|||
}) {
|
||||
const [layout, setLayout] = useState<"chat" | "initial">("chat");
|
||||
const [chatHistory, setChatHistory] = useState<ChatHistory[]>(initialChat);
|
||||
const [speakingIdx, setSpeakingIdx] = useState<number | null>(null);
|
||||
const speechSynth: SpeechSynthesis = window.speechSynthesis;
|
||||
|
||||
const removeJustificationFromText = (text: string) => {
|
||||
// remove everything after the first "<justification>" word
|
||||
|
|
@ -66,12 +72,31 @@ function ChatWindow({
|
|||
return text;
|
||||
};
|
||||
|
||||
const handleTTS = (text: string, idx: number) => {
|
||||
if (speakingIdx != null) return stopTTS();
|
||||
if (!text) return;
|
||||
const utterThis: SpeechSynthesisUtterance = new SpeechSynthesisUtterance(
|
||||
text,
|
||||
);
|
||||
utterThis.lang = "en-US";
|
||||
speechSynth.speak(utterThis);
|
||||
setSpeakingIdx(idx);
|
||||
utterThis.onend = () => {
|
||||
setSpeakingIdx(null);
|
||||
};
|
||||
};
|
||||
|
||||
const stopTTS = () => {
|
||||
speechSynth.cancel();
|
||||
setSpeakingIdx(null);
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const getAnswer = async (
|
||||
query: string,
|
||||
spaces: string[],
|
||||
proMode: boolean = false,
|
||||
proMode = false,
|
||||
) => {
|
||||
if (query.trim() === "from_loading" || query.trim().length === 0) {
|
||||
return;
|
||||
|
|
@ -263,10 +288,10 @@ function ChatWindow({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col mt-2">
|
||||
<div>
|
||||
<div className="flex flex-col mt-2 w-full">
|
||||
<div className="w-full">
|
||||
<div className="text-foreground-menu py-2">Answer</div>
|
||||
<div className="text-base">
|
||||
<div className="text-base prose prose-invert prose-headings:py-0 prose-h1:py-4 prose-h2:py-4 prose-headings:-my-2 prose-h1:text-2xl prose-h2:text-xl prose:min-w-full min-w-full">
|
||||
{/* Loading state */}
|
||||
{(chat.answer.parts.length === 0 ||
|
||||
chat.answer.parts.join("").length === 0) && (
|
||||
|
|
@ -291,11 +316,7 @@ function ChatWindow({
|
|||
},
|
||||
],
|
||||
]}
|
||||
components={{
|
||||
code: code as any,
|
||||
p: p as any,
|
||||
}}
|
||||
className="flex flex-col gap-2 text-base"
|
||||
className="flex flex-col gap-2 w-full"
|
||||
>
|
||||
{removeJustificationFromText(
|
||||
chat.answer.parts
|
||||
|
|
@ -305,10 +326,6 @@ function ChatWindow({
|
|||
</Markdown>
|
||||
|
||||
<div className="mt-3 relative -left-2 flex items-center gap-1">
|
||||
{/* TODO: speak response */}
|
||||
{/* <button className="group h-8 w-8 flex justify-center items-center active:scale-75 duration-200">
|
||||
<SpeakerWaveIcon className="size-[18px] group-hover:text-primary" />
|
||||
</button> */}
|
||||
{/* copy response */}
|
||||
<button
|
||||
onClick={() =>
|
||||
|
|
@ -322,6 +339,27 @@ function ChatWindow({
|
|||
>
|
||||
<ClipboardIcon className="size-[18px] group-hover:text-primary" />
|
||||
</button>
|
||||
{/* speak response */}
|
||||
<button
|
||||
disabled={
|
||||
speakingIdx !== null && speakingIdx !== idx
|
||||
}
|
||||
onClick={() => {
|
||||
handleTTS(
|
||||
chat.answer.parts
|
||||
.map((part) => part.text)
|
||||
.join(""),
|
||||
idx,
|
||||
);
|
||||
}}
|
||||
className="group h-8 w-8 flex justify-center items-center active:scale-75 duration-200"
|
||||
>
|
||||
{speakingIdx === idx ? (
|
||||
<SpeakerXMarkIcon className="size-[18px] group-hover:text-primary" />
|
||||
) : (
|
||||
<SpeakerWaveIcon className="size-[18px] group-hover:text-primary group-disabled:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -342,10 +380,10 @@ function ChatWindow({
|
|||
</AccordionTrigger>
|
||||
{/* TODO: fade out content on the right side, the fade goes away when the user scrolls */}
|
||||
<AccordionContent
|
||||
className="flex flex-col no-scrollbar overflow-auto gap-4 relative max-w-3xl no-scrollbar"
|
||||
className="flex flex-col gap-4 relative max-w-3xl overflow-x-auto scrollbar-thin scrollbar-thumb-scrollbar-thumb scrollbar-track-scrollbar-track scrollbar-thumb-rounded"
|
||||
defaultChecked
|
||||
>
|
||||
<div className="w-full no-scrollbar flex gap-4">
|
||||
<div className="w-full flex gap-3">
|
||||
{/* Loading state */}
|
||||
{chat.answer.sources.length > 0 ||
|
||||
(chat.answer.parts.length === 0 && (
|
||||
|
|
@ -373,7 +411,9 @@ function ChatWindow({
|
|||
<span>{source.type}</span>
|
||||
|
||||
{source.numChunks > 1 && (
|
||||
<span>{source.numChunks} chunks</span>
|
||||
<span className="font-bold">
|
||||
{source.numChunks} chunks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-base">
|
||||
|
|
@ -388,40 +428,36 @@ function ChatWindow({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{chat.answer.justification &&
|
||||
chat.answer.justification.length && (
|
||||
<div
|
||||
className={`${chat.answer.justification && chat.answer.justification.length > 0 ? "flex" : "hidden"}`}
|
||||
{chat.answer.justification?.length && (
|
||||
<div
|
||||
className={`${chat.answer.justification && chat.answer.justification.length > 0 ? "flex" : "hidden"}`}
|
||||
>
|
||||
<Accordion
|
||||
defaultValue={""}
|
||||
type="single"
|
||||
collapsible
|
||||
>
|
||||
<Accordion
|
||||
defaultValue={""}
|
||||
type="single"
|
||||
collapsible
|
||||
>
|
||||
<AccordionItem value="justification">
|
||||
<AccordionTrigger className="text-foreground-menu">
|
||||
Justification
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
className="relative flex gap-2 max-w-3xl overflow-auto no-scrollbar"
|
||||
defaultChecked
|
||||
>
|
||||
{chat.answer.justification.length > 0
|
||||
? chat.answer.justification
|
||||
.replaceAll(
|
||||
"<justification>",
|
||||
"",
|
||||
)
|
||||
.replaceAll(
|
||||
"</justification>",
|
||||
"",
|
||||
)
|
||||
: "No justification provided."}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
<AccordionItem value="justification">
|
||||
<AccordionTrigger className="text-foreground-menu">
|
||||
Justification
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
className="relative flex gap-2 max-w-3xl overflow-auto no-scrollbar"
|
||||
defaultChecked
|
||||
>
|
||||
{chat.answer.justification.length > 0
|
||||
? chat.answer.justification
|
||||
.replaceAll("<justification>", "")
|
||||
.replaceAll(
|
||||
"</justification>",
|
||||
"",
|
||||
)
|
||||
: "No justification provided."}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ function Page({ searchParams }: { searchParams: Record<string, string> }) {
|
|||
}}
|
||||
>
|
||||
<motion.h1
|
||||
className="text-center mx-auto bg-[linear-gradient(180deg,_#FFF_0%,_rgba(255,_255,_255,_0.00)_202.08%)] bg-clip-text text-4xl tracking-tighter text-transparent md:text-5xl"
|
||||
className="text-center mx-auto bg-[linear-gradient(180deg,_#FFF_0%,_rgba(255,_255,_255,_0.00)_202.08%)] bg-clip-text text-4xl tracking-tighter text-transparent md:text-5xl pb-1"
|
||||
animate={{
|
||||
opacity: query.length ? 0 : 1,
|
||||
translateY: query.length ? "10px" : "0px",
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ function QueryInput({
|
|||
initialSpaces={initialSpaces || []}
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-[#369DFD1A]">
|
||||
{/* <div className="flex items-center gap-2 p-2 rounded-lg bg-[#369DFD1A]">
|
||||
<Label htmlFor="pro-mode" className="text-sm">
|
||||
Pro mode
|
||||
</Label>
|
||||
|
|
@ -84,7 +84,7 @@ function QueryInput({
|
|||
id="pro-mode"
|
||||
about="Pro mode"
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
<button type="submit" className="rounded-lg bg-[#369DFD1A] p-3">
|
||||
<Image src={ArrowRightIcon} alt="Enter" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { createMemory, createSpace } from "../actions/doers";
|
|||
import ComboboxWithCreate from "@repo/ui/shadcn/combobox";
|
||||
import { StoredSpace } from "@repo/db/schema";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { useKeyPress } from "@/lib/useKeyPress";
|
||||
|
||||
function Menu() {
|
||||
const [spaces, setSpaces] = useState<StoredSpace[]>([]);
|
||||
|
|
@ -48,7 +49,11 @@ function Menu() {
|
|||
setSpaces(spaces.data);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useKeyPress("a", () => {
|
||||
if (!dialogOpen) {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
});
|
||||
const menuItems = [
|
||||
{
|
||||
icon: HomeIconWeb,
|
||||
|
|
@ -106,33 +111,23 @@ function Menu() {
|
|||
|
||||
const handleSubmit = async (content?: string, spaces?: number[]) => {
|
||||
setDialogOpen(false);
|
||||
|
||||
toast.info("Creating memory...", {
|
||||
icon: <PlusCircleIcon className="w-4 h-4 text-white animate-spin" />,
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
if (!content || content.length === 0) {
|
||||
toast.error("Content is required");
|
||||
return;
|
||||
throw new Error("Content is required");
|
||||
}
|
||||
|
||||
console.log(spaces);
|
||||
|
||||
const cont = await createMemory({
|
||||
content: content,
|
||||
spaces: spaces ?? undefined,
|
||||
});
|
||||
|
||||
setContent("");
|
||||
setSelectedSpaces([]);
|
||||
|
||||
if (cont.success) {
|
||||
toast.success("Memory queued", {
|
||||
richColors: true,
|
||||
});
|
||||
} else {
|
||||
toast.error(`Memory creation failed: ${cont.error}`);
|
||||
throw new Error(`Memory creation failed: ${cont.error}`);
|
||||
return cont;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -188,8 +183,17 @@ function Menu() {
|
|||
<form
|
||||
action={async (e: FormData) => {
|
||||
const content = e.get("content")?.toString();
|
||||
|
||||
await handleSubmit(content, selectedSpaces);
|
||||
toast.promise(handleSubmit(content, selectedSpaces), {
|
||||
loading: (
|
||||
<span>
|
||||
<PlusCircleIcon className="w-4 h-4 inline mr-2 text-white animate-spin" />{" "}
|
||||
Creating memory...
|
||||
</span>
|
||||
),
|
||||
success: (data) => "Memory created",
|
||||
error: (error) => error.message,
|
||||
richColors: true,
|
||||
});
|
||||
}}
|
||||
className="flex flex-col gap-4 "
|
||||
>
|
||||
|
|
@ -213,7 +217,17 @@ function Menu() {
|
|||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(content, selectedSpaces);
|
||||
toast.promise(handleSubmit(content, selectedSpaces), {
|
||||
loading: (
|
||||
<span>
|
||||
<PlusCircleIcon className="w-4 h-4 inline mr-2 text-white animate-spin" />{" "}
|
||||
Creating memory...
|
||||
</span>
|
||||
),
|
||||
success: (data) => "Memory created",
|
||||
error: (error) => error.message,
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -266,10 +280,7 @@ function Menu() {
|
|||
]);
|
||||
setSelectedSpaces((prev) => [...prev, creationTask.data!]);
|
||||
} else {
|
||||
toast.error(
|
||||
"Space creation failed: " + creationTask.error ??
|
||||
"Unknown error",
|
||||
);
|
||||
toast.error("Space creation failed: " + creationTask.error);
|
||||
}
|
||||
}}
|
||||
placeholder="Select or create a new space."
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ const ImageComponent = memo(({ id }: { id: string }) => {
|
|||
|
||||
return (
|
||||
<div className="w-full aspect-video bg-[#2C3439] flex justify-center items-center">
|
||||
Drew things to seee here
|
||||
Draw things. They will appear here.
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ async function page() {
|
|||
|
||||
<BlurHeaderMenu />
|
||||
|
||||
<div className="w-full flex py-20">
|
||||
<div className="w-full flex py-20 justify-center">
|
||||
{!canvas.success || canvas.error ? (
|
||||
<div>Hmmm... Something went wrong. :/</div>
|
||||
) : (
|
||||
|
|
@ -37,7 +37,7 @@ async function page() {
|
|||
</div>
|
||||
|
||||
<h3 className="fixed left-1/2 -translate-x-1/2 bottom-4 text-gray-400 pt-20 text-center">
|
||||
*this is under beta and only one canvas is allowed per user
|
||||
Thinkpads is under beta and only one thinkpad is allowed per user.
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -167,6 +167,17 @@ const getTweetData = async (tweetID: string) => {
|
|||
return data;
|
||||
};
|
||||
|
||||
export const deleteSpace = async (id: number) => {
|
||||
try {
|
||||
await db.delete(space).where(eq(space.id, id));
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const createMemory = async (input: {
|
||||
content: string;
|
||||
spaces?: number[];
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ export const createMemoryFromAPI = async (input: {
|
|||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
pageContent: input.data.pageContent,
|
||||
title: input.data.title,
|
||||
description: input.data.description,
|
||||
title: input.data.title.slice(0, 500),
|
||||
description: input.data.description.slice(0, 500),
|
||||
url: input.data.url,
|
||||
spaces: input.data.spaces,
|
||||
user: input.userId,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { type NextRequest } from "next/server";
|
|||
import { addFromAPIType } from "@repo/shared-types";
|
||||
import { ensureAuth } from "../ensureAuth";
|
||||
import { createMemoryFromAPI } from "./helper";
|
||||
import { getRawTweet } from "@repo/shared-types/utils";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
|
|
@ -16,7 +17,43 @@ export async function POST(req: NextRequest) {
|
|||
return new Response("Missing BACKEND_SECURITY_KEY", { status: 500 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
let body;
|
||||
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch (e) {
|
||||
const error = (e as Error).message;
|
||||
|
||||
console.log(error);
|
||||
|
||||
const tryJson = getRawTweet(await req.text());
|
||||
console.log(tryJson);
|
||||
|
||||
if (tryJson) {
|
||||
try {
|
||||
body = JSON.parse(tryJson);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: "Raw found but not json?" + error,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: "Raw not found & not json." + error,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const validated = addFromAPIType.safeParse(body);
|
||||
|
||||
|
|
|
|||
|
|
@ -68,9 +68,9 @@ bot.on("message", async (ctx) => {
|
|||
if (response.status !== 200) {
|
||||
console.log("Failed to get response from backend");
|
||||
console.log(response.status);
|
||||
console.log(await response.text());
|
||||
await ctx.reply(
|
||||
"Sorry, I am not able to process your request at the moment.",
|
||||
"Sorry, I am not able to process your request at the moment." +
|
||||
(await response.text()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
import { Button } from "@repo/ui/shadcn/button";
|
||||
import { auth, signIn, signOut } from "../../server/auth";
|
||||
import { db } from "../../server/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { users } from "@repo/db/schema";
|
||||
import { getThemeToggler } from "../../lib/get-theme-button";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default async function Page() {
|
||||
const usr = await auth();
|
||||
|
||||
const userCount = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)`.mapWith(Number),
|
||||
})
|
||||
.from(users);
|
||||
|
||||
const SetThemeButton = getThemeToggler();
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-center justify-center min-h-screen">
|
||||
<div className="flex max-w-2xl justify-between w-full">
|
||||
<SetThemeButton />
|
||||
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
{" "}
|
||||
<svg
|
||||
viewBox="0 0 256 116"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="45px"
|
||||
height="45px"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
>
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="m202.357 49.394-5.311-2.124C172.085 103.434 72.786 69.289 66.81 85.997c-.996 11.286 54.227 2.146 93.706 4.059 12.039.583 18.076 9.671 12.964 24.484l10.069.031c11.615-36.209 48.683-17.73 50.232-29.68-2.545-7.857-42.601 0-31.425-35.497Z"
|
||||
/>
|
||||
<path
|
||||
fill="#F4811F"
|
||||
d="M176.332 108.348c1.593-5.31 1.062-10.622-1.593-13.809-2.656-3.187-6.374-5.31-11.154-5.842L71.17 87.634c-.531 0-1.062-.53-1.593-.53-.531-.532-.531-1.063 0-1.594.531-1.062 1.062-1.594 2.124-1.594l92.946-1.062c11.154-.53 22.839-9.56 27.087-20.182l5.312-13.809c0-.532.531-1.063 0-1.594C191.203 20.182 166.772 0 138.091 0 111.535 0 88.697 16.995 80.73 40.896c-5.311-3.718-11.684-5.843-19.12-5.31-12.747 1.061-22.838 11.683-24.432 24.43-.531 3.187 0 6.374.532 9.56C16.996 70.107 0 87.103 0 108.348c0 2.124 0 3.718.531 5.842 0 1.063 1.062 1.594 1.594 1.594h170.489c1.062 0 2.125-.53 2.125-1.594l1.593-5.842Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FAAD3F"
|
||||
d="M205.544 48.863h-2.656c-.531 0-1.062.53-1.593 1.062l-3.718 12.747c-1.593 5.31-1.062 10.623 1.594 13.809 2.655 3.187 6.373 5.31 11.153 5.843l19.652 1.062c.53 0 1.062.53 1.593.53.53.532.53 1.063 0 1.594-.531 1.063-1.062 1.594-2.125 1.594l-20.182 1.062c-11.154.53-22.838 9.56-27.087 20.182l-1.063 4.78c-.531.532 0 1.594 1.063 1.594h70.108c1.062 0 1.593-.531 1.593-1.593 1.062-4.25 2.124-9.03 2.124-13.81 0-27.618-22.838-50.456-50.456-50.456"
|
||||
/>
|
||||
</svg>
|
||||
<span className="italic">Cloudflare Next Saas Starter</span>
|
||||
</div>
|
||||
|
||||
<div className="border border-black dark:border-white rounded-2xl p-2 flex items-center">
|
||||
Start by editing apps/web/page.tsx
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl text-start w-full mt-16">
|
||||
Welcome to Cloudflare Next Saas Starter. <br /> Built a full stack app
|
||||
using production-ready tools and frameworks, host on Cloudflare
|
||||
instantly.
|
||||
<br />
|
||||
An opinionated, batteries-included framework with{" "}
|
||||
<a
|
||||
className="text-transparent bg-clip-text bg-gradient-to-r from-[#a93d64] to-[#275ba9]"
|
||||
href="https://turbo.build"
|
||||
>
|
||||
Turborepo
|
||||
</a>{" "}
|
||||
and Nextjs. Fully Typesafe. Best practices followed by default.
|
||||
<br /> <br />
|
||||
Here's what the stack includes:
|
||||
<ul className="list-disc mt-4 prose dark:prose-invert">
|
||||
<li>
|
||||
Authentication with <code>next-auth</code>
|
||||
</li>
|
||||
<li>Database using Cloudflare's D1 serverless databases</li>
|
||||
<li>Drizzle ORM, already connected to your database and auth ⚡</li>
|
||||
<li>Light/darkmode theming that works with server components (!)</li>
|
||||
<li>Styling using TailwindCSS and ShadcnUI</li>
|
||||
<li>Turborepo with a landing page and shared components</li>
|
||||
<li>Cloudflare wrangler for quick functions on the edge</li>
|
||||
<li>
|
||||
... best part: everything's already set up for you. Just code!
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<span>Number of users in database: {userCount[0]!.count}</span>
|
||||
</div>
|
||||
{usr?.user?.email ? (
|
||||
<>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<span>Hello {usr.user.name} 👋</span>
|
||||
<span>{usr.user.email}</span>
|
||||
</div>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signOut();
|
||||
}}
|
||||
>
|
||||
<Button variant={"destructive"} className="mt-4">
|
||||
Sign out
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn("google");
|
||||
}}
|
||||
>
|
||||
<Button className="mt-4">Login with Google</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
15
apps/web/lib/useKeyPress.ts
Normal file
15
apps/web/lib/useKeyPress.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
export const useKeyPress = (key: string, callback: () => void) => {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === key && e.altKey) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handler);
|
||||
};
|
||||
}, [key, callback]);
|
||||
};
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@sentry/nextjs": "^8",
|
||||
"ai": "^3.3.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"drizzle-orm": "0.30.0",
|
||||
|
|
|
|||
|
|
@ -12,14 +12,6 @@ export const {
|
|||
} = NextAuth({
|
||||
secret: process.env.BACKEND_SECURITY_KEY,
|
||||
trustHost: true,
|
||||
// callbacks: {
|
||||
// session: ({ session, token, user }) => ({
|
||||
// ...session,
|
||||
// user: {
|
||||
// ...session.user,
|
||||
// },
|
||||
// }),
|
||||
// },
|
||||
adapter: DrizzleAdapter(db, {
|
||||
usersTable: users,
|
||||
accountsTable: accounts,
|
||||
|
|
|
|||
|
|
@ -1 +1,20 @@
|
|||
module.exports = require("@repo/tailwind-config/tailwind.config");
|
||||
// Import the existing Tailwind config from your shared repository
|
||||
const sharedConfig = require("@repo/tailwind-config/tailwind.config");
|
||||
|
||||
module.exports = {
|
||||
presets: [sharedConfig],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
scrollbar: {
|
||||
// thumb: "#d1d5db",
|
||||
// thumbHover: "#1D4ED8",
|
||||
thumb: "#303c4c",
|
||||
thumbHover: "#2E3A48",
|
||||
track: "#1F2937",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwind-scrollbar")({ nocompatible: true })],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
|
|
@ -40,15 +41,72 @@ AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
|||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [fadeWidth, setFadeWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (containerRef.current) {
|
||||
event.preventDefault();
|
||||
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
|
||||
containerRef.current.scrollLeft += event.deltaX;
|
||||
} else {
|
||||
containerRef.current.scrollLeft += event.deltaY;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (containerRef.current) {
|
||||
const { scrollWidth, clientWidth, scrollLeft } = containerRef.current;
|
||||
const calculatedFadeWidth = Math.min(
|
||||
8,
|
||||
scrollWidth - clientWidth - scrollLeft,
|
||||
);
|
||||
setFadeWidth(calculatedFadeWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const currentRef = containerRef.current;
|
||||
currentRef?.addEventListener("wheel", handleWheel);
|
||||
currentRef?.addEventListener("scroll", handleScroll);
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
currentRef?.removeEventListener("wheel", handleWheel);
|
||||
currentRef?.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fadeStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: `${fadeWidth}px`,
|
||||
height: "85%",
|
||||
pointerEvents: "none",
|
||||
background: "linear-gradient(to left, rgb(46, 58, 72), transparent)",
|
||||
};
|
||||
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="relative overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("pb-4 pt-0", className)}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{/* Fade-out effect with inline styles */}
|
||||
<div style={fadeStyle}></div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
});
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
|
|
|
|||
877
pnpm-lock.yaml
generated
877
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue