mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-10 03:28:14 +00:00
added a batch delete feature
This commit is contained in:
parent
3e1b4bb788
commit
747306979a
6 changed files with 370 additions and 84 deletions
|
|
@ -50,6 +50,9 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
})
|
||||
),
|
||||
async (c) => {
|
||||
const startTime = performance.now();
|
||||
console.log("[chat] Starting request");
|
||||
|
||||
const user = c.get("user");
|
||||
if (!user) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
|
|
@ -57,6 +60,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
|
||||
const { messages, threadId } = await c.req.valid("json");
|
||||
|
||||
console.log("[chat] Converting messages");
|
||||
const unfilteredCoreMessages = convertToCoreMessages(
|
||||
(messages as Message[])
|
||||
.filter((m) => m.content.length > 0)
|
||||
|
|
@ -79,6 +83,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
(message) => message.content.length > 0
|
||||
);
|
||||
|
||||
console.log("[chat] Setting up DB and logger");
|
||||
const db = database(c.env.HYPERDRIVE.connectionString);
|
||||
const { initLogger, wrapAISDKModel } = await import("braintrust");
|
||||
|
||||
|
|
@ -101,6 +106,8 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
return c.json({ error: "Empty query" }, 400);
|
||||
}
|
||||
|
||||
console.log("[chat] Generating embeddings and creating thread");
|
||||
const embedStart = performance.now();
|
||||
// Run embedding generation and thread creation in parallel
|
||||
const [{ data: embedding }, thread] = await Promise.all([
|
||||
c.env.AI.run("@cf/baai/bge-base-en-v1.5", { text: queryText }),
|
||||
|
|
@ -116,6 +123,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
.returning()
|
||||
: null,
|
||||
]);
|
||||
console.log(`[chat] Embedding generation took ${performance.now() - embedStart}ms`);
|
||||
|
||||
const threadUuid = threadId || thread?.[0].uuid;
|
||||
|
||||
|
|
@ -123,6 +131,8 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
return c.json({ error: "Failed to generate embedding" }, 500);
|
||||
}
|
||||
|
||||
console.log("[chat] Performing semantic search");
|
||||
const searchStart = performance.now();
|
||||
// Perform semantic search
|
||||
const similarity = sql<number>`1 - (${cosineDistance(chunk.embeddings, embedding[0])})`;
|
||||
|
||||
|
|
@ -144,6 +154,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
.where(and(eq(documents.userId, user.id), sql`${similarity} > 0.4`))
|
||||
.orderBy(desc(similarity))
|
||||
.limit(5);
|
||||
console.log(`[chat] Semantic search took ${performance.now() - searchStart}ms`);
|
||||
|
||||
const cleanDocumentsForContext = finalResults.map((d) => ({
|
||||
title: d.title,
|
||||
|
|
@ -169,6 +180,8 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
}
|
||||
|
||||
try {
|
||||
console.log("[chat] Starting stream generation");
|
||||
const streamStart = performance.now();
|
||||
const data = new StreamData();
|
||||
// De-duplicate chunks by URL to avoid showing duplicate content
|
||||
const uniqueResults = finalResults.reduce((acc, current) => {
|
||||
|
|
@ -224,6 +237,8 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
],
|
||||
async onFinish(completion) {
|
||||
try {
|
||||
console.log("[chat] Stream finished, updating thread");
|
||||
const updateStart = performance.now();
|
||||
if (lastUserMessage) {
|
||||
lastUserMessage.content =
|
||||
typeof lastUserMessage.content === "string"
|
||||
|
|
@ -257,12 +272,15 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
.set({ messages: newMessages })
|
||||
.where(eq(chatThreads.uuid, threadUuid));
|
||||
}
|
||||
console.log(`[chat] Thread update took ${performance.now() - updateStart}ms`);
|
||||
} catch (error) {
|
||||
console.error("Failed to update thread:", error);
|
||||
}
|
||||
},
|
||||
});
|
||||
console.log(`[chat] Stream generation took ${performance.now() - streamStart}ms`);
|
||||
|
||||
console.log(`[chat] Total request time: ${performance.now() - startTime}ms`);
|
||||
return result.toDataStreamResponse({
|
||||
headers: {
|
||||
"Supermemory-Thread-Uuid": threadUuid ?? "",
|
||||
|
|
|
|||
|
|
@ -128,15 +128,9 @@ const memories = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
documents: documents,
|
||||
})
|
||||
.from(documents)
|
||||
.leftJoin(
|
||||
contentToSpace,
|
||||
eq(documents.id, contentToSpace.contentId)
|
||||
)
|
||||
.leftJoin(contentToSpace, eq(documents.id, contentToSpace.contentId))
|
||||
.where(
|
||||
and(
|
||||
eq(documents.userId, user.id),
|
||||
isNull(contentToSpace.contentId)
|
||||
)
|
||||
and(eq(documents.userId, user.id), isNull(contentToSpace.contentId))
|
||||
)
|
||||
.orderBy(desc(documents.createdAt))
|
||||
.limit(count)
|
||||
|
|
@ -146,15 +140,9 @@ const memories = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
total: sql<number>`count(*)`.as("total"),
|
||||
})
|
||||
.from(documents)
|
||||
.leftJoin(
|
||||
contentToSpace,
|
||||
eq(documents.id, contentToSpace.contentId)
|
||||
)
|
||||
.leftJoin(contentToSpace, eq(documents.id, contentToSpace.contentId))
|
||||
.where(
|
||||
and(
|
||||
eq(documents.userId, user.id),
|
||||
isNull(contentToSpace.contentId)
|
||||
)
|
||||
and(eq(documents.userId, user.id), isNull(contentToSpace.contentId))
|
||||
),
|
||||
]);
|
||||
|
||||
|
|
@ -196,7 +184,7 @@ const memories = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
|
||||
const db = database(c.env.HYPERDRIVE.connectionString);
|
||||
|
||||
let documentIdNum
|
||||
let documentIdNum;
|
||||
|
||||
try {
|
||||
documentIdNum = Number(id);
|
||||
|
|
@ -209,7 +197,9 @@ const memories = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
.from(documents)
|
||||
.where(
|
||||
and(
|
||||
documentIdNum ? or(eq(documents.uuid, id), eq(documents.id, documentIdNum)) : eq(documents.uuid, id),
|
||||
documentIdNum
|
||||
? or(eq(documents.uuid, id), eq(documents.id, documentIdNum))
|
||||
: eq(documents.uuid, id),
|
||||
eq(documents.userId, user.id)
|
||||
)
|
||||
)
|
||||
|
|
@ -230,6 +220,73 @@ const memories = new Hono<{ Variables: Variables; Bindings: Env }>()
|
|||
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
.post(
|
||||
"/batch-delete",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
ids: z.array(z.string()),
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { ids } = c.req.valid("json");
|
||||
const user = c.get("user");
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const db = database(c.env.HYPERDRIVE.connectionString);
|
||||
|
||||
try {
|
||||
// First get all valid documents that belong to the user
|
||||
const docs = await db
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(
|
||||
and(
|
||||
eq(documents.userId, user.id),
|
||||
sql`${documents.uuid} = ANY(ARRAY[${ids}]::text[])`
|
||||
)
|
||||
);
|
||||
|
||||
if (docs.length === 0) {
|
||||
return c.json({ error: "No valid documents found" }, 404);
|
||||
}
|
||||
|
||||
const docIds = docs.map((doc) => doc.id);
|
||||
|
||||
// Delete in transaction to ensure consistency
|
||||
await db.transaction(async (tx) => {
|
||||
await Promise.all([
|
||||
// Delete document entries
|
||||
tx
|
||||
.delete(documents)
|
||||
.where(
|
||||
and(
|
||||
eq(documents.userId, user.id),
|
||||
sql`${documents.uuid} = ANY(ARRAY[${ids}]::text[])`
|
||||
)
|
||||
),
|
||||
// Delete space connections
|
||||
tx
|
||||
.delete(contentToSpace)
|
||||
.where(
|
||||
sql`${contentToSpace.contentId} = ANY(ARRAY[${docIds}]::int[])`
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
deletedCount: docs.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Batch delete error:", error);
|
||||
return c.json({ error: "Failed to delete documents" }, 500);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default memories;
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import { Button } from "../ui/button";
|
|||
import AddMemory from "./AddMemory";
|
||||
import SharedCard from "./SharedCard";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Masonry, useInfiniteLoader } from "masonic";
|
||||
import { useHydrated } from "remix-utils/use-hydrated";
|
||||
import { toast } from "sonner";
|
||||
import { useMemories } from "~/lib/hooks/use-memories";
|
||||
import { useSpaces } from "~/lib/hooks/use-spaces";
|
||||
import { Memory } from "~/lib/types/memory";
|
||||
|
|
@ -25,11 +27,66 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr
|
|||
const { spaceId } = useParams();
|
||||
const [selectedVariant, setSelectedVariant] = useState<Variant>("All Memories");
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const { memories, isLoading, loadMore, hasMore } = useMemories(0, 20, spaceId);
|
||||
const { memories, isLoading, loadMore, hasMore, mutate } = useMemories(0, 20, spaceId);
|
||||
|
||||
const { spaces } = useSpaces();
|
||||
|
||||
const handleToggleSelection = useCallback((uuid: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(uuid)) {
|
||||
newSet.delete(uuid);
|
||||
} else {
|
||||
newSet.add(uuid);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Are you sure you want to delete ${selectedItems.size} item${selectedItems.size > 1 ? "s" : ""}?`,
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await fetch("/backend/v1/memories/batch-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: Array.from(selectedItems) }),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete items");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { success: boolean; deletedCount: number };
|
||||
toast.success(
|
||||
`Successfully deleted ${data.deletedCount} item${data.deletedCount > 1 ? "s" : ""}`,
|
||||
);
|
||||
|
||||
// Reset selection mode and clear selected items
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
|
||||
// Refresh the memories list
|
||||
mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to delete items");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [selectedItems, mutate]);
|
||||
|
||||
const loadMoreItems = useCallback(
|
||||
(startIndex: number, stopIndex: number) => {
|
||||
if (!hasMore || isLoading) {
|
||||
|
|
@ -109,7 +166,8 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr
|
|||
|
||||
// Combine items and generate key
|
||||
const { items, key } = useMemo(() => {
|
||||
const shouldShowSpaces = !isSpace && (selectedVariant === "All Memories" || selectedVariant === "Spaces");
|
||||
const shouldShowSpaces =
|
||||
!isSpace && (selectedVariant === "All Memories" || selectedVariant === "Spaces");
|
||||
const allItems = [
|
||||
...addButtonItem,
|
||||
...(shouldShowSpaces ? spaceItems : []),
|
||||
|
|
@ -127,16 +185,96 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr
|
|||
if (index === 0 && showAddButtons) {
|
||||
return <AddMemory isSpace={isSpace} />;
|
||||
}
|
||||
return <SharedCard data={data} />;
|
||||
if (data.type === "space") {
|
||||
return <SharedCard data={data} />;
|
||||
}
|
||||
return (
|
||||
<SharedCard
|
||||
data={data}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={selectedItems.has(data.uuid)}
|
||||
onToggleSelect={() => handleToggleSelection(data.uuid)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[showAddButtons],
|
||||
[showAddButtons, isSelectionMode, selectedItems, handleToggleSelection],
|
||||
);
|
||||
|
||||
const handleVariantClick = useCallback((variant: Variant) => {
|
||||
setSelectedVariant(variant);
|
||||
setIsMobileMenuOpen(false);
|
||||
if (variant === "Spaces") {
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const SelectionControls = useMemo(
|
||||
() => (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{selectedVariant !== "Spaces" && (
|
||||
<>
|
||||
<Button
|
||||
variant={isSelectionMode ? "secondary" : "outline"}
|
||||
onClick={() => {
|
||||
setIsSelectionMode(!isSelectionMode);
|
||||
if (!isSelectionMode) {
|
||||
setSelectedItems(new Set());
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSelectionMode ? "Cancel Selection" : "Select Items"}
|
||||
</Button>
|
||||
{isSelectionMode && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Get all non-space items' UUIDs
|
||||
const allUuids = filteredMemories
|
||||
.filter((item) => item.type !== "space")
|
||||
.map((item) => item.uuid);
|
||||
|
||||
// If all items are selected, clear selection
|
||||
if (allUuids.every((uuid) => selectedItems.has(uuid))) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
// Otherwise, select all items
|
||||
setSelectedItems(new Set(allUuids));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{filteredMemories.every(
|
||||
(item) => item.type === "space" || selectedItems.has(item.uuid),
|
||||
)
|
||||
? "Deselect All"
|
||||
: "Select All"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedItems.size === 0 || isDeleting}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{isDeleting ? "Deleting..." : `Delete Selected (${selectedItems.size})`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[
|
||||
isSelectionMode,
|
||||
selectedItems.size,
|
||||
isDeleting,
|
||||
handleBatchDelete,
|
||||
selectedVariant,
|
||||
filteredMemories,
|
||||
selectedItems,
|
||||
],
|
||||
);
|
||||
|
||||
const MobileVariantButton = useMemo(
|
||||
() => (
|
||||
<Button
|
||||
|
|
@ -158,21 +296,23 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr
|
|||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{variants.filter(variant => !(isSpace && variant === "Spaces")).map((variant) => (
|
||||
<Button
|
||||
key={variant}
|
||||
variant="ghost"
|
||||
onClick={() => handleVariantClick(variant)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-accent hover:text-foreground text-base w-full py-4 rounded-lg transition-colors",
|
||||
selectedVariant === variant
|
||||
? "bg-accent text-foreground font-medium shadow-sm"
|
||||
: "hover:shadow-sm",
|
||||
)}
|
||||
>
|
||||
{variant}
|
||||
</Button>
|
||||
))}
|
||||
{variants
|
||||
.filter((variant) => !(isSpace && variant === "Spaces"))
|
||||
.map((variant) => (
|
||||
<Button
|
||||
key={variant}
|
||||
variant="ghost"
|
||||
onClick={() => handleVariantClick(variant)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-accent hover:text-foreground text-base w-full py-4 rounded-lg transition-colors",
|
||||
selectedVariant === variant
|
||||
? "bg-accent text-foreground font-medium shadow-sm"
|
||||
: "hover:shadow-sm",
|
||||
)}
|
||||
>
|
||||
{variant}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
|
@ -182,19 +322,21 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr
|
|||
const DesktopVariantMenu = useMemo(
|
||||
() => (
|
||||
<div className="hidden md:flex">
|
||||
{variants.filter(variant => !(isSpace && variant === "Spaces")).map((variant) => (
|
||||
<Button
|
||||
key={variant}
|
||||
variant="ghost"
|
||||
onClick={() => setSelectedVariant(variant)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-accent hover:text-foreground text-base",
|
||||
selectedVariant === variant ? "bg-accent text-foreground" : "",
|
||||
)}
|
||||
>
|
||||
{variant}
|
||||
</Button>
|
||||
))}
|
||||
{variants
|
||||
.filter((variant) => !(isSpace && variant === "Spaces"))
|
||||
.map((variant) => (
|
||||
<Button
|
||||
key={variant}
|
||||
variant="ghost"
|
||||
onClick={() => setSelectedVariant(variant)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-accent hover:text-foreground text-base",
|
||||
selectedVariant === variant ? "bg-accent text-foreground" : "",
|
||||
)}
|
||||
>
|
||||
{variant}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
[selectedVariant, isSpace],
|
||||
|
|
@ -210,6 +352,8 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr
|
|||
{DesktopVariantMenu}
|
||||
</div>
|
||||
|
||||
{SelectionControls}
|
||||
|
||||
<Masonry
|
||||
key={key + "memories"}
|
||||
id="memories-masonry"
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|||
import { useInView } from "react-intersection-observer";
|
||||
import { TweetSkeleton } from "react-tweet";
|
||||
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
|
||||
import { NotionIcon } from "../icons/IntegrationIcons";
|
||||
import { CustomTwitterComp } from "../twitter/render-tweet";
|
||||
import Loader from "../ui/Loader";
|
||||
import { Button, ButtonProps } from "../ui/button";
|
||||
import { Card } from "../ui/card";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
|
|
@ -34,6 +38,7 @@ import { SpaceIcon } from "@supermemory/shared/icons";
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FastAverageColor } from "fast-average-color";
|
||||
import { MenuIcon, TrashIcon } from "lucide-react";
|
||||
import { FolderIcon, MoreVertical, Trash } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { pastelColors } from "~/lib/constants/pastelColors";
|
||||
import { typeIcons } from "~/lib/constants/typeIcons";
|
||||
|
|
@ -41,7 +46,6 @@ import { ExtraSpaceMetaData, fetchSpaces } from "~/lib/hooks/use-spaces";
|
|||
import { useTextOverflow } from "~/lib/hooks/use-text-overflow";
|
||||
import { Memory, WebsiteMetadata } from "~/lib/types/memory";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
|
||||
const { useTweet } = ReactTweet;
|
||||
|
||||
|
|
@ -615,10 +619,39 @@ export function FetchAndRenderContent({ content }: { content: string }) {
|
|||
return memory ? <SharedCard data={memory} /> : null;
|
||||
}
|
||||
|
||||
function SharedCard({ data }: { data: Memory }) {
|
||||
interface SharedCardProps {
|
||||
data: Memory;
|
||||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
}
|
||||
|
||||
export default function SharedCard({
|
||||
data,
|
||||
isSelectionMode,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
}: SharedCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Flatten the data if it's a nested array and get the first item
|
||||
if (Array.isArray(data)) {
|
||||
console.log("weird data here, will try flattening.", data);
|
||||
data = data.flat(Infinity)[0];
|
||||
}
|
||||
|
||||
const ContentRenderer =
|
||||
renderContent[data.type as keyof typeof renderContent] ||
|
||||
(() => {
|
||||
console.log("SharedCard data", data);
|
||||
return (
|
||||
<div>
|
||||
Unsupported content type: {typeof data.type === "undefined" ? "undefined" : data.type}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
|
|
@ -686,27 +719,14 @@ function SharedCard({ data }: { data: Memory }) {
|
|||
},
|
||||
});
|
||||
|
||||
// Flatten the data if it's a nested array and get the first item
|
||||
if (Array.isArray(data)) {
|
||||
console.log("weird data here, will try flattening.", data);
|
||||
data = data.flat(Infinity)[0];
|
||||
}
|
||||
|
||||
const ContentRenderer =
|
||||
renderContent[data.type as keyof typeof renderContent] ||
|
||||
(() => {
|
||||
console.log("SharedCard data", data);
|
||||
return (
|
||||
<div>
|
||||
Unsupported content type: {typeof data.type === "undefined" ? "undefined" : data.type}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleDelete = (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate(data.id);
|
||||
const handleDelete = (e?: Event) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
if (window.confirm("Are you sure you want to delete this memory?")) {
|
||||
deleteMutation.mutate(data.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveToSpace = (spaceId: string) => {
|
||||
|
|
@ -716,10 +736,23 @@ function SharedCard({ data }: { data: Memory }) {
|
|||
});
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isSelectionMode && onToggleSelect) {
|
||||
e.preventDefault();
|
||||
onToggleSelect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal navigation behavior
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
className={cn(
|
||||
`relative overflow-hidden rounded-3xl border bg-card`,
|
||||
"group relative overflow-hidden rounded-3xl border",
|
||||
(() => {
|
||||
switch (data.type) {
|
||||
case "note":
|
||||
|
|
@ -730,13 +763,20 @@ function SharedCard({ data }: { data: Memory }) {
|
|||
return "border-accent/30";
|
||||
}
|
||||
})(),
|
||||
isSelectionMode && "hover:ring-2 hover:ring-primary",
|
||||
isSelected && "ring-2 ring-primary bg-primary/5",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<a href={`/content/${data.uuid}`}>
|
||||
{data.type !== "space" && (
|
||||
{isSelectionMode ? (
|
||||
<div className="absolute right-2 top-2 z-50">
|
||||
<Checkbox checked={isSelected} onCheckedChange={onToggleSelect} />
|
||||
</div>
|
||||
) : (
|
||||
data.type !== "space" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-full bg-white/70 dark:bg-gray-900/70 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 shadow-sm/50 backdrop-blur-[2px] border border-gray-200/30 dark:border-gray-700/30">
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-full bg-white/70 dark:bg-gray-900/70 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 shadow-sm/50 backdrop-blur-[2px] border border-gray-200/30 dark:border-gray-700/30 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<MenuIcon className="h-4 w-4" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
|
@ -754,10 +794,10 @@ function SharedCard({ data }: { data: Memory }) {
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<ContentRenderer data={data} />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<ContentRenderer data={data} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -838,5 +878,3 @@ export const SpaceSelector = function SpaceSelector({
|
|||
};
|
||||
|
||||
const MemoizedSpaceSelector = memo(SpaceSelector);
|
||||
|
||||
export default SharedCard;
|
||||
|
|
|
|||
28
apps/web/app/components/ui/checkbox.tsx
Normal file
28
apps/web/app/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
|
|
@ -271,5 +271,6 @@ export function useMemories(start = 0, count = 40, spaceId?: string) {
|
|||
hasMore: memoriesData ? memoriesData.nextCursor < memoriesData.total : false,
|
||||
total: memoriesData?.total ?? 0,
|
||||
addMemory: wrappedAddMemory,
|
||||
mutate: () => queryClient.invalidateQueries({ queryKey: cacheKey }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue