mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
spaces dialog
This commit is contained in:
parent
070ead4551
commit
5b34007124
6 changed files with 167 additions and 106 deletions
|
|
@ -15,7 +15,7 @@ import { like, eq, and, sql } from "drizzle-orm";
|
|||
import { union } from "drizzle-orm/sqlite-core"
|
||||
|
||||
// @todo: (future) pagination not yet needed
|
||||
export async function searchMemoriesAndSpaces(query: string): Promise<SearchResult[]> {
|
||||
export async function searchMemoriesAndSpaces(query: string, opts?: { filter?: { memories?: boolean, spaces?: boolean }, range?: { offset: number, limit: number } }): Promise<SearchResult[]> {
|
||||
|
||||
const user = await getUser()
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ export async function searchMemoriesAndSpaces(query: string): Promise<SearchResu
|
|||
}).from(storedContent).where(and(
|
||||
eq(storedContent.user, user.id),
|
||||
like(storedContent.title, `%${query}%`)
|
||||
)).all()
|
||||
));
|
||||
|
||||
const searchSpacesQuery = db.select({
|
||||
type: sql<string>`'space'`,
|
||||
|
|
@ -42,9 +42,20 @@ export async function searchMemoriesAndSpaces(query: string): Promise<SearchResu
|
|||
eq(space.user, user.id),
|
||||
like(space.name, `%${query}%`)
|
||||
)
|
||||
).all()
|
||||
);
|
||||
|
||||
let queries = [];
|
||||
|
||||
const data = await Promise.all([searchSpacesQuery, searchMemoriesQuery])
|
||||
[undefined, true].includes(opts?.filter?.memories) && queries.push(searchMemoriesQuery);
|
||||
[undefined, true].includes(opts?.filter?.spaces) && queries.push(searchSpacesQuery);
|
||||
|
||||
if (opts?.range) {
|
||||
queries = queries.map(q => q.offset(opts.range!.offset).limit(opts.range!.limit))
|
||||
} else {
|
||||
queries = queries.map(q => q.all())
|
||||
}
|
||||
|
||||
const data = await Promise.all(queries)
|
||||
|
||||
return data.reduce((acc, i) => [...acc, ...i]) as SearchResult[]
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ import { Input } from "../ui/input";
|
|||
import { Label } from "../ui/label";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FilterSpaces } from "./FilterCombobox";
|
||||
import { FilterMemories, FilterSpaces } from "./FilterCombobox";
|
||||
import { useMemory } from "@/contexts/MemoryContext";
|
||||
import { Command, Plus, X } from "lucide-react";
|
||||
import { StoredContent } from "@/server/db/schema";
|
||||
import { cleanUrl } from "@/lib/utils";
|
||||
|
||||
export function AddMemoryPage() {
|
||||
const { addMemory } = useMemory();
|
||||
|
|
@ -153,29 +156,28 @@ export function NoteAddPage({ closeDialog }: { closeDialog: () => void }) {
|
|||
}
|
||||
|
||||
export function SpaceAddPage({ closeDialog }: { closeDialog: () => void }) {
|
||||
const [selectedSpacesId, setSelectedSpacesId] = useState<number[]>([]);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [selected, setSelected] = useState<StoredContent[]>([]);
|
||||
|
||||
|
||||
function check(): boolean {
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
content,
|
||||
};
|
||||
console.log(name);
|
||||
if (!data.name || data.name.length < 1) {
|
||||
if (!inputRef.current) {
|
||||
alert("Please enter a name for the note");
|
||||
return false;
|
||||
}
|
||||
inputRef.current.value = "";
|
||||
inputRef.current.placeholder = "Please enter a title for the note";
|
||||
inputRef.current.placeholder = "Please enter a title for the space";
|
||||
inputRef.current.dataset["error"] = "true";
|
||||
setTimeout(() => {
|
||||
inputRef.current!.placeholder = "Title of the note";
|
||||
inputRef.current!.placeholder = "Enter the name of the space";
|
||||
inputRef.current!.dataset["error"] = "false";
|
||||
}, 500);
|
||||
inputRef.current.focus();
|
||||
|
|
@ -191,19 +193,48 @@ export function SpaceAddPage({ closeDialog }: { closeDialog: () => void }) {
|
|||
</DialogHeader>
|
||||
<Label className="mt-5 block">Name</Label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="Enter the name of the space"
|
||||
type="url"
|
||||
data-modal-autofocus
|
||||
className="bg-rgray-4 mt-2 w-full"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="bg-rgray-4 mt-2 w-full focus-visible:data-[error=true]:ring-red-500/10 data-[error=true]:placeholder:text-red-400 placeholder:transition placeholder:duration-500"
|
||||
/>
|
||||
<Label className="mt-5 block">Memories</Label>
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<Label className="mt-5 block">Add Memories</Label>
|
||||
<div className="flex min-h-5 py-2 flex-col justify-center items-center">
|
||||
{selected.map(i => (
|
||||
<MemorySelectedItem
|
||||
key={i.id}
|
||||
onRemove={() => setSelected(prev => prev.filter(p => p.id !== i.id))}
|
||||
{...i}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<DialogClose
|
||||
<FilterMemories
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
className="mr-auto bg-white/5 hover:bg-rgray-4 focus-visible:bg-rgray-4"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Memory
|
||||
</FilterMemories>
|
||||
<button
|
||||
type={undefined}
|
||||
onClick={() => {
|
||||
if (check()) {
|
||||
|
||||
}
|
||||
}}
|
||||
className="bg-rgray-4 hover:bg-rgray-5 focus-visible:bg-rgray-5 focus-visible:ring-rgray-7 rounded-md px-4 py-2 ring-transparent transition focus-visible:outline-none focus-visible:ring-2"
|
||||
>
|
||||
Add
|
||||
</DialogClose>
|
||||
</button>
|
||||
<DialogClose className="hover:bg-rgray-4 focus-visible:bg-rgray-4 focus-visible:ring-rgray-7 rounded-md px-3 py-2 ring-transparent transition focus-visible:outline-none focus-visible:ring-2">
|
||||
Cancel
|
||||
</DialogClose>
|
||||
|
|
@ -211,3 +242,16 @@ export function SpaceAddPage({ closeDialog }: { closeDialog: () => void }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemorySelectedItem({ id, title, url, image, onRemove }: StoredContent & { onRemove: () => void; }) {
|
||||
return (
|
||||
<div className="flex justify-start gap-2 p-1 px-2 w-full items-center text-sm rounded-md hover:bg-rgray-4 focus-within-bg-rgray-4 [&:hover>[data-icon]]:block [&:hover>img]:hidden">
|
||||
<img src={image ?? "/icons/logo_without_bg.png"} className="h-5 w-5" />
|
||||
<button onClick={onRemove} data-icon className="w-5 h-5 p-0 m-0 hidden focus-visible:outline-none">
|
||||
<X className="w-5 h-5 scale-90" />
|
||||
</button>
|
||||
<span>{title}</span>
|
||||
<span className="ml-auto block opacity-50">{cleanUrl(url)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ import {
|
|||
} from "@/components/ui/popover";
|
||||
import { SpaceIcon } from "@/assets/Memories";
|
||||
import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
|
||||
import { useMemory } from "@/contexts/MemoryContext";
|
||||
import { SearchResult, useMemory } from "@/contexts/MemoryContext";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { StoredContent } from "@/server/db/schema";
|
||||
|
||||
export interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
export interface FilterSpacesProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
side?: "top" | "bottom";
|
||||
align?: "end" | "start" | "center";
|
||||
onClose?: () => void;
|
||||
|
|
@ -42,7 +44,7 @@ export function FilterSpaces({
|
|||
setSelectedSpaces,
|
||||
name,
|
||||
...props
|
||||
}: Props) {
|
||||
}: FilterSpacesProps) {
|
||||
const { spaces } = useMemory();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
|
|
@ -150,26 +152,58 @@ export function FilterSpaces({
|
|||
);
|
||||
}
|
||||
|
||||
export type FilterMemoriesProps = {
|
||||
side?: "top" | "bottom";
|
||||
align?: "end" | "start" | "center";
|
||||
onClose?: () => void;
|
||||
selected: StoredContent[];
|
||||
setSelected: React.Dispatch<React.SetStateAction<StoredContent[]>>;
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
export function FilterMemories({
|
||||
className,
|
||||
side = "bottom",
|
||||
align = "center",
|
||||
onClose,
|
||||
selectedSpaces,
|
||||
setSelectedSpaces,
|
||||
name,
|
||||
selected,
|
||||
setSelected,
|
||||
...props
|
||||
}: Props) {
|
||||
const { spaces } = useMemory();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
}: FilterMemoriesProps) {
|
||||
|
||||
const sortedSpaces = spaces.sort(({ id: a }, { id: b }) =>
|
||||
selectedSpaces.includes(a) && !selectedSpaces.includes(b)
|
||||
? -1
|
||||
: selectedSpaces.includes(b) && !selectedSpaces.includes(a)
|
||||
? 1
|
||||
: 0,
|
||||
);
|
||||
const { search } = useMemory();
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const query = useDebounce(searchQuery, 500)
|
||||
|
||||
const [searchResults, setSearchResults] = React.useState<SearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = React.useState(false)
|
||||
|
||||
const results = React.useMemo(() => {
|
||||
console.log("use memo")
|
||||
return searchResults.map(r => r.memory)
|
||||
}, [searchResults])
|
||||
|
||||
console.log('memoized', results)
|
||||
|
||||
React.useEffect(() => {
|
||||
const q = query.trim()
|
||||
if (q.length > 0) {
|
||||
setIsSearching(true);
|
||||
(async () => {
|
||||
const results = await search(q, {
|
||||
filter: {
|
||||
memories: true,
|
||||
spaces: false
|
||||
}
|
||||
})
|
||||
setSearchResults(results)
|
||||
setIsSearching(false)
|
||||
})();
|
||||
} else {
|
||||
setSearchResults([])
|
||||
}
|
||||
}, [query])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -177,6 +211,7 @@ export function FilterMemories({
|
|||
}
|
||||
}, [open]);
|
||||
|
||||
console.log(searchResults);
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
<LayoutGroup>
|
||||
|
|
@ -191,15 +226,7 @@ export function FilterMemories({
|
|||
)}
|
||||
{...props}
|
||||
>
|
||||
<SpaceIcon className="mr-1 h-5 w-5" />
|
||||
{name}
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<div
|
||||
data-state-on={selectedSpaces.length > 0}
|
||||
className="on:flex text-rgray-11 border-rgray-6 bg-rgray-2 absolute left-0 top-0 hidden aspect-[1] h-4 w-4 -translate-x-1/3 -translate-y-1/3 items-center justify-center rounded-full border text-center text-[9px]"
|
||||
>
|
||||
{selectedSpaces.length}
|
||||
</div>
|
||||
{props.children}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
|
@ -209,56 +236,42 @@ export function FilterMemories({
|
|||
className="w-[200px] p-0"
|
||||
>
|
||||
<Command
|
||||
filter={(val, search) =>
|
||||
spaces
|
||||
.find((s) => s.id.toString() === val)
|
||||
?.name.toLowerCase()
|
||||
.includes(search.toLowerCase().trim())
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
shouldFilter={false}
|
||||
>
|
||||
<CommandInput placeholder="Filter spaces..." />
|
||||
<CommandList asChild>
|
||||
<motion.div layoutScroll>
|
||||
<CommandEmpty>Nothing found</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sortedSpaces.map((space) => (
|
||||
<CommandItem
|
||||
key={space.id}
|
||||
value={space.id.toString()}
|
||||
onSelect={(val) => {
|
||||
setSelectedSpaces((prev: number[]) =>
|
||||
prev.includes(parseInt(val))
|
||||
? prev.filter((v) => v !== parseInt(val))
|
||||
: [...prev, parseInt(val)],
|
||||
);
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { delay: 0.05 } }}
|
||||
transition={{ duration: 0.15 }}
|
||||
layout
|
||||
layoutId={`space-combobox-${space.id}`}
|
||||
className="text-rgray-11"
|
||||
>
|
||||
<SpaceIcon className="mr-2 h-4 w-4" />
|
||||
{space.name}
|
||||
{selectedSpaces.includes(space.id)}
|
||||
<Check
|
||||
data-state-on={selectedSpaces.includes(space.id)}
|
||||
className={cn(
|
||||
"on:opacity-100 ml-auto h-4 w-4 opacity-0",
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</motion.div>
|
||||
</CommandList>
|
||||
<CommandInput isSearching={isSearching} value={searchQuery} onValueChange={setSearchQuery} placeholder="Filter memories..." />
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandEmpty className="text-rgray-11 text-sm text-center py-5">{isSearching ? "Searching..." : query.trim().length > 0 ? "Nothing Found" : "Search something"}</CommandEmpty>
|
||||
{results.map((m) => (
|
||||
<CommandItem
|
||||
key={m.id}
|
||||
value={m.id.toString()}
|
||||
onSelect={(val) => {
|
||||
setSelected((prev) =>
|
||||
prev.find(p => p.id === parseInt(val))
|
||||
? prev.filter((v) => v.id !== parseInt(val))
|
||||
: [...prev, m],
|
||||
);
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<div
|
||||
className="text-rgray-11"
|
||||
>
|
||||
<img src={m.image ?? "/icons/logo_without_bg.png"} className="mr-2 h-4 w-4" />
|
||||
{m.title}
|
||||
<Check
|
||||
data-state-on={selected.find(i => i.id === m.id) !== undefined}
|
||||
className={cn(
|
||||
"on:opacity-100 ml-auto h-4 w-4 opacity-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function MemoriesBar() {
|
|||
const [expandedSpace, setExpandedSpace] = useState<number | null>(null);
|
||||
const [searchQuery, setSearcyQuery] = useState("");
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const query = useDebounce(searchQuery, 1000)
|
||||
const query = useDebounce(searchQuery, 500)
|
||||
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
|
||||
|
|
@ -148,7 +148,7 @@ export function MemoriesBar() {
|
|||
ref={parent}
|
||||
className="grid w-full grid-flow-row grid-cols-3 gap-1 px-2 py-5"
|
||||
>
|
||||
{searchQuery.trim().length > 0 ? (
|
||||
{query.trim().length > 0 ? (
|
||||
<>
|
||||
{searchResults.map(({ type, space, memory }, i) => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@
|
|||
import * as React from "react";
|
||||
import { type DialogProps } from "@radix-ui/react-dialog";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
import { Loader, Search } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { isSea } from "node:sea";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
|
|
@ -39,13 +40,13 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
|||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & { isSearching?: boolean }
|
||||
>(({ className, isSearching = false ,...props }, ref) => (
|
||||
<div
|
||||
className="border-rgray-6 flex items-center border-b px-3"
|
||||
cmdk-input-wrapper=""
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
{isSearching ? <Loader className="mr-2 h-4 w-4 shrink-9 opacity-50 animate-spin" /> : <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />}
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const MemoryContext = React.createContext<{
|
|||
spaces?: number[],
|
||||
) => Promise<void>;
|
||||
cachedMemories: ChachedSpaceContent[];
|
||||
search: (query: string) => Promise<SearchResult[]>;
|
||||
search: typeof searchMemoriesAndSpaces;
|
||||
}>({
|
||||
spaces: [],
|
||||
freeMemories: [],
|
||||
|
|
@ -57,15 +57,7 @@ export const MemoryProvider: React.FC<
|
|||
const deleteSpace = async (id: number) => {
|
||||
setSpaces((prev) => prev.filter((s) => s.id !== id));
|
||||
}
|
||||
|
||||
const search = async (query: string) => {
|
||||
if (!user.id) {
|
||||
throw new Error('user id is not define')
|
||||
}
|
||||
const data = await searchMemoriesAndSpaces(query)
|
||||
return data as SearchResult[]
|
||||
}
|
||||
|
||||
|
||||
// const fetchMemories = useCallback(async (query: string) => {
|
||||
// const response = await fetch(`/api/memories?${query}`);
|
||||
// }, []);
|
||||
|
|
@ -80,7 +72,7 @@ export const MemoryProvider: React.FC<
|
|||
return (
|
||||
<MemoryContext.Provider
|
||||
value={{
|
||||
search,
|
||||
search: searchMemoriesAndSpaces,
|
||||
spaces,
|
||||
addSpace,
|
||||
deleteSpace,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue