spaces dialog

This commit is contained in:
yxshv 2024-04-13 18:11:14 +05:30
parent 070ead4551
commit 5b34007124
6 changed files with 167 additions and 106 deletions

View file

@ -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 {

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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) => (
<>

View file

@ -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(

View file

@ -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,