supermemory/apps/web/app/components/memories/SharedCard.tsx
2025-02-18 15:28:01 -07:00

883 lines
25 KiB
TypeScript

import * as ReactTweet from "react-tweet";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { useInView } from "react-intersection-observer";
import { TweetSkeleton } from "react-tweet";
import { useNavigate, useParams } 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,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "../ui/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { FileIcon } from "@radix-ui/react-icons";
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";
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";
const { useTweet } = ReactTweet;
export const typeDecider = (content: string) => {
try {
// If it's a tweet URL
if (content.match(/https?:\/\/(x\.com|twitter\.com)\/[\w]+\/[\w]+\/[\d]+/)) {
return "tweet";
}
// If it's a document
if (content.match(/\.(pdf|doc|docx|txt|rtf|odt|md)/i)) {
return "document";
}
// If it's a Notion URL
if (content.match(/https?:\/\/(www\.)?notion\.so/)) {
return "notion";
}
// If it's any other URL
if (content.match(/^(https?:\/\/)?(www\.)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(\/.*)?$/i)) {
return "page";
}
// Otherwise it's a note
return "note";
} catch (e) {
console.error("[Decide Type Error]", e);
return "note";
}
};
// Create a cache to store fetched metadata
const metadataCache: Record<string, WebsiteMetadata> = {};
const renderContent = {
tweet: ({ data: ourData }: { data: Memory }) => {
// If content starts with <raw>, parse the JSON inside
if (ourData.content?.startsWith("<raw>")) {
const rawContent = ourData.content.replace(/<\/?raw>/g, "");
try {
const parsedData = JSON.parse(rawContent);
return <CustomTwitterComp tweet={parsedData} />;
} catch (error) {
console.error("Error parsing raw tweet data:", error);
return <div className="p-4 text-muted-foreground">Error parsing tweet data</div>;
}
}
// Otherwise use normal tweet ID flow
const tweetId = ourData.content?.match(/\/status\/(\d+)/)?.[1] ?? ourData.content;
const { data, error } = useTweet(tweetId ?? undefined);
if (error) {
console.error("Error parsing tweet:", error);
console.log("Tweet ID:", tweetId);
return <div className="p-4 text-muted-foreground">Error parsing tweet</div>;
}
if (!data || typeof data == "undefined") {
return <TweetSkeleton />;
}
return <CustomTwitterComp tweet={data} />;
},
page: ({ data }: { data: Memory }) => (
<WebsiteCard
id={data.uuid}
url={data.url ?? ""}
title={data.title}
description={data.description}
image={data.ogImage}
/>
),
note: ({ data }: { data: Memory }) => {
const { contentRef, showFade } = useTextOverflow(data.content ?? "", 5);
// Use a constant color for id 0, otherwise select based on content length
const color = useMemo(
() =>
data.id === 0
? pastelColors[0]
: pastelColors[(data.content?.length ?? 0) % pastelColors.length],
[data.content, data.id],
);
const formattedDate = new Date(data.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
return (
<div
style={{ backgroundColor: color }}
className="text-lg rounded-2xl relative p-8 font-semibold"
>
<p ref={contentRef} className="text-card-foreground line-clamp-4">
{data.content}
</p>
{showFade && <div className="fade-overlay"></div>}
<div className="flex justify-between items-center mt-8 ">
<div className="text-slate-500 font-normal text-base">{formattedDate}</div>
<a className="rounded-full bg-black text-white p-2" href="#">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="size-3"
>
<path d="m2.695 14.762-1.262 3.155a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.886L17.5 5.501a2.121 2.121 0 0 0-3-3L3.58 13.419a4 4 0 0 0-.885 1.343Z" />
</svg>
</a>
</div>
</div>
);
},
notion: ({ data }: { data: Memory }) => {
const { contentRef, showFade } = useTextOverflow(data.content ?? "", 5);
const formattedDate = new Date(data.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
return (
<div className="text-lg rounded-2xl relative p-8 font-semibold bg-[#F7F6F3] dark:bg-neutral-900">
<div className="flex items-center gap-2 mb-4">
<NotionIcon className="w-5 h-5" />
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">Notion Page</span>
</div>
<p
ref={contentRef}
className="text-card-foreground line-clamp-4 text-gray-900 dark:text-gray-100"
>
{data.content}
</p>
{showFade && <div className="fade-overlay"></div>}
<div className="flex justify-between items-center mt-8">
<div className="text-gray-500 dark:text-gray-400 font-normal text-base">
{formattedDate}
</div>
<a
href={data.url ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="rounded-full bg-black dark:bg-white text-white dark:text-black p-2 hover:opacity-80 transition-opacity"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="size-3"
>
<path
fillRule="evenodd"
d="M5.22 14.78a.75.75 0 001.06 0l7.22-7.22v5.69a.75.75 0 001.5 0v-7.5a.75.75 0 00-.75-.75h-7.5a.75.75 0 000 1.5h5.69l-7.22 7.22a.75.75 0 000 1.06z"
clipRule="evenodd"
/>
</svg>
</a>
</div>
</div>
);
},
space: ({ data }: { data: Memory & Partial<ExtraSpaceMetaData> }) => {
return (
<a
href={data.url ?? ""}
className="flex flex-col gap-2 p-6 bg-white dark:bg-neutral-800 border border-gray-200 dark:border-gray-800 rounded-3xl"
>
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<SpaceIcon className="h-4 w-4" />
<span className="text-sm font-medium">Space</span>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mt-2">{data.content}</h3>
<div className="flex flex-col gap-3 mt-4">
<div className="flex items-center gap-2">
{data.permissions?.isPublic ? (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 font-medium">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-4 h-4"
>
<path
fillRule="evenodd"
d="M4.5 2A1.5 1.5 0 0 0 3 3.5v13A1.5 1.5 0 0 0 4.5 18h11a1.5 1.5 0 0 0 1.5-1.5V7.621a1.5 1.5 0 0 0-.44-1.06l-4.12-4.122A1.5 1.5 0 0 0 11.378 2H4.5Zm7.75 9.75a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0 0 1.5h4.5Z"
clipRule="evenodd"
/>
</svg>
Public Space
</div>
) : (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 font-medium">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-4 h-4"
>
<path
fillRule="evenodd"
d="M10 1a4.5 4.5 0 0 0-4.5 4.5V9H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-.5V5.5A4.5 4.5 0 0 0 10 1Zm3 8V5.5a3 3 0 1 0-6 0V9h6Z"
clipRule="evenodd"
/>
</svg>
Private Space
</div>
)}
{data.favorited && (
<div className="flex items-center gap-1 px-3 py-1.5 rounded-full bg-yellow-50 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400 font-medium">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-4 h-4"
>
<path
fillRule="evenodd"
d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401z"
clipRule="evenodd"
/>
</svg>
Favorited
</div>
)}
</div>
{data.owner ? (
<>
<div className="flex flex-wrap gap-2">
{data.permissions?.canRead && !data.permissions.canEdit && (
<div className="text-xs px-2 py-1 rounded bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
Can Read
</div>
)}
{data.permissions?.canEdit && (
<div className="text-xs px-2 py-1 rounded bg-purple-50 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400">
Can Edit
</div>
)}
</div>
<div className="flex items-center gap-2 mt-1">
{data.owner.profileImage ? (
<img
src={data.owner.profileImage}
alt={data.owner.name}
className="w-6 h-6 rounded-full"
/>
) : (
<div className="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700" />
)}
<span className="text-sm text-gray-600 dark:text-gray-300">{data.owner.name}</span>
</div>
</>
) : null}
</div>
</a>
);
},
document: ({ data }: { data: Memory }) => {
// just render the document like normally
// and get the name from the url
// TODO: This can be improved
return (
<a
href={`/content/${data.id}`}
className="block p-4 rounded-3xl border border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<div className="flex items-center gap-3 text-gray-600 dark:text-gray-300">
<FileIcon className="h-5 w-5 flex-shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Document</span>
<span className="text-base font-medium text-gray-900 dark:text-white mt-0.5">
{decodeURIComponent(data.url?.split("/").pop() ?? "")}
</span>
</div>
</div>
</a>
);
},
};
async function fetchWebsiteMetadata(url: string): Promise<WebsiteMetadata> {
// Check if metadata is already in cache
if (metadataCache[url]) {
return metadataCache[url];
}
// Add http:// if not present
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "http://" + url;
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const response = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error("Failed to fetch metadata");
}
const metadata = (await response.json()) as WebsiteMetadata;
if (metadata.image) {
try {
const fac = new FastAverageColor();
const color = await fac.getColorAsync(metadata.image, {
algorithm: "dominant",
crossOrigin: "anonymous",
mode: "speed",
});
metadata.dominantColor = color.hex;
metadata.isDark = color.isDark;
} catch (error) {
console.error("Error getting dominant color:", error);
// Fallback to a default color if there's an error
metadata.dominantColor = "#CFCFCF";
metadata.isDark = false;
}
} else {
metadata.dominantColor = "#CFCFCF";
metadata.isDark = false;
}
// Store the fetched metadata in cache
metadataCache[url] = metadata;
return metadata;
} catch (error) {
console.error("Error fetching metadata:", error);
return {
title: "Unknown",
description: "No description available",
image: "",
dominantColor: "#CFCFCF",
isDark: false,
};
}
}
const WebsiteCard = memo(
({
url,
title,
description,
image,
id,
}: {
url: string;
title?: string | null;
description?: string | null;
image?: string | null;
id: string;
}) => {
// Memoize domain extraction to avoid recalculation
const domain = useMemo(() => {
try {
let formattedUrl = url;
if (!formattedUrl.startsWith("http")) {
formattedUrl = "http://" + formattedUrl;
}
return new URL(formattedUrl).hostname.replace(/^www\./, "");
} catch {
return url;
}
}, [url]);
// Memoize initial color based on URL
const initialColor = useMemo(() => {
if (!image) {
const hash = url.split("").reduce((acc, char) => {
return char.charCodeAt(0) + ((acc << 5) - acc);
}, 0);
return `hsl(${hash % 360}, 70%, 85%)`; // Slightly lighter base color
}
return "#f0f0f0"; // Lighter default color
}, [url, image]);
const [dominantColor, setDominantColor] = useState(initialColor);
const [isDark, setIsDark] = useState(false);
// Only calculate dominant color when component is in view
useEffect(() => {
if (image) {
const fac = new FastAverageColor();
fac
.getColorAsync(image, {
algorithm: "dominant",
crossOrigin: "anonymous",
mode: "speed",
})
.then((color) => {
setDominantColor(color.hex);
setIsDark(color.isDark);
})
.catch((error) => {
console.error("Error getting dominant color:", error);
});
}
}, [image]);
const displayTitle = title || domain;
const displayDescription = description || `Saved from ${domain}`;
return (
<div className="overflow-hidden rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200">
<div className="relative">
{image && (
<div className="relative h-40">
<img
src={image}
alt={displayTitle}
className="absolute inset-0 w-full h-full object-cover"
loading="lazy"
style={{ backgroundColor: dominantColor }}
/>
<div
className="absolute inset-0"
style={{
background: `linear-gradient(to bottom, transparent 40%, ${dominantColor} 100%)`,
}}
/>
</div>
)}
<div
className={cn(
"p-5 relative",
!image && "rounded-lg",
isDark ? "text-white/90" : "text-black/90",
)}
style={{
backgroundColor: dominantColor,
marginTop: image ? "-2.5rem" : 0,
}}
>
<h3 className="text-lg font-semibold tracking-tight">{displayTitle}</h3>
<p className="mt-2 line-clamp-2 text-sm opacity-80">{displayDescription}</p>
<a
href={`/content/${id}`}
className="mt-3 inline-flex items-center gap-1 text-sm hover:underline opacity-70 hover:opacity-100 transition-opacity"
style={{
color: isDark ? "white" : "black",
}}
>
<span>{domain}</span>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path
d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
</div>
</div>
</div>
);
},
);
export function FetchAndRenderContent({ content }: { content: string }) {
const [memory, setMemory] = useState<Memory | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
(async () => {
const type = typeDecider(content);
if (type !== "page") {
const item: Memory = {
type: type,
content: content,
createdAt: new Date(),
description: null,
id: 0,
ogImage: null,
title: null,
url: null,
uuid: "",
updatedAt: null,
raw: null,
userId: 0,
isSuccessfullyProcessed: true,
errorMessage: null,
contentHash: null,
};
setMemory(item);
return;
}
try {
setIsLoading(true);
const metadata = await fetchWebsiteMetadata(content);
const item: Memory = {
type: type,
content: content,
createdAt: new Date(),
description: metadata?.description ?? null,
id: 0,
ogImage: metadata?.image ?? null,
title: metadata?.title ?? null,
url: content,
uuid: "",
updatedAt: null,
raw: null,
userId: 0,
isSuccessfullyProcessed: true,
errorMessage: null,
contentHash: null,
};
setMemory(item);
} catch (error) {
// If metadata fetch fails, create memory with basic URL info
const item: Memory = {
type: type,
content: content,
createdAt: new Date(),
description: null,
id: 0,
ogImage: null,
title: content,
url: content,
uuid: "",
updatedAt: null,
raw: null,
userId: 0,
isSuccessfullyProcessed: false,
errorMessage: null,
contentHash: null,
};
setMemory(item);
} finally {
setIsLoading(false);
clearTimeout(timeoutId);
}
})();
return () => {
clearTimeout(timeoutId);
controller.abort();
};
}, [content]);
if (isLoading) {
return <div className="p-4 text-muted-foreground">Loading website metadata...</div>;
}
return memory ? <SharedCard data={memory} /> : null;
}
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) => {
const response = await fetch(`/backend/v1/memories/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to delete memory");
}
return response.json();
},
onMutate: async (id) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ["memories"] });
// Snapshot the previous value
const previousMemories = queryClient.getQueryData(["memories"]);
// Optimistically remove the memory
queryClient.setQueryData(["memories"], (old: any) => {
return old?.filter((memory: Memory) => memory.id !== id);
});
return { previousMemories };
},
onError: (err, variables, context) => {
// Revert the optimistic update
queryClient.setQueryData(["memories"], context?.previousMemories);
toast.error("Failed to delete memory");
},
onSuccess: () => {
toast.success("Memory deleted successfully");
queryClient.invalidateQueries({ queryKey: ["memories"] });
queryClient.invalidateQueries({ queryKey: ["spaces"] });
},
});
// Move to space mutation
const moveToSpaceMutation = useMutation({
mutationFn: async ({ spaceId, documentId }: { spaceId: string; documentId: string }) => {
const response = await fetch("/backend/v1/spaces/moveContent", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ spaceId, documentId }),
});
if (!response.ok) {
throw new Error("Failed to move memory");
}
return response.json() as Promise<{ spaceId: string }>;
},
onError: (err) => {
toast.error("Failed to move memory to space");
},
onSuccess: ({ spaceId }: { spaceId: string }) => {
toast.success("Memory moved successfully");
queryClient.invalidateQueries({ queryKey: ["memories"] });
queryClient.invalidateQueries({ queryKey: ["spaces"] });
if (spaceId === "<HOME>") {
return navigate("/");
}
return navigate(`/space/${spaceId}`);
},
});
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) => {
moveToSpaceMutation.mutate({
spaceId,
documentId: data.uuid,
});
};
const handleClick = (e: React.MouseEvent) => {
if (isSelectionMode && onToggleSelect) {
e.preventDefault();
onToggleSelect();
return;
}
};
return (
<Card
className={cn(
"group relative overflow-hidden rounded-3xl border",
(() => {
switch (data.type) {
case "note":
return "border-primary/30";
case "document":
return "border-secondary/30";
default:
return "border-accent/30";
}
})(),
isSelectionMode && "hover:ring-2 hover:ring-primary",
isSelected && "ring-2 ring-primary bg-primary/5",
)}
onClick={handleClick}
>
{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 opacity-0 group-hover:opacity-100 transition-opacity">
<MenuIcon className="h-4 w-4" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Move to ...</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<MemoizedSpaceSelector contentId={data.id} onSelect={handleMoveToSpace} />
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem onSelect={(e) => handleDelete(e)} asChild>
<Button className="w-full gap-2 flex" variant={"destructive"}>
<TrashIcon className="h-4 w-4" /> Delete
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
)}
<ContentRenderer data={data} />
</Card>
);
}
export const SpaceSelector = function SpaceSelector({
contentId,
onSelect,
}: {
contentId: number;
onSelect: (spaceId: string) => void;
}) {
const [search, setSearch] = useState("");
const { spaceId } = useParams();
console.log(spaceId);
const {
data: spacesData,
isLoading,
error,
} = useQuery({
queryKey: ["spaces"],
queryFn: fetchSpaces,
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
});
const filteredSpaces = useMemo(() => {
if (!spacesData?.spaces) return [];
return spacesData.spaces.filter(
(space) =>
space.name.toLowerCase().includes(search.toLowerCase()) && space.uuid !== (spaceId ? spaceId.split("---")[0] : "<HOME>"),
);
}, [spacesData?.spaces, search]);
if (isLoading) {
return (
<DropdownMenuSubContent>
<DropdownMenuItem disabled>
<div className="flex items-center gap-2">
<Loader />
Loading spaces...
</div>
</DropdownMenuItem>
</DropdownMenuSubContent>
);
}
if (error) {
return (
<DropdownMenuSubContent>
<DropdownMenuItem disabled>
<div className="text-destructive">
Error: {error instanceof Error ? error.message : "Failed to load spaces"}
</div>
</DropdownMenuItem>
</DropdownMenuSubContent>
);
}
return (
<DropdownMenuSubContent className="p-0">
<Command>
<CommandInput placeholder="Search spaces..." value={search} onValueChange={setSearch} />
<CommandList className="max-h-[200px] overflow-y-auto">
<CommandGroup>
{filteredSpaces.map((space) => (
<CommandItem
key={space.uuid}
value={space.name}
onSelect={() => onSelect(space.uuid)}
>
<div className="flex items-center gap-2">
<SpaceIcon className="h-4 w-4" />
{space.name}
</div>
</CommandItem>
))}
{filteredSpaces.length === 0 && <CommandEmpty>No spaces found.</CommandEmpty>}
</CommandGroup>
</CommandList>
</Command>
</DropdownMenuSubContent>
);
};
const MemoizedSpaceSelector = memo(SpaceSelector);