@tulip-systems/drive 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +662 -0
  2. package/package.json +113 -0
  3. package/src/components/content.tsx +13 -0
  4. package/src/components/context.client.tsx +12 -0
  5. package/src/components/dnd.client.tsx +47 -0
  6. package/src/components/grid-card.client.tsx +252 -0
  7. package/src/components/grid.client.tsx +96 -0
  8. package/src/components/navigation/breadcrumbs.client.tsx +125 -0
  9. package/src/components/navigation/header.client.tsx +45 -0
  10. package/src/components/navigation/toolbar.client.tsx +35 -0
  11. package/src/components/navigation/view-switcher.client.tsx +32 -0
  12. package/src/components/selection.client.tsx +48 -0
  13. package/src/components/view.client.tsx +67 -0
  14. package/src/config/filters.ts +14 -0
  15. package/src/config/types.tsx +90 -0
  16. package/src/entry.client.ts +7 -0
  17. package/src/entry.server.ts +4 -0
  18. package/src/entry.ts +10 -0
  19. package/src/lib/constants.ts +19 -0
  20. package/src/lib/contracts.ts +121 -0
  21. package/src/lib/dto.ts +83 -0
  22. package/src/lib/helpers.server.ts +14 -0
  23. package/src/lib/helpers.ts +32 -0
  24. package/src/lib/search-params.ts +5 -0
  25. package/src/lib/validators.ts +89 -0
  26. package/src/providers/google/components/command-file-update.tsx +100 -0
  27. package/src/providers/google/components/command-folder-create.tsx +104 -0
  28. package/src/providers/google/components/command-folder-update.tsx +100 -0
  29. package/src/providers/google/components/content.client.tsx +6 -0
  30. package/src/providers/google/components/navigation.client.tsx +21 -0
  31. package/src/providers/google/components/provider.client.tsx +60 -0
  32. package/src/providers/google/components/view.client.tsx +158 -0
  33. package/src/providers/google/config/columns-data.tsx +81 -0
  34. package/src/providers/google/config/filters.ts +3 -0
  35. package/src/providers/google/entry.client.ts +10 -0
  36. package/src/providers/google/entry.server.ts +5 -0
  37. package/src/providers/google/entry.ts +12 -0
  38. package/src/providers/google/lib/constants.ts +10 -0
  39. package/src/providers/google/lib/dto.ts +104 -0
  40. package/src/providers/google/lib/helpers.ts +37 -0
  41. package/src/providers/google/lib/router.server.ts +62 -0
  42. package/src/providers/google/lib/schema.ts +9 -0
  43. package/src/providers/google/lib/search-params.ts +7 -0
  44. package/src/providers/google/lib/service.server.ts +792 -0
  45. package/src/providers/google/lib/validators.ts +148 -0
  46. package/src/providers/local/components/command-file-update.tsx +93 -0
  47. package/src/providers/local/components/command-file-upload.tsx +29 -0
  48. package/src/providers/local/components/command-folder-create.tsx +100 -0
  49. package/src/providers/local/components/command-folder-update.tsx +93 -0
  50. package/src/providers/local/components/content.client.tsx +3 -0
  51. package/src/providers/local/components/navigation.client.tsx +23 -0
  52. package/src/providers/local/components/provider.client.tsx +90 -0
  53. package/src/providers/local/components/upload-zone-context.client.tsx +43 -0
  54. package/src/providers/local/components/upload-zone.client.tsx +182 -0
  55. package/src/providers/local/components/view.client.tsx +145 -0
  56. package/src/providers/local/config/columns-data.tsx +81 -0
  57. package/src/providers/local/config/filters.ts +14 -0
  58. package/src/providers/local/entry.client.ts +18 -0
  59. package/src/providers/local/entry.server.ts +7 -0
  60. package/src/providers/local/entry.ts +14 -0
  61. package/src/providers/local/lib/constants.ts +23 -0
  62. package/src/providers/local/lib/helpers.ts +105 -0
  63. package/src/providers/local/lib/route-handler.server.ts +153 -0
  64. package/src/providers/local/lib/router.server.ts +137 -0
  65. package/src/providers/local/lib/schema.ts +104 -0
  66. package/src/providers/local/lib/search-params.ts +4 -0
  67. package/src/providers/local/lib/service.server.ts +1116 -0
  68. package/src/providers/local/lib/upload.client.ts +177 -0
  69. package/src/providers/local/lib/validators.ts +154 -0
  70. package/src/styles.css +1 -0
