mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-02 05:24:56 +08:00
feat: change "All Spaces" to "Nova Spaces" with multi-select support (#731)
### Implemented nova spaces multi-select
##### Summary
- Renamed "All Spaces" to "Nova Spaces" - now filters to only nova content (sm_project_*)
- Added multi-select spaces support via "Select Spaces" modal
- Projects API now returns `{ nova, developer }` instead of `{ projects }`
##### Changes
- `selectedProject` → `selectedProjects[]` (array-based selection)
- New `SelectSpacesModal` component for picking multiple spaces
- Selected spaces appear at top in modal
- Search works by containerTag
- Graphs filter by selected spaces
This commit is contained in:
@@ -63,7 +63,7 @@ function ViewErrorFallback() {
|
||||
export default function NewPage() {
|
||||
const isMobile = useIsMobile()
|
||||
const { user, session } = useAuth()
|
||||
const { selectedProject } = useProject()
|
||||
const { selectedProject, isNovaSpaces, novaContainerTags } = useProject()
|
||||
const { viewMode, setViewMode } = useViewMode()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -88,7 +88,10 @@ export default function NewPage() {
|
||||
const [isSearchOpen, setIsSearchOpen] = useQueryState("search", searchParam)
|
||||
const [searchPrefill, setSearchPrefill] = useQueryState("q", qParam)
|
||||
const [docId, setDocId] = useQueryState("doc", docParam)
|
||||
const [isFullscreen, setIsFullscreen] = useQueryState("fullscreen", fullscreenParam)
|
||||
const [isFullscreen, setIsFullscreen] = useQueryState(
|
||||
"fullscreen",
|
||||
fullscreenParam,
|
||||
)
|
||||
const [isChatOpen, setIsChatOpen] = useQueryState("chat", chatParam)
|
||||
|
||||
// Ephemeral local state (not worth URL-encoding)
|
||||
@@ -399,6 +402,7 @@ export default function NewPage() {
|
||||
if (!open) setSearchPrefill("")
|
||||
}}
|
||||
projectId={selectedProject}
|
||||
novaContainerTags={isNovaSpaces ? novaContainerTags : undefined}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
|
||||
@@ -155,7 +155,7 @@ export function ConnectAIModal({
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to load projects")
|
||||
}
|
||||
return response.data?.projects || []
|
||||
return (response.data?.projects || []) as Project[]
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
@@ -803,7 +803,9 @@ export function ConnectAIModal({
|
||||
className="bg-input border-border text-foreground"
|
||||
id="mcpUrl"
|
||||
onBlur={handleBlur}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onChange={(
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => handleChange(e.target.value)}
|
||||
placeholder="https://mcp.supermemory.ai/your-user-id/sse"
|
||||
value={state.value}
|
||||
/>
|
||||
|
||||
@@ -105,7 +105,9 @@ export function AddDocument({
|
||||
const [addParam, setAddParam] = useQueryState("add", addDocumentParam)
|
||||
const activeTab: TabType = addParam ?? "note"
|
||||
const setActiveTab = useCallback(
|
||||
(tab: TabType) => { setAddParam(tab) },
|
||||
(tab: TabType) => {
|
||||
setAddParam(tab)
|
||||
},
|
||||
[setAddParam],
|
||||
)
|
||||
const { selectedProject: globalSelectedProject } = useProject()
|
||||
@@ -199,20 +201,23 @@ export function AddDocument({
|
||||
setFileData(data)
|
||||
}, [])
|
||||
|
||||
// Button click handler
|
||||
const handleButtonClick = () => {
|
||||
if (activeTab === "note") {
|
||||
handleNoteSubmit(noteContent)
|
||||
} else if (activeTab === "link") {
|
||||
handleLinkSubmit(linkData)
|
||||
} else if (activeTab === "file") {
|
||||
if (fileData.file) {
|
||||
handleFileSubmit(
|
||||
fileData as { file: File; title: string; description: string },
|
||||
)
|
||||
} else {
|
||||
toast.error("Please select a file")
|
||||
}
|
||||
switch (activeTab) {
|
||||
case "note":
|
||||
handleNoteSubmit(noteContent)
|
||||
break
|
||||
case "link":
|
||||
handleLinkSubmit(linkData)
|
||||
break
|
||||
case "file":
|
||||
if (fileData.file) {
|
||||
handleFileSubmit(
|
||||
fileData as { file: File; title: string; description: string },
|
||||
)
|
||||
} else {
|
||||
toast.error("Please select a file")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,17 +255,27 @@ export function AddDocument({
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<div
|
||||
data-testid="usage-counter"
|
||||
className="flex flex-col gap-3 mr-4"
|
||||
>
|
||||
<div data-testid="usage-counter" className="flex flex-col gap-3 mr-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={cn("text-[#FAFAFA] text-sm font-medium", dmSansClassName())}>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[#FAFAFA] text-sm font-medium",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Credits
|
||||
</span>
|
||||
<span className={cn("text-sm font-medium", hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]", dmSansClassName())}>
|
||||
{isLoadingUsage ? "…" : `${tokensToCredits(tokensUsed)} / ${tokensToCredits(tokensLimit)}`}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{isLoadingUsage
|
||||
? "…"
|
||||
: `${tokensToCredits(tokensUsed)} / ${tokensToCredits(tokensLimit)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
|
||||
@@ -268,11 +283,12 @@ export function AddDocument({
|
||||
className="h-full rounded-[40px]"
|
||||
style={{
|
||||
width: `${tokensPercent}%`,
|
||||
background: tokensPercent > 80
|
||||
? "#ef4444"
|
||||
: hasPaidPlan
|
||||
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
|
||||
: "#0054AD",
|
||||
background:
|
||||
tokensPercent > 80
|
||||
? "#ef4444"
|
||||
: hasPaidPlan
|
||||
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
|
||||
: "#0054AD",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -280,11 +296,24 @@ export function AddDocument({
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={cn("text-[#FAFAFA] text-sm font-medium", dmSansClassName())}>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[#FAFAFA] text-sm font-medium",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Search Queries
|
||||
</span>
|
||||
<span className={cn("text-sm font-medium", hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]", dmSansClassName())}>
|
||||
{isLoadingUsage ? "…" : `${formatUsageNumber(searchesUsed)} / ${formatUsageNumber(searchesLimit)}`}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{isLoadingUsage
|
||||
? "…"
|
||||
: `${formatUsageNumber(searchesUsed)} / ${formatUsageNumber(searchesLimit)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
|
||||
@@ -292,11 +321,12 @@ export function AddDocument({
|
||||
className="h-full rounded-[40px]"
|
||||
style={{
|
||||
width: `${searchesPercent}%`,
|
||||
background: searchesPercent > 80
|
||||
? "#ef4444"
|
||||
: hasPaidPlan
|
||||
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
|
||||
: "#0054AD",
|
||||
background:
|
||||
searchesPercent > 80
|
||||
? "#ef4444"
|
||||
: hasPaidPlan
|
||||
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
|
||||
: "#0054AD",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -327,8 +357,10 @@ export function AddDocument({
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
|
||||
boxShadow: "1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
|
||||
background:
|
||||
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
|
||||
boxShadow:
|
||||
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
|
||||
}}
|
||||
>
|
||||
{isUpgrading ? (
|
||||
@@ -387,8 +419,10 @@ export function AddDocument({
|
||||
>
|
||||
{!isMobile && (
|
||||
<SpaceSelector
|
||||
value={localSelectedProject}
|
||||
onValueChange={setLocalSelectedProject}
|
||||
selectedProjects={[localSelectedProject]}
|
||||
onValueChange={(projects) =>
|
||||
setLocalSelectedProject(projects[0] ?? localSelectedProject)
|
||||
}
|
||||
variant="insideOut"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -12,14 +12,7 @@ import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import {
|
||||
SearchIcon,
|
||||
Settings,
|
||||
Home,
|
||||
Plus,
|
||||
Code2,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { SearchIcon, Settings, Home, Plus, Code2, Loader2 } from "lucide-react"
|
||||
import { DocumentIcon } from "@/components/new/document-icon"
|
||||
import { $fetch } from "@lib/api"
|
||||
|
||||
@@ -28,7 +21,13 @@ type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
type SearchResult = z.infer<typeof SearchResponseSchema>["results"][number]
|
||||
|
||||
type PaletteItem =
|
||||
| { kind: "action"; id: string; label: string; icon: React.ReactNode; action: () => void }
|
||||
| {
|
||||
kind: "action"
|
||||
id: string
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
action: () => void
|
||||
}
|
||||
| { kind: "document"; doc: DocumentWithMemories }
|
||||
| { kind: "search-result"; result: SearchResult }
|
||||
|
||||
@@ -36,6 +35,7 @@ interface DocumentsCommandPaletteProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
projectId: string
|
||||
novaContainerTags?: string[]
|
||||
onOpenDocument: (document: DocumentWithMemories) => void
|
||||
onAddMemory?: () => void
|
||||
onOpenIntegrations?: () => void
|
||||
@@ -46,6 +46,7 @@ export function DocumentsCommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
novaContainerTags,
|
||||
onOpenDocument,
|
||||
onAddMemory,
|
||||
onOpenIntegrations,
|
||||
@@ -64,12 +65,15 @@ export function DocumentsCommandPalette({
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const close = useCallback((then?: () => void) => {
|
||||
onOpenChange(false)
|
||||
setSearch("")
|
||||
setSearchResults([])
|
||||
if (then) setTimeout(then, 0)
|
||||
}, [onOpenChange])
|
||||
const close = useCallback(
|
||||
(then?: () => void) => {
|
||||
onOpenChange(false)
|
||||
setSearch("")
|
||||
setSearchResults([])
|
||||
if (then) setTimeout(then, 0)
|
||||
},
|
||||
[onOpenChange],
|
||||
)
|
||||
|
||||
const actions: PaletteItem[] = [
|
||||
{
|
||||
@@ -87,22 +91,32 @@ export function DocumentsCommandPalette({
|
||||
action: () => close(() => router.push("/settings")),
|
||||
},
|
||||
...(onAddMemory
|
||||
? [{
|
||||
kind: "action" as const,
|
||||
id: "add-memory",
|
||||
label: "Add Memory",
|
||||
icon: <Plus className="size-4 text-[#737373]" />,
|
||||
action: () => { close(); onAddMemory() },
|
||||
}]
|
||||
? [
|
||||
{
|
||||
kind: "action" as const,
|
||||
id: "add-memory",
|
||||
label: "Add Memory",
|
||||
icon: <Plus className="size-4 text-[#737373]" />,
|
||||
action: () => {
|
||||
close()
|
||||
onAddMemory()
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onOpenIntegrations
|
||||
? [{
|
||||
kind: "action" as const,
|
||||
id: "integrations",
|
||||
label: "Open Integrations",
|
||||
icon: <Code2 className="size-4 text-[#737373]" />,
|
||||
action: () => { close(); onOpenIntegrations() },
|
||||
}]
|
||||
? [
|
||||
{
|
||||
kind: "action" as const,
|
||||
id: "integrations",
|
||||
label: "Open Integrations",
|
||||
icon: <Code2 className="size-4 text-[#737373]" />,
|
||||
action: () => {
|
||||
close()
|
||||
onOpenIntegrations()
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
@@ -145,7 +159,8 @@ export function DocumentsCommandPalette({
|
||||
body: {
|
||||
q: search.trim(),
|
||||
limit: 10,
|
||||
containerTags: projectId ? [projectId] : undefined,
|
||||
containerTags:
|
||||
novaContainerTags ?? (projectId ? [projectId] : undefined),
|
||||
includeSummary: true,
|
||||
},
|
||||
signal: controller.signal,
|
||||
@@ -163,7 +178,7 @@ export function DocumentsCommandPalette({
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [search, projectId])
|
||||
}, [search, projectId, novaContainerTags])
|
||||
|
||||
// Build the item list
|
||||
const hasQuery = search.trim().length > 0
|
||||
@@ -175,10 +190,12 @@ export function DocumentsCommandPalette({
|
||||
}
|
||||
const q = search.toLowerCase()
|
||||
for (const a of actions) {
|
||||
if (a.kind === "action" && a.label.toLowerCase().includes(q)) items.push(a)
|
||||
if (a.kind === "action" && a.label.toLowerCase().includes(q))
|
||||
items.push(a)
|
||||
}
|
||||
} else {
|
||||
for (const doc of cachedDocs.slice(0, 10)) items.push({ kind: "document", doc })
|
||||
for (const doc of cachedDocs.slice(0, 10))
|
||||
items.push({ kind: "document", doc })
|
||||
for (const a of actions) items.push(a)
|
||||
}
|
||||
|
||||
@@ -211,7 +228,8 @@ export function DocumentsCommandPalette({
|
||||
createdAt: item.result.createdAt as unknown as string,
|
||||
updatedAt: item.result.updatedAt as unknown as string,
|
||||
url: (item.result.metadata?.url as string) ?? null,
|
||||
content: item.result.content ?? item.result.chunks?.[0]?.content ?? null,
|
||||
content:
|
||||
item.result.content ?? item.result.chunks?.[0]?.content ?? null,
|
||||
summary: item.result.summary ?? null,
|
||||
} as unknown as DocumentWithMemories)
|
||||
close()
|
||||
@@ -264,20 +282,15 @@ export function DocumentsCommandPalette({
|
||||
)
|
||||
}
|
||||
|
||||
const title =
|
||||
item.kind === "document" ? item.doc.title : item.result.title
|
||||
const type =
|
||||
item.kind === "document" ? item.doc.type : item.result.type
|
||||
const title = item.kind === "document" ? item.doc.title : item.result.title
|
||||
const type = item.kind === "document" ? item.doc.type : item.result.type
|
||||
const url =
|
||||
item.kind === "document"
|
||||
? item.doc.url
|
||||
: (item.result.metadata?.url as string) ?? null
|
||||
: ((item.result.metadata?.url as string) ?? null)
|
||||
const date =
|
||||
item.kind === "document"
|
||||
? item.doc.createdAt
|
||||
: item.result.createdAt
|
||||
const key =
|
||||
item.kind === "document" ? item.doc.id : item.result.documentId
|
||||
item.kind === "document" ? item.doc.createdAt : item.result.createdAt
|
||||
const key = item.kind === "document" ? item.doc.id : item.result.documentId
|
||||
const snippet =
|
||||
item.kind === "search-result"
|
||||
? item.result.chunks?.find((c) => c.isRelevant)?.content
|
||||
@@ -391,11 +404,14 @@ export function DocumentsCommandPalette({
|
||||
.filter(({ item }) => item.kind === "action")
|
||||
.map(({ item, globalIndex }) => renderItem(item, globalIndex))}
|
||||
|
||||
{hasQuery && !isSearching && searchResults.length === 0 && items.every((i) => i.kind === "action") && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-[#737373] text-sm">No results found</p>
|
||||
</div>
|
||||
)}
|
||||
{hasQuery &&
|
||||
!isSearching &&
|
||||
searchResults.length === 0 &&
|
||||
items.every((i) => i.kind === "action") && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-[#737373] text-sm">No results found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-4 py-2.5 text-[11px] text-[#737373]">
|
||||
|
||||
@@ -17,13 +17,14 @@ interface GraphLayoutViewProps {
|
||||
}
|
||||
|
||||
export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
|
||||
const { selectedProject } = useProject()
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useQueryState("share", shareParam)
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useQueryState(
|
||||
"share",
|
||||
shareParam,
|
||||
)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
const containerTags = selectedProject ? [selectedProject] : undefined
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
setIsShareModalOpen(true)
|
||||
}, [setIsShareModalOpen])
|
||||
@@ -37,7 +38,7 @@ export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
|
||||
{/* Full-width graph */}
|
||||
<div className="absolute inset-0">
|
||||
<MemoryGraph
|
||||
containerTags={containerTags}
|
||||
containerTags={effectiveContainerTags}
|
||||
variant="consumer"
|
||||
highlightDocumentIds={allHighlightDocumentIds}
|
||||
highlightsVisible={isChatOpen}
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useProjectMutations } from "@/hooks/use-project-mutations"
|
||||
import { useProject } from "@/stores"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
@@ -51,8 +50,7 @@ interface HeaderProps {
|
||||
|
||||
export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
||||
const { user } = useAuth()
|
||||
const { selectedProject } = useProject()
|
||||
const { switchProject } = useProjectMutations()
|
||||
const { selectedProjects, setSelectedProjects } = useProject()
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const { resetOrgOnboarded } = useOrgOnboarding()
|
||||
@@ -144,8 +142,8 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
||||
<div className="self-stretch w-px bg-[#FFFFFF33] hidden md:block" />
|
||||
{!isMobile && (
|
||||
<SpaceSelector
|
||||
value={selectedProject}
|
||||
onValueChange={switchProject}
|
||||
selectedProjects={selectedProjects}
|
||||
onValueChange={setSelectedProjects}
|
||||
showChevron
|
||||
enableDelete
|
||||
/>
|
||||
@@ -196,8 +194,8 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
||||
{isMobile ? (
|
||||
<>
|
||||
<SpaceSelector
|
||||
value={selectedProject}
|
||||
onValueChange={switchProject}
|
||||
selectedProjects={selectedProjects}
|
||||
onValueChange={setSelectedProjects}
|
||||
showChevron
|
||||
enableDelete
|
||||
compact
|
||||
|
||||
@@ -56,8 +56,10 @@ export function MCPModal({
|
||||
<DialogFooter className="justify-between!">
|
||||
<div className="flex items-center gap-2">
|
||||
<SpaceSelector
|
||||
value={selectedProject}
|
||||
onValueChange={switchProject}
|
||||
selectedProjects={[selectedProject]}
|
||||
onValueChange={(projects) =>
|
||||
switchProject(projects[0] ?? selectedProject)
|
||||
}
|
||||
variant="insideOut"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -96,7 +96,7 @@ export function MemoriesGrid({
|
||||
highlightsProps,
|
||||
}: MemoriesGridProps) {
|
||||
const { user } = useAuth()
|
||||
const { selectedProject } = useProject()
|
||||
const { selectedProjects, effectiveContainerTags } = useProject()
|
||||
const isMobile = useIsMobile()
|
||||
const [selectedCategories, setSelectedCategories] = useQueryState(
|
||||
"categories",
|
||||
@@ -104,11 +104,11 @@ export function MemoriesGrid({
|
||||
)
|
||||
|
||||
const { data: facetsData } = useQuery({
|
||||
queryKey: ["document-facets", selectedProject],
|
||||
queryKey: ["document-facets", effectiveContainerTags],
|
||||
queryFn: async (): Promise<FacetsResponse> => {
|
||||
const response = await $fetch("@post/documents/documents/facets", {
|
||||
body: {
|
||||
containerTags: selectedProject ? [selectedProject] : undefined,
|
||||
containerTags: effectiveContainerTags,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
@@ -131,7 +131,11 @@ export function MemoriesGrid({
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery<DocumentsResponse, Error>({
|
||||
queryKey: ["documents-with-memories", selectedProject, selectedCategories],
|
||||
queryKey: [
|
||||
"documents-with-memories",
|
||||
effectiveContainerTags,
|
||||
selectedCategories,
|
||||
],
|
||||
initialPageParam: 1,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const response = await $fetch("@post/documents/documents", {
|
||||
@@ -140,7 +144,7 @@ export function MemoriesGrid({
|
||||
limit: PAGE_SIZE,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags: selectedProject ? [selectedProject] : undefined,
|
||||
containerTags: effectiveContainerTags,
|
||||
categories:
|
||||
selectedCategories.length > 0 ? selectedCategories : undefined,
|
||||
},
|
||||
@@ -346,9 +350,7 @@ export function MemoriesGrid({
|
||||
)}
|
||||
<div className="w-[216px] shrink-0">
|
||||
<GraphCard
|
||||
containerTags={
|
||||
selectedProject ? [selectedProject] : undefined
|
||||
}
|
||||
containerTags={effectiveContainerTags}
|
||||
width={200}
|
||||
height={220}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
|
||||
import { cn } from "@lib/utils"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon, Search, Check } from "lucide-react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
|
||||
import type { ContainerTagListType } from "@repo/lib/types"
|
||||
|
||||
interface SelectSpacesModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
selectedProjects: string[]
|
||||
onApply: (selected: string[]) => void
|
||||
projects: ContainerTagListType[]
|
||||
}
|
||||
|
||||
export function SelectSpacesModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedProjects,
|
||||
onApply,
|
||||
projects,
|
||||
}: SelectSpacesModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [localSelection, setLocalSelection] =
|
||||
useState<string[]>(selectedProjects)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalSelection(selectedProjects)
|
||||
}
|
||||
}, [isOpen, selectedProjects])
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
onClose()
|
||||
setSearchQuery("")
|
||||
setLocalSelection(selectedProjects)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = (containerTag: string) => {
|
||||
setLocalSelection((prev) => {
|
||||
if (prev.includes(containerTag)) {
|
||||
return prev.filter((tag) => tag !== containerTag)
|
||||
}
|
||||
return [...prev, containerTag]
|
||||
})
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(localSelection)
|
||||
setSearchQuery("")
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose()
|
||||
setSearchQuery("")
|
||||
setLocalSelection(selectedProjects)
|
||||
}
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const defaultSpace = {
|
||||
id: "default",
|
||||
name: "My Space",
|
||||
emoji: "📁",
|
||||
containerTag: DEFAULT_PROJECT_ID,
|
||||
}
|
||||
|
||||
const allSpaces = [
|
||||
defaultSpace,
|
||||
...projects.filter((p) => p.containerTag !== DEFAULT_PROJECT_ID),
|
||||
]
|
||||
|
||||
let result = allSpaces
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
result = allSpaces.filter(
|
||||
(p) =>
|
||||
p.containerTag.toLowerCase().includes(query) ||
|
||||
p.name?.toLowerCase().includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
return result.sort((a, b) => {
|
||||
const aSelected = localSelection.includes(a.containerTag)
|
||||
const bSelected = localSelection.includes(b.containerTag)
|
||||
if (aSelected && !bSelected) return -1
|
||||
if (!aSelected && bSelected) return 1
|
||||
return 0
|
||||
})
|
||||
}, [projects, searchQuery, localSelection])
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"w-[90%]! max-w-[500px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 rounded-[22px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="pl-1 space-y-1 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Select Spaces
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[16px] leading-[1.35]">
|
||||
Choose one or more spaces to filter your memories
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
style={{
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
<XIcon stroke="#737373" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-[#737373]" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search spaces..."
|
||||
className={cn(
|
||||
"w-full bg-[#14161A] border border-[rgba(82,89,102,0.2)] pl-10 pr-4 py-3 rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto space-y-1 scrollbar-thin">
|
||||
{filteredProjects.length === 0 ? (
|
||||
<p className="text-center text-[#737373] text-sm py-4">
|
||||
No spaces found
|
||||
</p>
|
||||
) : (
|
||||
filteredProjects.map((project) => {
|
||||
const isSelected = localSelection.includes(project.containerTag)
|
||||
return (
|
||||
<button
|
||||
key={project.containerTag}
|
||||
type="button"
|
||||
onClick={() => handleToggle(project.containerTag)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 w-full px-3 py-2.5 rounded-[12px] cursor-pointer transition-colors text-left",
|
||||
isSelected
|
||||
? "bg-[#14161A] border border-[rgba(82,89,102,0.3)]"
|
||||
: "bg-transparent border border-transparent hover:bg-[#14161A]/50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors",
|
||||
isSelected
|
||||
? "bg-blue-500 border-blue-500"
|
||||
: "border-[#737373]",
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="size-3 text-white" />}
|
||||
</div>
|
||||
<span className="text-lg">{project.emoji || "📁"}</span>
|
||||
<span className="text-[#fafafa] text-sm font-medium truncate flex-1">
|
||||
{project.name ?? project.containerTag}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[#737373] text-sm">
|
||||
{localSelection.length === 0
|
||||
? "No spaces selected (showing all)"
|
||||
: `${localSelection.length} space${localSelection.length > 1 ? "s" : ""} selected`}
|
||||
</p>
|
||||
<div className="flex items-center gap-[22px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className={cn(
|
||||
"text-[#737373] font-medium text-[14px] cursor-pointer transition-colors hover:text-[#999]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={handleApply}
|
||||
className="px-4 py-[10px] rounded-full"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -3,22 +3,36 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { $fetch } from "@repo/lib/api"
|
||||
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { ChevronsLeftRight, Plus, Trash2, XIcon, Loader2 } from "lucide-react"
|
||||
import type { Project } from "@repo/lib/types"
|
||||
import {
|
||||
ChevronsLeftRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
XIcon,
|
||||
Loader2,
|
||||
Globe,
|
||||
Layers,
|
||||
} from "lucide-react"
|
||||
import type { ContainerTagListType } from "@repo/lib/types"
|
||||
import { AddSpaceModal } from "./add-space-modal"
|
||||
import { SelectSpacesModal } from "./select-spaces-modal"
|
||||
import { useProjectMutations } from "@/hooks/use-project-mutations"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { motion } from "motion/react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@repo/ui/components/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -30,8 +44,8 @@ import { Button } from "@repo/ui/components/button"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
export interface SpaceSelectorProps {
|
||||
value: string
|
||||
onValueChange: (containerTag: string) => void
|
||||
selectedProjects: string[]
|
||||
onValueChange: (containerTags: string[]) => void
|
||||
variant?: "default" | "insideOut"
|
||||
showChevron?: boolean
|
||||
triggerClassName?: string
|
||||
@@ -47,7 +61,7 @@ const triggerVariants = {
|
||||
}
|
||||
|
||||
export function SpaceSelector({
|
||||
value,
|
||||
selectedProjects,
|
||||
onValueChange,
|
||||
variant = "default",
|
||||
showChevron = false,
|
||||
@@ -59,6 +73,7 @@ export function SpaceSelector({
|
||||
}: SpaceSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [showSelectSpacesModal, setShowSelectSpacesModal] = useState(false)
|
||||
const [deleteDialog, setDeleteDialog] = useState<{
|
||||
open: boolean
|
||||
project: { id: string; name: string; containerTag: string } | null
|
||||
@@ -73,39 +88,65 @@ export function SpaceSelector({
|
||||
|
||||
const { deleteProjectMutation } = useProjectMutations()
|
||||
|
||||
const { data: projects = [], isLoading } = useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@get/projects")
|
||||
const { allProjects, novaProjects, isLoading } = useContainerTags()
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to load projects")
|
||||
}
|
||||
const isNovaSpaces = selectedProjects.length === 0
|
||||
|
||||
return response.data?.projects || []
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
const selectedProject = useMemo(() => {
|
||||
if (value === DEFAULT_PROJECT_ID) return { name: "My Space", emoji: "📁" }
|
||||
const found = projects.find((p: Project) => p.containerTag === value)
|
||||
return found
|
||||
? { name: found.name, emoji: found.emoji }
|
||||
: { name: value, emoji: undefined }
|
||||
}, [projects, value])
|
||||
|
||||
const selectedProjectName = selectedProject.name
|
||||
const selectedProjectEmoji = selectedProject.emoji || "📁"
|
||||
|
||||
const handleSelect = (containerTag: string) => {
|
||||
if (containerTag !== value) {
|
||||
analytics.spaceSwitched({ space_id: containerTag })
|
||||
const displayInfo = useMemo(() => {
|
||||
if (isNovaSpaces) {
|
||||
return { name: "Nova Spaces", emoji: null, isMultiple: false }
|
||||
}
|
||||
onValueChange(containerTag)
|
||||
|
||||
if (selectedProjects.length === 1) {
|
||||
const containerTag = selectedProjects[0]
|
||||
if (containerTag === DEFAULT_PROJECT_ID) {
|
||||
return { name: "My Space", emoji: "📁", isMultiple: false }
|
||||
}
|
||||
const found = allProjects.find(
|
||||
(p: ContainerTagListType) => p.containerTag === containerTag,
|
||||
)
|
||||
return {
|
||||
name: found?.name || containerTag,
|
||||
emoji: found?.emoji || "📁",
|
||||
isMultiple: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${selectedProjects.length} spaces`,
|
||||
emoji: null,
|
||||
isMultiple: true,
|
||||
}
|
||||
}, [allProjects, selectedProjects, isNovaSpaces])
|
||||
|
||||
const handleSelectNovaSpaces = () => {
|
||||
analytics.spaceSwitched({ space_id: "nova_spaces" })
|
||||
onValueChange([]) // Empty array = "Nova Spaces" (all nova)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleSelectSingleSpace = (containerTag: string) => {
|
||||
analytics.spaceSwitched({ space_id: containerTag })
|
||||
onValueChange([containerTag])
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleOpenSelectSpaces = () => {
|
||||
setIsOpen(false)
|
||||
setShowSelectSpacesModal(true)
|
||||
}
|
||||
|
||||
const handleSelectSpacesApply = (selected: string[]) => {
|
||||
if (selected.length > 0) {
|
||||
analytics.spaceSwitched({
|
||||
space_id:
|
||||
selected.length === 1 ? (selected[0] ?? "unknown") : "multiple",
|
||||
})
|
||||
}
|
||||
onValueChange(selected)
|
||||
setShowSelectSpacesModal(false)
|
||||
}
|
||||
|
||||
const handleNewSpace = () => {
|
||||
setIsOpen(false)
|
||||
setShowCreateDialog(true)
|
||||
@@ -161,14 +202,14 @@ export function SpaceSelector({
|
||||
}
|
||||
|
||||
const availableTargetProjects = useMemo(() => {
|
||||
const filtered = projects.filter(
|
||||
(p: Project) =>
|
||||
const filtered = novaProjects.filter(
|
||||
(p: ContainerTagListType) =>
|
||||
p.id !== deleteDialog.project?.id &&
|
||||
p.containerTag !== deleteDialog.project?.containerTag,
|
||||
)
|
||||
|
||||
const defaultProject = projects.find(
|
||||
(p: Project) => p.containerTag === DEFAULT_PROJECT_ID,
|
||||
const defaultProject = novaProjects.find(
|
||||
(p: ContainerTagListType) => p.containerTag === DEFAULT_PROJECT_ID,
|
||||
)
|
||||
|
||||
const isDefaultProjectBeingDeleted =
|
||||
@@ -176,7 +217,7 @@ export function SpaceSelector({
|
||||
|
||||
if (defaultProject && !isDefaultProjectBeingDeleted) {
|
||||
const defaultProjectIncluded = filtered.some(
|
||||
(p: Project) => p.containerTag === DEFAULT_PROJECT_ID,
|
||||
(p: ContainerTagListType) => p.containerTag === DEFAULT_PROJECT_ID,
|
||||
)
|
||||
if (!defaultProjectIncluded) {
|
||||
return [defaultProject, ...filtered]
|
||||
@@ -184,7 +225,7 @@ export function SpaceSelector({
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [projects, deleteDialog.project])
|
||||
}, [novaProjects, deleteDialog.project])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -193,18 +234,24 @@ export function SpaceSelector({
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-2 cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
"flex items-center gap-2 cursor-pointer transition-colors focus:outline-none focus-visible:outline-none",
|
||||
triggerVariants[variant],
|
||||
dmSansClassName(),
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-bold tracking-[-0.98px]">
|
||||
{selectedProjectEmoji}
|
||||
</span>
|
||||
{isNovaSpaces ? (
|
||||
<Globe className="size-4 text-white" />
|
||||
) : displayInfo.isMultiple ? (
|
||||
<Layers className="size-4 text-white" />
|
||||
) : (
|
||||
<span className="text-sm font-bold tracking-[-0.98px]">
|
||||
{displayInfo.emoji}
|
||||
</span>
|
||||
)}
|
||||
{!compact && (
|
||||
<span className="text-sm font-medium text-white">
|
||||
{isLoading ? "..." : selectedProjectName}
|
||||
{isLoading ? "..." : displayInfo.name}
|
||||
</span>
|
||||
)}
|
||||
{showChevron && (
|
||||
@@ -225,12 +272,33 @@ export function SpaceSelector({
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col">
|
||||
{/* Default Project - no delete allowed */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSelect(DEFAULT_PROJECT_ID)}
|
||||
onClick={handleSelectNovaSpaces}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium",
|
||||
value === DEFAULT_PROJECT_ID
|
||||
isNovaSpaces
|
||||
? "bg-[#293952]/40"
|
||||
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
|
||||
)}
|
||||
>
|
||||
<Globe className="size-4" />
|
||||
<span className="flex-1">Nova Spaces</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator className="bg-[#2E3033] my-1" />
|
||||
|
||||
<div className="px-3 py-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[#737373] font-medium">
|
||||
My Spaces
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSelectSingleSpace(DEFAULT_PROJECT_ID)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium",
|
||||
selectedProjects.length === 1 &&
|
||||
selectedProjects[0] === DEFAULT_PROJECT_ID
|
||||
? "bg-[#293952]/40"
|
||||
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
|
||||
)}
|
||||
@@ -239,16 +307,21 @@ export function SpaceSelector({
|
||||
<span className="flex-1">My Space</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* User Projects */}
|
||||
{projects
|
||||
.filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID)
|
||||
.map((project: Project) => (
|
||||
{novaProjects
|
||||
.filter(
|
||||
(p: ContainerTagListType) =>
|
||||
p.containerTag !== DEFAULT_PROJECT_ID,
|
||||
)
|
||||
.map((project: ContainerTagListType) => (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() => handleSelect(project.containerTag)}
|
||||
onClick={() =>
|
||||
handleSelectSingleSpace(project.containerTag)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium group",
|
||||
value === project.containerTag
|
||||
selectedProjects.length === 1 &&
|
||||
selectedProjects[0] === project.containerTag
|
||||
? "bg-[#293952]/40"
|
||||
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
|
||||
)}
|
||||
@@ -256,7 +329,9 @@ export function SpaceSelector({
|
||||
<span className="font-bold tracking-[-0.98px]">
|
||||
{project.emoji || "📁"}
|
||||
</span>
|
||||
<span className="truncate flex-1">{project.name}</span>
|
||||
<span className="truncate flex-1">
|
||||
{project.name ?? project.containerTag}
|
||||
</span>
|
||||
{enableDelete && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -276,6 +351,20 @@ export function SpaceSelector({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenSelectSpaces}
|
||||
className="flex items-center justify-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium border border-[#161F2C] hover:bg-[#0D121A]/80 transition-colors"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A 0%, #000000 100%)",
|
||||
}}
|
||||
>
|
||||
<Layers className="size-4" />
|
||||
<span>Select Spaces</span>
|
||||
</button>
|
||||
|
||||
{showNewSpace && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -299,10 +388,17 @@ export function SpaceSelector({
|
||||
onClose={() => setShowCreateDialog(false)}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog - matching /new design system */}
|
||||
<SelectSpacesModal
|
||||
isOpen={showSelectSpacesModal}
|
||||
onClose={() => setShowSelectSpacesModal(false)}
|
||||
selectedProjects={selectedProjects}
|
||||
onApply={handleSelectSpacesApply}
|
||||
projects={allProjects}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={deleteDialog.open}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
setDeleteDialog({
|
||||
open: false,
|
||||
@@ -330,21 +426,21 @@ export function SpaceSelector({
|
||||
className="flex justify-between items-start gap-4"
|
||||
>
|
||||
<div className="pl-1 space-y-1 flex-1">
|
||||
<p
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Delete space
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[16px] leading-[1.35]">
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#737373] font-medium text-[16px] leading-[1.35]">
|
||||
What would you like to do with the documents and memories in{" "}
|
||||
<span className="text-[#fafafa] font-medium">
|
||||
"{deleteDialog.project?.name}"
|
||||
</span>
|
||||
?
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
@@ -404,7 +500,7 @@ export function SpaceSelector({
|
||||
>
|
||||
<Select
|
||||
value={deleteDialog.targetProjectId}
|
||||
onValueChange={(val) =>
|
||||
onValueChange={(val: string) =>
|
||||
setDeleteDialog((prev) => ({
|
||||
...prev,
|
||||
targetProjectId: val,
|
||||
@@ -433,22 +529,24 @@ export function SpaceSelector({
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08)",
|
||||
}}
|
||||
>
|
||||
{availableTargetProjects.map((p: Project) => (
|
||||
<SelectItem
|
||||
key={p.id}
|
||||
value={p.id}
|
||||
className="text-[#fafafa] hover:bg-[#1B1F24] cursor-pointer rounded-md"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{p.emoji || "📁"}</span>
|
||||
<span>
|
||||
{p.containerTag === DEFAULT_PROJECT_ID
|
||||
? "My Space"
|
||||
: p.name}
|
||||
{availableTargetProjects.map(
|
||||
(p: ContainerTagListType) => (
|
||||
<SelectItem
|
||||
key={p.id}
|
||||
value={p.id}
|
||||
className="text-[#fafafa] hover:bg-[#1B1F24] cursor-pointer rounded-md"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{p.emoji || "📁"}</span>
|
||||
<span>
|
||||
{p.containerTag === DEFAULT_PROJECT_ID
|
||||
? "My Space"
|
||||
: p.name}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useMemo } from "react"
|
||||
import { $fetch } from "@repo/lib/api"
|
||||
import type { ContainerTagListType } from "@repo/lib/types"
|
||||
|
||||
export function useContainerTags() {
|
||||
const { data: allProjects = [], isLoading } = useQuery({
|
||||
queryKey: ["container-tags"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@get/container-tags/list")
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to load projects")
|
||||
}
|
||||
return (response.data || []) as ContainerTagListType[]
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
const novaProjects = useMemo(
|
||||
() => allProjects.filter((p) => p.isNova),
|
||||
[allProjects],
|
||||
)
|
||||
|
||||
const novaContainerTags = useMemo(
|
||||
() => novaProjects.map((p) => p.containerTag),
|
||||
[novaProjects],
|
||||
)
|
||||
|
||||
return {
|
||||
allProjects,
|
||||
novaProjects,
|
||||
novaContainerTags,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
@@ -31,12 +35,136 @@ interface UseDocumentMutationsOptions {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
interface OptimisticMemory {
|
||||
id: string
|
||||
content: string
|
||||
url: string | null
|
||||
title: string
|
||||
description: string
|
||||
containerTags: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
status: string
|
||||
type: string
|
||||
metadata: Record<string, unknown>
|
||||
memoryEntries: unknown[]
|
||||
isOptimistic?: boolean
|
||||
}
|
||||
|
||||
function addOptimisticMemoryToQueryData(
|
||||
old: unknown,
|
||||
memory: OptimisticMemory,
|
||||
): unknown {
|
||||
if (!old || typeof old !== "object") return old
|
||||
|
||||
const data = old as Record<string, unknown>
|
||||
|
||||
if ("pages" in data && Array.isArray(data.pages)) {
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map((page: unknown, index: number) => {
|
||||
if (index !== 0) return page
|
||||
const p = page as Record<string, unknown>
|
||||
if (!p?.documents || !Array.isArray(p.documents)) return page
|
||||
return {
|
||||
...p,
|
||||
documents: [memory, ...p.documents],
|
||||
pagination: p.pagination
|
||||
? {
|
||||
...(p.pagination as Record<string, unknown>),
|
||||
totalItems:
|
||||
((p.pagination as Record<string, number>).totalItems ?? 0) +
|
||||
1,
|
||||
}
|
||||
: p.pagination,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if ("documents" in data && Array.isArray(data.documents)) {
|
||||
return {
|
||||
...data,
|
||||
documents: [memory, ...data.documents],
|
||||
totalCount: ((data.totalCount as number) ?? 0) + 1,
|
||||
}
|
||||
}
|
||||
|
||||
return old
|
||||
}
|
||||
|
||||
function removeDocumentFromQueryData(
|
||||
old: unknown,
|
||||
documentId: string,
|
||||
): unknown {
|
||||
if (!old || typeof old !== "object") return old
|
||||
|
||||
const data = old as Record<string, unknown>
|
||||
|
||||
if ("pages" in data && Array.isArray(data.pages)) {
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map((page: unknown) => {
|
||||
const p = page as Record<string, unknown>
|
||||
if (!p?.documents || !Array.isArray(p.documents)) return page
|
||||
return {
|
||||
...p,
|
||||
documents: (p.documents as DocumentWithId[]).filter(
|
||||
(doc) => doc.id !== documentId && doc.customId !== documentId,
|
||||
),
|
||||
pagination: p.pagination
|
||||
? {
|
||||
...(p.pagination as Record<string, unknown>),
|
||||
totalItems: Math.max(
|
||||
0,
|
||||
((p.pagination as Record<string, number>).totalItems ?? 0) -
|
||||
1,
|
||||
),
|
||||
}
|
||||
: p.pagination,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if ("documents" in data && Array.isArray(data.documents)) {
|
||||
return {
|
||||
...data,
|
||||
documents: (data.documents as DocumentWithId[]).filter(
|
||||
(doc) => doc.id !== documentId && doc.customId !== documentId,
|
||||
),
|
||||
totalCount: Math.max(0, ((data.totalCount as number) ?? 0) - 1),
|
||||
}
|
||||
}
|
||||
|
||||
return old
|
||||
}
|
||||
|
||||
async function cancelAndSnapshotQueries(
|
||||
queryClient: QueryClient,
|
||||
): Promise<[unknown, unknown][]> {
|
||||
await queryClient.cancelQueries({ queryKey: ["documents-with-memories"] })
|
||||
return queryClient.getQueriesData({ queryKey: ["documents-with-memories"] })
|
||||
}
|
||||
|
||||
function restoreQueriesFromSnapshot(
|
||||
queryClient: QueryClient,
|
||||
previousQueries: [unknown, unknown][] | undefined,
|
||||
): void {
|
||||
if (!previousQueries) return
|
||||
for (const [queryKey, data] of previousQueries) {
|
||||
queryClient.setQueryData(queryKey as unknown[], data)
|
||||
}
|
||||
}
|
||||
|
||||
export function useDocumentMutations({
|
||||
onClose,
|
||||
}: UseDocumentMutationsOptions = {}) {
|
||||
const queryClient = useQueryClient()
|
||||
const { user } = useAuth()
|
||||
|
||||
const entityContext = `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`
|
||||
|
||||
const noteMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
content,
|
||||
@@ -47,12 +175,10 @@ export function useDocumentMutations({
|
||||
}) => {
|
||||
const response = await $fetch("@post/documents", {
|
||||
body: {
|
||||
content: content,
|
||||
content,
|
||||
containerTags: [project],
|
||||
entityContext: `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`,
|
||||
metadata: {
|
||||
sm_source: "consumer",
|
||||
},
|
||||
entityContext,
|
||||
metadata: { sm_source: "consumer" },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -63,24 +189,18 @@ export function useDocumentMutations({
|
||||
return response.data
|
||||
},
|
||||
onMutate: async ({ content, project }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["documents-with-memories", project],
|
||||
})
|
||||
const previousQueries = await cancelAndSnapshotQueries(queryClient)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const previousMemories = queryClient.getQueryData([
|
||||
"documents-with-memories",
|
||||
project,
|
||||
])
|
||||
|
||||
const optimisticMemory = {
|
||||
const optimisticMemory: OptimisticMemory = {
|
||||
id: `temp-${crypto.randomUUID()}`,
|
||||
content: content,
|
||||
content,
|
||||
url: null,
|
||||
title: content.substring(0, 100),
|
||||
description: "Processing content...",
|
||||
containerTags: [project],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: "queued",
|
||||
type: "note",
|
||||
metadata: {
|
||||
@@ -91,42 +211,25 @@ export function useDocumentMutations({
|
||||
isOptimistic: true,
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
["documents-with-memories", project],
|
||||
(old: DocumentsQueryData | undefined) => {
|
||||
const existingDocs = old?.documents ?? []
|
||||
return {
|
||||
...old,
|
||||
documents: [optimisticMemory, ...existingDocs],
|
||||
totalCount: (old?.totalCount ?? 0) + 1,
|
||||
}
|
||||
},
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["documents-with-memories"] },
|
||||
(old) => addOptimisticMemoryToQueryData(old, optimisticMemory),
|
||||
)
|
||||
|
||||
return { previousMemories }
|
||||
return { previousQueries }
|
||||
},
|
||||
onError: (_error, variables, context) => {
|
||||
if (context?.previousMemories) {
|
||||
queryClient.setQueryData(
|
||||
["documents-with-memories", variables.project],
|
||||
context.previousMemories,
|
||||
)
|
||||
}
|
||||
onError: (error, _variables, context) => {
|
||||
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
|
||||
toast.error("Failed to add note", {
|
||||
description: _error instanceof Error ? _error.message : "Unknown error",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
analytics.documentAdded({
|
||||
type: "note",
|
||||
project_id: variables.project,
|
||||
})
|
||||
analytics.documentAdded({ type: "note", project_id: variables.project })
|
||||
toast.success("Note added successfully!", {
|
||||
description: "Your note is being processed",
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["documents-with-memories", variables.project],
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
onClose?.()
|
||||
},
|
||||
})
|
||||
@@ -137,10 +240,8 @@ export function useDocumentMutations({
|
||||
body: {
|
||||
content: url,
|
||||
containerTags: [project],
|
||||
entityContext: `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`,
|
||||
metadata: {
|
||||
sm_source: "consumer",
|
||||
},
|
||||
entityContext,
|
||||
metadata: { sm_source: "consumer" },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -151,24 +252,18 @@ export function useDocumentMutations({
|
||||
return response.data
|
||||
},
|
||||
onMutate: async ({ url, project }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["documents-with-memories", project],
|
||||
})
|
||||
const previousQueries = await cancelAndSnapshotQueries(queryClient)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const previousMemories = queryClient.getQueryData([
|
||||
"documents-with-memories",
|
||||
project,
|
||||
])
|
||||
|
||||
const optimisticMemory = {
|
||||
const optimisticMemory: OptimisticMemory = {
|
||||
id: `temp-${crypto.randomUUID()}`,
|
||||
content: "",
|
||||
url: url,
|
||||
url,
|
||||
title: "Processing...",
|
||||
description: "Extracting content...",
|
||||
containerTags: [project],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: "queued",
|
||||
type: "link",
|
||||
metadata: {
|
||||
@@ -179,42 +274,25 @@ export function useDocumentMutations({
|
||||
isOptimistic: true,
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
["documents-with-memories", project],
|
||||
(old: DocumentsQueryData | undefined) => {
|
||||
const existingDocs = old?.documents ?? []
|
||||
return {
|
||||
...old,
|
||||
documents: [optimisticMemory, ...existingDocs],
|
||||
totalCount: (old?.totalCount ?? 0) + 1,
|
||||
}
|
||||
},
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["documents-with-memories"] },
|
||||
(old) => addOptimisticMemoryToQueryData(old, optimisticMemory),
|
||||
)
|
||||
|
||||
return { previousMemories }
|
||||
return { previousQueries }
|
||||
},
|
||||
onError: (_error, variables, context) => {
|
||||
if (context?.previousMemories) {
|
||||
queryClient.setQueryData(
|
||||
["documents-with-memories", variables.project],
|
||||
context.previousMemories,
|
||||
)
|
||||
}
|
||||
onError: (error, _variables, context) => {
|
||||
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
|
||||
toast.error("Failed to add link", {
|
||||
description: _error instanceof Error ? _error.message : "Unknown error",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
analytics.documentAdded({
|
||||
type: "link",
|
||||
project_id: variables.project,
|
||||
})
|
||||
analytics.documentAdded({ type: "link", project_id: variables.project })
|
||||
toast.success("Link added successfully!", {
|
||||
description: "Your link is being processed",
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["documents-with-memories", variables.project],
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
onClose?.()
|
||||
},
|
||||
})
|
||||
@@ -234,16 +312,8 @@ export function useDocumentMutations({
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("containerTags", JSON.stringify([project]))
|
||||
formData.append(
|
||||
"entityContext",
|
||||
`This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`,
|
||||
)
|
||||
formData.append(
|
||||
"metadata",
|
||||
JSON.stringify({
|
||||
sm_source: "consumer",
|
||||
}),
|
||||
)
|
||||
formData.append("entityContext", entityContext)
|
||||
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
|
||||
@@ -276,24 +346,18 @@ export function useDocumentMutations({
|
||||
return data
|
||||
},
|
||||
onMutate: async ({ file, title, description, project }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["documents-with-memories", project],
|
||||
})
|
||||
const previousQueries = await cancelAndSnapshotQueries(queryClient)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const previousMemories = queryClient.getQueryData([
|
||||
"documents-with-memories",
|
||||
project,
|
||||
])
|
||||
|
||||
const optimisticMemory = {
|
||||
const optimisticMemory: OptimisticMemory = {
|
||||
id: `temp-file-${crypto.randomUUID()}`,
|
||||
content: "",
|
||||
url: null,
|
||||
title: title || file.name,
|
||||
description: description || `Uploading ${file.name}...`,
|
||||
containerTags: [project],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: "processing",
|
||||
type: "file",
|
||||
metadata: {
|
||||
@@ -304,42 +368,25 @@ export function useDocumentMutations({
|
||||
memoryEntries: [],
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
["documents-with-memories", project],
|
||||
(old: DocumentsQueryData | undefined) => {
|
||||
const existingDocs = old?.documents ?? []
|
||||
return {
|
||||
...old,
|
||||
documents: [optimisticMemory, ...existingDocs],
|
||||
totalCount: (old?.totalCount ?? 0) + 1,
|
||||
}
|
||||
},
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["documents-with-memories"] },
|
||||
(old) => addOptimisticMemoryToQueryData(old, optimisticMemory),
|
||||
)
|
||||
|
||||
return { previousMemories }
|
||||
return { previousQueries }
|
||||
},
|
||||
onError: (_error, variables, context) => {
|
||||
if (context?.previousMemories) {
|
||||
queryClient.setQueryData(
|
||||
["documents-with-memories", variables.project],
|
||||
context.previousMemories,
|
||||
)
|
||||
}
|
||||
onError: (error, _variables, context) => {
|
||||
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
|
||||
toast.error("Failed to upload file", {
|
||||
description: _error instanceof Error ? _error.message : "Unknown error",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
analytics.documentAdded({
|
||||
type: "file",
|
||||
project_id: variables.project,
|
||||
})
|
||||
analytics.documentAdded({ type: "file", project_id: variables.project })
|
||||
toast.success("File uploaded successfully!", {
|
||||
description: "Your file is being processed",
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["documents-with-memories", variables.project],
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
onClose?.()
|
||||
},
|
||||
})
|
||||
@@ -355,9 +402,7 @@ export function useDocumentMutations({
|
||||
const response = await $fetch(`@patch/documents/${documentId}`, {
|
||||
body: {
|
||||
content,
|
||||
metadata: {
|
||||
sm_source: "consumer",
|
||||
},
|
||||
metadata: { sm_source: "consumer" },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -370,9 +415,7 @@ export function useDocumentMutations({
|
||||
onSuccess: (_data, variables) => {
|
||||
analytics.documentEdited({ document_id: variables.documentId })
|
||||
toast.success("Document saved successfully!")
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["documents-with-memories"],
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to save document", {
|
||||
@@ -394,78 +437,25 @@ export function useDocumentMutations({
|
||||
return response.data
|
||||
},
|
||||
onMutate: async ({ documentId }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["documents-with-memories"],
|
||||
})
|
||||
|
||||
const previousQueries = queryClient.getQueriesData({
|
||||
queryKey: ["documents-with-memories"],
|
||||
})
|
||||
const previousQueries = await cancelAndSnapshotQueries(queryClient)
|
||||
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["documents-with-memories"] },
|
||||
(old: QueryData | undefined) => {
|
||||
if (!old) return old
|
||||
|
||||
if ("pages" in old) {
|
||||
const infiniteData = old as InfiniteQueryData
|
||||
return {
|
||||
...infiniteData,
|
||||
pages: infiniteData.pages.map((page) => {
|
||||
if (!page?.documents) return page
|
||||
return {
|
||||
...page,
|
||||
documents: page.documents.filter(
|
||||
(doc) =>
|
||||
doc.id !== documentId && doc.customId !== documentId,
|
||||
),
|
||||
pagination: page.pagination
|
||||
? {
|
||||
...page.pagination,
|
||||
totalItems: Math.max(
|
||||
0,
|
||||
(page.pagination.totalItems ?? 0) - 1,
|
||||
),
|
||||
}
|
||||
: page.pagination,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if ("documents" in old) {
|
||||
const queryData = old as DocumentsQueryData
|
||||
return {
|
||||
...queryData,
|
||||
documents: queryData.documents.filter((doc: DocumentWithId) => {
|
||||
return doc.id !== documentId && doc.customId !== documentId
|
||||
}),
|
||||
totalCount: Math.max(0, (queryData.totalCount ?? 0) - 1),
|
||||
}
|
||||
}
|
||||
|
||||
return old
|
||||
},
|
||||
(old) => removeDocumentFromQueryData(old, documentId),
|
||||
)
|
||||
|
||||
return { previousQueries }
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.previousQueries) {
|
||||
context.previousQueries.forEach(([queryKey, data]) => {
|
||||
queryClient.setQueryData(queryKey, data)
|
||||
})
|
||||
}
|
||||
onError: (error, _variables, context) => {
|
||||
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
|
||||
toast.error("Failed to delete document", {
|
||||
description: _error instanceof Error ? _error.message : "Unknown error",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
analytics.documentDeleted({ document_id: variables.documentId })
|
||||
toast.success("Document deleted successfully!")
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["documents-with-memories"],
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
onClose?.()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,10 +4,12 @@ import { $fetch } from "@lib/api"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { useProject } from "@/stores"
|
||||
import type { Project } from "@repo/lib/types"
|
||||
|
||||
export function useProjectMutations() {
|
||||
const queryClient = useQueryClient()
|
||||
const { selectedProject, setSelectedProject } = useProject()
|
||||
const { selectedProjects, setSelectedProjects, setSelectedProject } =
|
||||
useProject()
|
||||
|
||||
const createProjectMutation = useMutation({
|
||||
mutationFn: async (input: string | { name: string; emoji?: string }) => {
|
||||
@@ -28,9 +30,8 @@ export function useProjectMutations() {
|
||||
toast.success("Project created successfully!")
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] })
|
||||
|
||||
// Automatically switch to the newly created project
|
||||
if (data?.containerTag) {
|
||||
setSelectedProject(data.containerTag)
|
||||
setSelectedProjects([data.containerTag])
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -63,13 +64,20 @@ export function useProjectMutations() {
|
||||
onSuccess: (_, variables) => {
|
||||
toast.success("Project deleted successfully")
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["container-tags"] })
|
||||
|
||||
// If we deleted the selected project, switch to default
|
||||
const deletedProject = queryClient
|
||||
.getQueryData<any[]>(["projects"])
|
||||
?.find((p) => p.id === variables.projectId)
|
||||
if (deletedProject?.containerTag === selectedProject) {
|
||||
setSelectedProject("sm_project_default")
|
||||
const allProjects =
|
||||
queryClient.getQueryData<Project[]>(["projects"]) || []
|
||||
const deletedProject = allProjects.find(
|
||||
(p) => p.id === variables.projectId,
|
||||
)
|
||||
if (
|
||||
deletedProject?.containerTag &&
|
||||
selectedProjects.includes(deletedProject.containerTag)
|
||||
) {
|
||||
setSelectedProjects(
|
||||
selectedProjects.filter((tag) => tag !== deletedProject.containerTag),
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -29,4 +29,4 @@ export const viewParam = parseAsStringLiteral(viewLiterals).withDefault("list")
|
||||
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(
|
||||
[],
|
||||
)
|
||||
export const projectParam = parseAsString.withDefault("sm_project_default")
|
||||
export const projectParam = parseAsArrayOf(parseAsString, ",").withDefault([])
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@ai-sdk/react": "^3.0.39",
|
||||
"@ai-sdk/xai": "^3.0.23",
|
||||
"@better-fetch/fetch": "^1.1.18",
|
||||
"@cloudflare/ai-chat": "^0.0.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -63,6 +64,7 @@
|
||||
"@tiptap/suggestion": "^3.15.3",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"agents": "^0.4.0",
|
||||
"ai": "^6.0.35",
|
||||
"autumn-js": "0.0.116",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -2,22 +2,54 @@
|
||||
|
||||
import { useQueryState } from "nuqs"
|
||||
import { projectParam } from "@/lib/search-params"
|
||||
import { useCallback } from "react"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
|
||||
export function useProject() {
|
||||
const [selectedProject, _setSelectedProject] = useQueryState(
|
||||
const [selectedProjects, _setSelectedProjects] = useQueryState(
|
||||
"project",
|
||||
projectParam,
|
||||
)
|
||||
const { novaContainerTags } = useContainerTags()
|
||||
|
||||
const isNovaSpaces = selectedProjects.length === 0
|
||||
|
||||
const selectedProject = isNovaSpaces
|
||||
? DEFAULT_PROJECT_ID
|
||||
: (selectedProjects[0] ?? DEFAULT_PROJECT_ID)
|
||||
|
||||
// Get effective container tags for API calls
|
||||
// When "Nova Spaces" is selected, use all nova container tags
|
||||
// Otherwise, use the selected projects
|
||||
const effectiveContainerTags = useMemo(
|
||||
() => (isNovaSpaces ? novaContainerTags : selectedProjects),
|
||||
[isNovaSpaces, novaContainerTags, selectedProjects],
|
||||
)
|
||||
|
||||
const setSelectedProjects = useCallback(
|
||||
(projects: string[]) => {
|
||||
_setSelectedProjects(projects.length === 0 ? null : projects)
|
||||
},
|
||||
[_setSelectedProjects],
|
||||
)
|
||||
|
||||
const setSelectedProject = useCallback(
|
||||
(projectId: string) => {
|
||||
_setSelectedProject(projectId)
|
||||
_setSelectedProjects([projectId])
|
||||
},
|
||||
[_setSelectedProject],
|
||||
[_setSelectedProjects],
|
||||
)
|
||||
|
||||
return { selectedProject, setSelectedProject }
|
||||
return {
|
||||
selectedProjects,
|
||||
selectedProject,
|
||||
setSelectedProjects,
|
||||
setSelectedProject,
|
||||
isNovaSpaces,
|
||||
effectiveContainerTags,
|
||||
novaContainerTags,
|
||||
}
|
||||
}
|
||||
|
||||
export { usePersistentChat, usePersistentChatStore } from "./chat"
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DeleteProjectSchema,
|
||||
DocumentsWithMemoriesQuerySchema,
|
||||
DocumentsWithMemoriesResponseSchema,
|
||||
ListContainerTagsResponseSchema,
|
||||
ListMemoriesResponseSchema,
|
||||
ListProjectsResponseSchema,
|
||||
MemoryAddSchema,
|
||||
@@ -179,6 +180,9 @@ export const apiSchema = createSchema({
|
||||
"@get/projects": {
|
||||
output: ListProjectsResponseSchema,
|
||||
},
|
||||
"@get/container-tags/list": {
|
||||
output: ListContainerTagsResponseSchema,
|
||||
},
|
||||
"@post/projects": {
|
||||
input: CreateProjectSchema,
|
||||
output: ProjectSchema,
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Common TypeScript types shared across the application
|
||||
*/
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
@@ -11,3 +7,8 @@ export interface Project {
|
||||
isExperimental?: boolean
|
||||
emoji?: string
|
||||
}
|
||||
|
||||
export interface ContainerTagListType extends Project {
|
||||
isExperimental: boolean
|
||||
isNova: boolean
|
||||
}
|
||||
|
||||
@@ -1123,6 +1123,20 @@ export const DocumentsWithMemoriesQuerySchema = z
|
||||
description: "Query parameters for listing documents with memory entries",
|
||||
})
|
||||
|
||||
export const DocumentFacetsQuerySchema = z
|
||||
.object({
|
||||
containerTags: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.openapi({
|
||||
description: "Optional container tags to filter facets by",
|
||||
example: ["sm_project_default"],
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Query parameters for getting document facets",
|
||||
})
|
||||
|
||||
export const MigrateMCPRequestSchema = z
|
||||
.object({
|
||||
userId: z.string().openapi({
|
||||
@@ -1269,7 +1283,7 @@ export const CreateProjectSchema = z
|
||||
export const ListProjectsResponseSchema = z
|
||||
.object({
|
||||
projects: z.array(ProjectSchema).openapi({
|
||||
description: "List of projects",
|
||||
description: "List of user-created projects with sm_project_* prefix",
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
@@ -1400,3 +1414,51 @@ export const BulkDeleteMemoriesResponseSchema = z
|
||||
.openapi({
|
||||
description: "Response for bulk memory deletion",
|
||||
})
|
||||
|
||||
export const ContainerTagListTypeSchema = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: "Unique identifier of the container tag/space",
|
||||
example: "space_abc123",
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "Display name of the container tag",
|
||||
example: "My Project",
|
||||
}),
|
||||
containerTag: z.string().openapi({
|
||||
description: "The container tag identifier",
|
||||
example: "sm_project_my_project",
|
||||
}),
|
||||
createdAt: z.string().openapi({
|
||||
description: "Creation timestamp",
|
||||
example: new Date().toISOString(),
|
||||
format: "date-time",
|
||||
}),
|
||||
updatedAt: z.string().openapi({
|
||||
description: "Last update timestamp",
|
||||
example: new Date().toISOString(),
|
||||
format: "date-time",
|
||||
}),
|
||||
isExperimental: z.boolean().openapi({
|
||||
description: "Whether the space is experimental",
|
||||
example: false,
|
||||
}),
|
||||
emoji: z.string().optional().openapi({
|
||||
description: "Emoji icon for the container tag",
|
||||
example: "📁",
|
||||
}),
|
||||
isNova: z.boolean().openapi({
|
||||
description: "True if containerTag starts with 'sm_project_'",
|
||||
example: true,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description:
|
||||
"Container tag with isNova flag indicating Nova vs developer project",
|
||||
})
|
||||
|
||||
export const ListContainerTagsResponseSchema = z
|
||||
.array(ContainerTagListTypeSchema)
|
||||
.openapi({
|
||||
description: "Flat array of all container tags with isNova flag",
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user