@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.
- package/LICENSE +662 -0
- package/package.json +113 -0
- package/src/components/content.tsx +13 -0
- package/src/components/context.client.tsx +12 -0
- package/src/components/dnd.client.tsx +47 -0
- package/src/components/grid-card.client.tsx +252 -0
- package/src/components/grid.client.tsx +96 -0
- package/src/components/navigation/breadcrumbs.client.tsx +125 -0
- package/src/components/navigation/header.client.tsx +45 -0
- package/src/components/navigation/toolbar.client.tsx +35 -0
- package/src/components/navigation/view-switcher.client.tsx +32 -0
- package/src/components/selection.client.tsx +48 -0
- package/src/components/view.client.tsx +67 -0
- package/src/config/filters.ts +14 -0
- package/src/config/types.tsx +90 -0
- package/src/entry.client.ts +7 -0
- package/src/entry.server.ts +4 -0
- package/src/entry.ts +10 -0
- package/src/lib/constants.ts +19 -0
- package/src/lib/contracts.ts +121 -0
- package/src/lib/dto.ts +83 -0
- package/src/lib/helpers.server.ts +14 -0
- package/src/lib/helpers.ts +32 -0
- package/src/lib/search-params.ts +5 -0
- package/src/lib/validators.ts +89 -0
- package/src/providers/google/components/command-file-update.tsx +100 -0
- package/src/providers/google/components/command-folder-create.tsx +104 -0
- package/src/providers/google/components/command-folder-update.tsx +100 -0
- package/src/providers/google/components/content.client.tsx +6 -0
- package/src/providers/google/components/navigation.client.tsx +21 -0
- package/src/providers/google/components/provider.client.tsx +60 -0
- package/src/providers/google/components/view.client.tsx +158 -0
- package/src/providers/google/config/columns-data.tsx +81 -0
- package/src/providers/google/config/filters.ts +3 -0
- package/src/providers/google/entry.client.ts +10 -0
- package/src/providers/google/entry.server.ts +5 -0
- package/src/providers/google/entry.ts +12 -0
- package/src/providers/google/lib/constants.ts +10 -0
- package/src/providers/google/lib/dto.ts +104 -0
- package/src/providers/google/lib/helpers.ts +37 -0
- package/src/providers/google/lib/router.server.ts +62 -0
- package/src/providers/google/lib/schema.ts +9 -0
- package/src/providers/google/lib/search-params.ts +7 -0
- package/src/providers/google/lib/service.server.ts +792 -0
- package/src/providers/google/lib/validators.ts +148 -0
- package/src/providers/local/components/command-file-update.tsx +93 -0
- package/src/providers/local/components/command-file-upload.tsx +29 -0
- package/src/providers/local/components/command-folder-create.tsx +100 -0
- package/src/providers/local/components/command-folder-update.tsx +93 -0
- package/src/providers/local/components/content.client.tsx +3 -0
- package/src/providers/local/components/navigation.client.tsx +23 -0
- package/src/providers/local/components/provider.client.tsx +90 -0
- package/src/providers/local/components/upload-zone-context.client.tsx +43 -0
- package/src/providers/local/components/upload-zone.client.tsx +182 -0
- package/src/providers/local/components/view.client.tsx +145 -0
- package/src/providers/local/config/columns-data.tsx +81 -0
- package/src/providers/local/config/filters.ts +14 -0
- package/src/providers/local/entry.client.ts +18 -0
- package/src/providers/local/entry.server.ts +7 -0
- package/src/providers/local/entry.ts +14 -0
- package/src/providers/local/lib/constants.ts +23 -0
- package/src/providers/local/lib/helpers.ts +105 -0
- package/src/providers/local/lib/route-handler.server.ts +153 -0
- package/src/providers/local/lib/router.server.ts +137 -0
- package/src/providers/local/lib/schema.ts +104 -0
- package/src/providers/local/lib/search-params.ts +4 -0
- package/src/providers/local/lib/service.server.ts +1116 -0
- package/src/providers/local/lib/upload.client.ts +177 -0
- package/src/providers/local/lib/validators.ts +154 -0
- 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,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
|
+
}
|