package/package.json ADDED
@@ -0,0 +1,113 @@
1
+ {
2
+ "name": "@tulip-systems/drive",
3
+ "version": "0.7.0",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "license": "AGPL-3.0",
7
+ "devDependencies": {
8
+ "@types/pg": "^8.16.0",
9
+ "@types/react": "19.2.14",
10
+ "@types/react-dom": "19.2.3",
11
+ "aws-sdk-client-mock": "^4.1.0",
12
+ "aws-sdk-client-mock-vitest": "^7.0.1",
13
+ "tailwindcss": "^4.2.0",
14
+ "tsdown": "^0.20.3",
15
+ "typescript": "5.9.3",
16
+ "vite": "^7.3.1",
17
+ "vitest": "^4.1.0",
18
+ "@tulip-systems/typescript-config": "0.0.0"
19
+ },
20
+ "dependencies": {
21
+ "@dnd-kit/collision": "^0.3.2",
22
+ "@dnd-kit/dom": "^0.3.2",
23
+ "@dnd-kit/helpers": "^0.3.2",
24
+ "@dnd-kit/react": "^0.3.2",
25
+ "@dnd-kit/sortable": "^10.0.0",
26
+ "@dnd-kit/utilities": "^3.2.2",
27
+ "@google-cloud/local-auth": "2.1.0",
28
+ "@hookform/resolvers": "^5.2.2",
29
+ "@orpc/client": "^1.13.5",
30
+ "@orpc/contract": "^1.13.5",
31
+ "@orpc/server": "^1.13.5",
32
+ "@orpc/tanstack-query": "^1.13.5",
33
+ "@react-email/components": "^1.0.8",
34
+ "@tanstack/react-query": "^5.90.21",
35
+ "@tanstack/react-query-devtools": "^5.91.3",
36
+ "@tanstack/react-table": "^8.21.3",
37
+ "@tanstack/react-virtual": "^3.13.18",
38
+ "class-variance-authority": "^0.7.1",
39
+ "client-only": "^0.0.1",
40
+ "clsx": "^2.1.1",
41
+ "cmdk": "1.1.1",
42
+ "date-fns": "^4.1.0",
43
+ "deepmerge": "^4.3.1",
44
+ "drizzle-orm": "^0.45.1",
45
+ "drizzle-zod": "^0.8.3",
46
+ "embla-carousel-react": "^8.6.0",
47
+ "googleapis": "105",
48
+ "lucide-react": "^0.575.0",
49
+ "motion": "^12.34.3",
50
+ "next-themes": "^0.4.6",
51
+ "radix-ui": "^1.4.3",
52
+ "react-colorful": "^5.6.1",
53
+ "react-day-picker": "9.13.2",
54
+ "react-dropzone": "^15.0.0",
55
+ "react-email": "^5.2.8",
56
+ "react-hook-form": "^7.71.2",
57
+ "react-hotkeys-hook": "^5.2.4",
58
+ "react-resizable-panels": "^4.6.4",
59
+ "resend": "^6.9.2",
60
+ "server-cli-only": "^0.3.2",
61
+ "server-only": "^0.0.1",
62
+ "sonner": "^2.0.7",
63
+ "tailwind-merge": "^3.5.0",
64
+ "use-debounce": "^10.1.0",
65
+ "uuid": "^13.0.0",
66
+ "vaul": "^1.1.2",
67
+ "zod": "^4.3.6",
68
+ "@tulip-systems/core": "0.8.0"
69
+ },
70
+ "peerDependencies": {
71
+ "@better-auth/passkey": "^1.5.5",
72
+ "@tailwindcss/typography": "^0.5.19",
73
+ "better-auth": "^1.5.5",
74
+ "next": "16.1.6",
75
+ "nuqs": "2.8.8",
76
+ "pg": "8.18.0",
77
+ "react": "19.2.4",
78
+ "react-dom": "19.2.4",
79
+ "recharts": "2.15.4",
80
+ "sharp": "^0.34.5"
81
+ },
82
+ "publishConfig": {
83
+ "access": "public"
84
+ },
85
+ "engines": {
86
+ "node": "24.x"
87
+ },
88
+ "files": [
89
+ "dist",
90
+ "src"
91
+ ],
92
+ "exports": {
93
+ ".": "./dist/index.mjs",
94
+ "./client": "./dist/client.mjs",
95
+ "./google": "./dist/google.mjs",
96
+ "./google/client": "./dist/google/client.mjs",
97
+ "./google/server": "./dist/google/server.mjs",
98
+ "./local": "./dist/local.mjs",
99
+ "./local/client": "./dist/local/client.mjs",
100
+ "./local/server": "./dist/local/server.mjs",
101
+ "./server": "./dist/server.mjs",
102
+ "./package.json": "./package.json",
103
+ "./styles.css": "./src/styles.css"
104
+ },
105
+ "scripts": {
106
+ "dev": "tsdown --config-loader tsdown.config.ts --watch",
107
+ "build": "tsdown --config-loader tsdown.config.ts",
108
+ "lint": "biome check",
109
+ "format": "biome format --write",
110
+ "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist",
111
+ "test": "vitest"
112
+ }
113
+ }
@@ -0,0 +1,13 @@
1
+ import { cn } from "@tulip-systems/core/lib";
2
+ import type { ComponentProps } from "react";
3
+
4
+ /**
5
+ * Drive Content
6
+ */
7
+ export function DriveContent({ className, ...props }: ComponentProps<"div">) {
8
+ return (
9
+ <div {...props} className={cn("space-y-6", className)}>
10
+ {props.children}
11
+ </div>
12
+ );
13
+ }
@@ -0,0 +1,12 @@
1
+ "use client";
2
+
3
+ import type { Permission } from "@tulip-systems/core/auth";
4
+
5
+ /**
6
+ * DriveContext
7
+ */
8
+ export type DriveContextValue = {
9
+ namespace: string;
10
+ permission?: Permission;
11
+ meta?: object;
12
+ };
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import { PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
4
+ import { DragDropProvider } from "@dnd-kit/react";
5
+ import type { PropsWithChildren } from "react";
6
+
7
+ /**
8
+ * DriveViewProvider
9
+ */
10
+
11
+ export type DriveDragDropProviderProps = PropsWithChildren<{
12
+ disabled?: boolean;
13
+ onMove?: (input: { id: string; parentId: string | null }) => void;
14
+ }>;
15
+
16
+ export function DriveDragDropProvider(props: DriveDragDropProviderProps) {
17
+ return (
18
+ <DragDropProvider
19
+ sensors={(defaults) => [
20
+ ...defaults,
21
+ PointerSensor.configure({
22
+ activationConstraints: [
23
+ // Start dragging after moving 35 pixels to prevent accidental drags when clicking
24
+ new PointerActivationConstraints.Distance({ value: 35 }),
25
+ ],
26
+ }),
27
+ ]}
28
+ onDragEnd={(event) => {
29
+ if (props.disabled) return;
30
+
31
+ const { operation, canceled } = event;
32
+ const { source, target } = operation;
33
+
34
+ if (!source || !target || canceled) return;
35
+
36
+ const id = source.id.toString();
37
+ const parentId = target.id.toString() === "drive" ? null : target.id.toString();
38
+
39
+ if (id === parentId) return;
40
+
41
+ props.onMove?.({ id, parentId });
42
+ }}
43
+ >
44
+ {props.children}
45
+ </DragDropProvider>
46
+ );
47
+ }
@@ -0,0 +1,252 @@
1
+ "use client";
2
+
3
+ import { useDraggable, useDroppable } from "@dnd-kit/react";
4
+ import type { CommandDef } from "@tulip-systems/core/commands";
5
+ import {
6
+ ContextCommandMenu,
7
+ ContextCommandMenuContent,
8
+ ContextCommandMenuTrigger,
9
+ DropdownCommandMenu,
10
+ } from "@tulip-systems/core/commands/client";
11
+ import {
12
+ Card,
13
+ CardContent,
14
+ CardHeader,
15
+ CardTitle,
16
+ findStatus,
17
+ Skeleton,
18
+ } from "@tulip-systems/core/components";
19
+ import { FolderIcon } from "lucide-react";
20
+ import Image from "next/image";
21
+ import { useRouter } from "next/navigation";
22
+ import type { ComponentProps } from "react";
23
+ import { nodeSubtypeConfig, nodeSubtypeVariants } from "@/config/types";
24
+ import type { DriveNode } from "@/lib/dto";
25
+ import { useDriveSelectionContext } from "./selection.client";
26
+
27
+ /**
28
+ * Drive Grid Card
29
+ */
30
+ type DriveGridCardProps<TData extends DriveNode> = ComponentProps<"div"> & {
31
+ node: TData;
32
+ commands?: CommandDef<TData>[];
33
+ };
34
+
35
+ export function DriveGridCard<TData extends DriveNode>(props: DriveGridCardProps<TData>) {
36
+ if (props.node.availability === "pending") {
37
+ return <DriveGridCardSkeleton {...props} />;
38
+ }
39
+
40
+ if (props.node.type === "folder") {
41
+ return <DriveGridFolderCard {...props} />;
42
+ }
43
+
44
+ if (props.node.type === "file") {
45
+ return <DriveGridFileCard {...props} />;
46
+ }
47
+
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Folder card
53
+ */
54
+ export function DriveGridFolderCard<TData extends DriveNode>({
55
+ node,
56
+ commands,
57
+ onDoubleClick,
58
+ className,
59
+ ...props
60
+ }: DriveGridCardProps<TData>) {
61
+ const { id } = node;
62
+
63
+ const router = useRouter();
64
+
65
+ const { selection, selectionConditions } = useDriveSelectionContext();
66
+
67
+ const droppable = useDroppable({ id: node.id });
68
+ const draggable = useDraggable({ id: node.id });
69
+
70
+ if (node.hidden) return null;
71
+
72
+ return (
73
+ <ContextCommandMenu>
74
+ <ContextCommandMenuTrigger asChild>
75
+ <Card
76
+ {...props}
77
+ key={id}
78
+ ref={draggable.ref}
79
+ data-selected={selection?.rowSelection?.[id]}
80
+ data-dragging={draggable.isDragging}
81
+ className="group @container relative flex max-h-48 max-w-full cursor-pointer flex-col items-center gap-2 overflow-hidden bg-transparent p-2 hover:bg-muted/70 active:bg-muted data-[selected=true]:bg-primary/30 data-[dragging=true]:opacity-50"
82
+ onClick={() => {
83
+ const conditions = selectionConditions?.(node);
84
+
85
+ if (conditions !== undefined) {
86
+ const canSelect = Array.isArray(conditions)
87
+ ? conditions.some((condition) => condition)
88
+ : conditions;
89
+
90
+ if (!canSelect) return;
91
+ }
92
+
93
+ const isSelected = selection?.rowSelection?.[id];
94
+
95
+ if (!isSelected) {
96
+ selection?.setRowSelection?.((old) => ({ ...old, [id]: true }));
97
+ } else {
98
+ selection?.setRowSelection?.((old) => {
99
+ const newSelection = { ...old };
100
+ delete newSelection[id];
101
+ return newSelection;
102
+ });
103
+ }
104
+ }}
105
+ onDoubleClick={() => router.push(`?parentId=${id}`)}
106
+ >
107
+ <div
108
+ ref={droppable.ref}
109
+ data-over={droppable.isDropTarget && !draggable.isDragging}
110
+ className="absolute inset-0 -z-10 data-[over=true]:bg-primary/35"
111
+ />
112
+
113
+ <CardHeader className="flex w-full flex-row items-center justify-between gap-2.5 p-0 px-1.5">
114
+ <FolderIcon className="my-1.5 size-4 fill-foreground" />
115
+
116
+ <CardTitle
117
+ className="line-clamp-2 w-full break-all text-xs leading-[1.2]"
118
+ title={node.name}
119
+ >
120
+ {node.name}
121
+ </CardTitle>
122
+
123
+ {commands && <DropdownCommandMenu data={node} commands={commands} />}
124
+ </CardHeader>
125
+
126
+ <CardContent className="h-48 w-full overflow-hidden rounded-lg bg-background p-0 group-hover:bg-background">
127
+ <div className="flex h-full w-full items-center justify-center">
128
+ <FolderIcon className="size-12 fill-foreground" />
129
+ </div>
130
+ </CardContent>
131
+ </Card>
132
+ </ContextCommandMenuTrigger>
133
+
134
+ {commands && <ContextCommandMenuContent data={node} commands={commands} />}
135
+ </ContextCommandMenu>
136
+ );
137
+ }
138
+
139
+ /**
140
+ * File card
141
+ */
142
+ export function DriveGridFileCard<TData extends DriveNode>({
143
+ node,
144
+ commands,
145
+ className,
146
+ ...props
147
+ }: DriveGridCardProps<TData>) {
148
+ const { id } = node;
149
+
150
+ const { ref, isDragging } = useDraggable({ id });
151
+ const { selection, selectionConditions } = useDriveSelectionContext();
152
+
153
+ const subtype = findStatus(nodeSubtypeConfig, node.subtype);
154
+ const Icon = subtype?.icon;
155
+ const fileUrl = node.links.view ?? node.links.download;
156
+ const imageUrl = node.links.thumbnail ?? node.links.preview;
157
+ if (node.hidden) return null;
158
+
159
+ return (
160
+ <ContextCommandMenu>
161
+ <ContextCommandMenuTrigger asChild>
162
+ <Card
163
+ {...props}
164
+ key={id}
165
+ ref={ref}
166
+ data-selected={selection?.rowSelection?.[id]}
167
+ data-dragging={isDragging}
168
+ className="@container flex max-h-48 max-w-full cursor-pointer flex-col items-center gap-2 overflow-hidden p-2 hover:bg-muted/70 active:bg-muted data-[selected=true]:bg-primary/30 data-[dragging=true]:opacity-50"
169
+ onClick={() => {
170
+ const conditions = selectionConditions?.(node);
171
+
172
+ if (conditions !== undefined) {
173
+ const canSelect = Array.isArray(conditions)
174
+ ? conditions.some((condition) => condition)
175
+ : conditions;
176
+
177
+ if (!canSelect) return;
178
+ }
179
+
180
+ const isSelected = selection?.rowSelection?.[id];
181
+
182
+ if (!isSelected) {
183
+ selection?.setRowSelection?.((old) => ({ ...old, [id]: true }));
184
+ } else {
185
+ selection?.setRowSelection?.((old) => {
186
+ const newSelection = { ...old };
187
+ delete newSelection[id];
188
+ return newSelection;
189
+ });
190
+ }
191
+ }}
192
+ onDoubleClick={() => window.open(fileUrl ?? "", "_blank", "noopener,noreferrer")}
193
+ >
194
+ <CardHeader className="flex w-full flex-row items-center justify-between gap-2 p-0 px-1.5">
195
+ {Icon && (
196
+ <Icon
197
+ className={nodeSubtypeVariants({
198
+ status: node.subtype,
199
+ className: "my-1.5 size-4",
200
+ })}
201
+ />
202
+ )}
203
+
204
+ <CardTitle
205
+ className="line-clamp-2 w-full break-all text-xs leading-[1.2]"
206
+ title={node.name}
207
+ >
208
+ {node.name}
209
+ </CardTitle>
210
+
211
+ {commands && <DropdownCommandMenu data={node} commands={commands} />}
212
+ </CardHeader>
213
+
214
+ <CardContent className="h-48 w-full overflow-hidden rounded-lg bg-background p-0">
215
+ {node.subtype === "image" ? (
216
+ <Image
217
+ src={imageUrl ?? fileUrl ?? ""}
218
+ alt={node.name}
219
+ width={200}
220
+ height={200}
221
+ className="h-full w-full object-cover"
222
+ />
223
+ ) : (
224
+ <div className="flex h-full w-full items-center justify-center">
225
+ {Icon && (
226
+ <Icon
227
+ className={nodeSubtypeVariants({ status: node.subtype, className: "size-12" })}
228
+ />
229
+ )}
230
+ </div>
231
+ )}
232
+ </CardContent>
233
+ </Card>
234
+ </ContextCommandMenuTrigger>
235
+
236
+ {commands && <ContextCommandMenuContent data={node} commands={commands} />}
237
+ </ContextCommandMenu>
238
+ );
239
+ }
240
+
241
+ /**
242
+ * Card Skeleton
243
+ */
244
+
245
+ export function DriveGridCardSkeleton() {
246
+ return (
247
+ <Card className="flex max-h-52 cursor-pointer flex-col items-center gap-3 overflow-hidden bg-muted/70 p-3">
248
+ <Skeleton className="h-7" />
249
+ <CardContent className="h-52 w-full overflow-hidden rounded-lg bg-background p-0" />
250
+ </Card>
251
+ );
252
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import { EmptyDescription, EmptyPage, EmptyPageTitle } from "@tulip-systems/core/components";
4
+ import { cn } from "@tulip-systems/core/lib";
5
+ import { useInView } from "motion/react";
6
+ import { type ComponentProps, type ReactNode, useEffect, useRef } from "react";
7
+ import { DriveGridCardSkeleton } from "./grid-card.client";
8
+
9
+ /**
10
+ * Layout primitive for drive grid content.
11
+ */
12
+ export type DriveGridProps = ComponentProps<"div">;
13
+
14
+ export function DriveGrid({ className, ...props }: DriveGridProps) {
15
+ return (
16
+ <div
17
+ {...props}
18
+ className={cn(
19
+ "grid grid-cols-1 gap-5 md:grid-cols-[repeat(auto-fill,minmax(13rem,1fr))]",
20
+ className,
21
+ )}
22
+ />
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Empty state that spans the full grid.
28
+ */
29
+ export type DriveGridEmptyProps = ComponentProps<"div"> & {
30
+ title?: ReactNode;
31
+ description?: ReactNode;
32
+ };
33
+
34
+ export function DriveGridEmpty({
35
+ title,
36
+ description,
37
+ className,
38
+ children,
39
+ ...props
40
+ }: DriveGridEmptyProps) {
41
+ return (
42
+ <EmptyPage {...props} className={cn("col-span-full min-h-[80dvh]", className)}>
43
+ <EmptyPageTitle>{title}</EmptyPageTitle>
44
+ {description && <EmptyDescription>{description}</EmptyDescription>}
45
+ </EmptyPage>
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Skeleton grid for page-level loading states.
51
+ */
52
+ export type DriveGridLoadingProps = ComponentProps<"div">;
53
+
54
+ export function DriveGridLoading({ className, ...props }: DriveGridLoadingProps) {
55
+ return (
56
+ <DriveGrid {...props}>
57
+ {Array.from({ length: 12 }).map((_, index) => (
58
+ <DriveGridCardSkeleton key={index} />
59
+ ))}
60
+ </DriveGrid>
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Infinite table bottombar
66
+ */
67
+ type DriveGridBottombarProps = ComponentProps<"div"> & {
68
+ hasNextPage: boolean;
69
+ fetchNextPage: () => void;
70
+ isFetching: boolean;
71
+ isFetchingNextPage: boolean;
72
+ };
73
+
74
+ export function DriveGridBottombar({
75
+ hasNextPage,
76
+ fetchNextPage,
77
+ isFetching,
78
+ isFetchingNextPage,
79
+ className,
80
+ ...props
81
+ }: DriveGridBottombarProps) {
82
+ const scrollRef = useRef(null);
83
+ const isInView = useInView(scrollRef);
84
+
85
+ useEffect(() => {
86
+ if (isInView && hasNextPage && !isFetching && !isFetchingNextPage) {
87
+ fetchNextPage();
88
+ }
89
+ }, [isInView, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage]);
90
+
91
+ return (
92
+ <div {...props} className={cn("relative col-span-full", className)}>
93
+ <div ref={scrollRef} className="absolute bottom-0 -z-50 min-h-[500px] bg-primary" />
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,125 @@
1
+ "use client";
2
+
3
+ import { pointerIntersection } from "@dnd-kit/collision";
4
+ import { useDroppable } from "@dnd-kit/react";
5
+ import { BreadcrumbItem, BreadcrumbLink, BreadcrumbPage } from "@tulip-systems/core/components";
6
+ import {
7
+ HeaderBreadcrumbSeparator,
8
+ HeaderBreadcrumbs,
9
+ HeaderBreadcrumbsDesktopList,
10
+ HeaderBreadcrumbsDropdownMenu,
11
+ HeaderBreadcrumbsDropdownMenuItem,
12
+ HeaderBreadcrumbsLink,
13
+ HeaderBreadcrumbsMobileList,
14
+ } from "@tulip-systems/core/components/client";
15
+ import Link from "next/link";
16
+ import { createSerializer, useQueryStates } from "nuqs";
17
+ import { Fragment } from "react";
18
+ import { driveTreeSearchParams } from "@/entry";
19
+
20
+ /**
21
+ * Drive Header Breadcrumbs
22
+ */
23
+ type DriveNodeBreadcrumb = {
24
+ node: true;
25
+ label: string;
26
+ parentId: string | null;
27
+ };
28
+
29
+ type DriveHrefBreadcrumb = {
30
+ node?: false;
31
+ label: string;
32
+ href: string;
33
+ };
34
+
35
+ export type DriveBreadcrumb = DriveNodeBreadcrumb | DriveHrefBreadcrumb;
36
+
37
+ export type DriveBreadCrumbsProps = {
38
+ breadcrumbs?: DriveBreadcrumb[];
39
+ };
40
+
41
+ export function DriveBreadcrumbs({ breadcrumbs = [] }: DriveBreadCrumbsProps) {
42
+ return (
43
+ <HeaderBreadcrumbs>
44
+ <HeaderBreadcrumbsMobileList>
45
+ {breadcrumbs.length > 1 && (
46
+ <>
47
+ <HeaderBreadcrumbsDropdownMenu>
48
+ {breadcrumbs.slice(0, -1).map((breadcrumb, index) => (
49
+ <HeaderBreadcrumbsDropdownMenuItem key={index} breadcrumb={breadcrumb} />
50
+ ))}
51
+ </HeaderBreadcrumbsDropdownMenu>
52
+
53
+ <HeaderBreadcrumbSeparator />
54
+ </>
55
+ )}
56
+
57
+ {breadcrumbs.slice(-1).map((breadcrumb, index, array) => (
58
+ <Fragment key={index}>
59
+ <HeaderBreadcrumbsLink breadcrumb={breadcrumb} />
60
+ {index < array.length - 1 && <HeaderBreadcrumbSeparator />}
61
+ </Fragment>
62
+ ))}
63
+ </HeaderBreadcrumbsMobileList>
64
+
65
+ <HeaderBreadcrumbsDesktopList>
66
+ {breadcrumbs.length > 2 && (
67
+ <>
68
+ <HeaderBreadcrumbsDropdownMenu>
69
+ {breadcrumbs.slice(0, -2).map((breadcrumb, index) => (
70
+ <HeaderBreadcrumbsDropdownMenuItem key={index} breadcrumb={breadcrumb} />
71
+ ))}
72
+ </HeaderBreadcrumbsDropdownMenu>
73
+
74
+ <HeaderBreadcrumbSeparator />
75
+ </>
76
+ )}
77
+
78
+ {breadcrumbs.slice(-2).map((breadcrumb, index, array) => (
79
+ <Fragment key={index}>
80
+ {breadcrumb.node ? (
81
+ <DriveHeaderBreadcrumbsLink breadcrumb={breadcrumb} />
82
+ ) : (
83
+ <HeaderBreadcrumbsLink breadcrumb={breadcrumb} />
84
+ )}
85
+ {index < array.length - 1 && <HeaderBreadcrumbSeparator />}
86
+ </Fragment>
87
+ ))}
88
+ </HeaderBreadcrumbsDesktopList>
89
+ </HeaderBreadcrumbs>
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Drive Header Breadcrumbs Link with DnD support
95
+ */
96
+ function DriveHeaderBreadcrumbsLink(props: { breadcrumb: DriveNodeBreadcrumb }) {
97
+ const id = props.breadcrumb.parentId ?? null;
98
+ const [query] = useQueryStates(driveTreeSearchParams);
99
+ const serialize = createSerializer(driveTreeSearchParams);
100
+
101
+ const { ref, isDropTarget } = useDroppable({
102
+ id: id ?? "drive",
103
+ collisionDetector: pointerIntersection,
104
+ });
105
+
106
+ const href = serialize({ ...query, parentId: id });
107
+
108
+ return (
109
+ <BreadcrumbItem
110
+ ref={ref}
111
+ data-over={isDropTarget && query.parentId !== id}
112
+ className="data-[over=true]:bg-primary/35"
113
+ >
114
+ {href !== undefined && href !== null ? (
115
+ <BreadcrumbLink asChild>
116
+ <Link href={href || "?"} className="truncate">
117
+ {props.breadcrumb.label}
118
+ </Link>
119
+ </BreadcrumbLink>
120
+ ) : (
121
+ <BreadcrumbPage className="truncate">{props.breadcrumb.label}</BreadcrumbPage>
122
+ )}
123
+ </BreadcrumbItem>
124
+ );
125
+ }