create-tulip-app 0.6.1 → 0.8.2

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 (90) hide show
  1. package/dist/index.d.mts +1 -0
  2. package/dist/index.mjs +3095 -0
  3. package/dist/index.mjs.map +1 -0
  4. package/dist/templates/basic/README.md +1 -0
  5. package/dist/templates/basic/drizzle.config.ts +11 -0
  6. package/dist/templates/basic/next.config.ts +15 -0
  7. package/dist/templates/basic/package.json +80 -0
  8. package/dist/templates/basic/postcss.config.js +6 -0
  9. package/dist/templates/basic/public/fonts/Inter/Inter-Black.ttf +0 -0
  10. package/dist/templates/basic/public/fonts/Inter/Inter-Bold.ttf +0 -0
  11. package/dist/templates/basic/public/fonts/Inter/Inter-ExtraBold.ttf +0 -0
  12. package/dist/templates/basic/public/fonts/Inter/Inter-ExtraLight.ttf +0 -0
  13. package/dist/templates/basic/public/fonts/Inter/Inter-Italic.ttf +0 -0
  14. package/dist/templates/basic/public/fonts/Inter/Inter-Light.ttf +0 -0
  15. package/dist/templates/basic/public/fonts/Inter/Inter-Medium.ttf +0 -0
  16. package/dist/templates/basic/public/fonts/Inter/Inter-Regular.ttf +0 -0
  17. package/dist/templates/basic/public/fonts/Inter/Inter-SemiBold.ttf +0 -0
  18. package/dist/templates/basic/public/fonts/Inter/Inter-Thin.ttf +0 -0
  19. package/dist/templates/basic/public/icons/icon-x192.png +0 -0
  20. package/dist/templates/basic/public/icons/icon-x512.png +0 -0
  21. package/dist/templates/basic/public/images/auth-background.jpeg +0 -0
  22. package/dist/templates/basic/public/images/placeholder.jpeg +0 -0
  23. package/dist/templates/basic/src/app/admin/(dashboard)/_components/budget-bar-chart.client.tsx +135 -0
  24. package/dist/templates/basic/src/app/admin/(dashboard)/_components/date-year-picker.tsx +49 -0
  25. package/dist/templates/basic/src/app/admin/(dashboard)/layout.tsx +16 -0
  26. package/dist/templates/basic/src/app/admin/(dashboard)/loading.tsx +15 -0
  27. package/dist/templates/basic/src/app/admin/(dashboard)/page.tsx +12 -0
  28. package/dist/templates/basic/src/app/admin/drive/(root)/layout.tsx +16 -0
  29. package/dist/templates/basic/src/app/admin/drive/(root)/loading.tsx +5 -0
  30. package/dist/templates/basic/src/app/admin/drive/(root)/page.tsx +3 -0
  31. package/dist/templates/basic/src/app/admin/drive/[namespace]/layout.tsx +21 -0
  32. package/dist/templates/basic/src/app/admin/drive/[namespace]/loading.tsx +5 -0
  33. package/dist/templates/basic/src/app/admin/drive/[namespace]/page.tsx +5 -0
  34. package/dist/templates/basic/src/app/admin/drive/_components/command-create.tsx +104 -0
  35. package/dist/templates/basic/src/app/admin/drive/_components/command-upload.tsx +31 -0
  36. package/dist/templates/basic/src/app/admin/drive/_components/drive-context.client.tsx +175 -0
  37. package/dist/templates/basic/src/app/admin/drive/_components/drive-header.client.tsx +219 -0
  38. package/dist/templates/basic/src/app/admin/drive/_components/drive-sidebar.tsx +61 -0
  39. package/dist/templates/basic/src/app/admin/drive/_components/drive-view.client.tsx +49 -0
  40. package/dist/templates/basic/src/app/admin/drive/_components/grid.client.tsx +372 -0
  41. package/dist/templates/basic/src/app/admin/drive/_components/list.client.tsx +83 -0
  42. package/dist/templates/basic/src/app/admin/drive/_components/toolbars.tsx +74 -0
  43. package/dist/templates/basic/src/app/admin/drive/_config/columns-data.tsx +98 -0
  44. package/dist/templates/basic/src/app/admin/drive/_config/commands.tsx +197 -0
  45. package/dist/templates/basic/src/app/admin/drive/_config/types.tsx +90 -0
  46. package/dist/templates/basic/src/app/admin/drive/_lib/router.ts +72 -0
  47. package/dist/templates/basic/src/app/admin/drive/_lib/search-params.ts +21 -0
  48. package/dist/templates/basic/src/app/admin/drive/loading.tsx +10 -0
  49. package/dist/templates/basic/src/app/admin/error.tsx +5 -0
  50. package/dist/templates/basic/src/app/admin/layout.tsx +21 -0
  51. package/dist/templates/basic/src/app/admin/not-found.tsx +3 -0
  52. package/dist/templates/basic/src/app/api/auth/[...all]/route.ts +4 -0
  53. package/dist/templates/basic/src/app/api/rpc/[[...rest]]/route.ts +5 -0
  54. package/dist/templates/basic/src/app/api/storage/files/route.ts +4 -0
  55. package/dist/templates/basic/src/app/apple-icon.png +0 -0
  56. package/dist/templates/basic/src/app/auth/forget-password/loading.tsx +3 -0
  57. package/dist/templates/basic/src/app/auth/forget-password/page.tsx +5 -0
  58. package/dist/templates/basic/src/app/auth/layout.tsx +13 -0
  59. package/dist/templates/basic/src/app/auth/login/loading.tsx +3 -0
  60. package/dist/templates/basic/src/app/auth/login/page.tsx +5 -0
  61. package/dist/templates/basic/src/app/auth/reset-password/loading.tsx +3 -0
  62. package/dist/templates/basic/src/app/auth/reset-password/page.tsx +5 -0
  63. package/dist/templates/basic/src/app/favicon.ico +0 -0
  64. package/dist/templates/basic/src/app/globals.css +3 -0
  65. package/dist/templates/basic/src/app/layout.tsx +17 -0
  66. package/dist/templates/basic/src/app/loading.tsx +3 -0
  67. package/dist/templates/basic/src/app/manifest.ts +4 -0
  68. package/dist/templates/basic/src/app/not-found.tsx +3 -0
  69. package/dist/templates/basic/src/instrumentation.ts +5 -0
  70. package/dist/templates/basic/src/lib/config/base.ts +11 -0
  71. package/dist/templates/basic/src/lib/config/paths.tsx +33 -0
  72. package/dist/templates/basic/src/proxy.ts +8 -0
  73. package/dist/templates/basic/src/server/auth/client.ts +6 -0
  74. package/dist/templates/basic/src/server/auth/init.ts +7 -0
  75. package/dist/templates/basic/src/server/auth/permissions.ts +46 -0
  76. package/dist/templates/basic/src/server/context.ts +9 -0
  77. package/dist/templates/basic/src/server/db/init.ts +16 -0
  78. package/dist/templates/basic/src/server/db/schema.ts +22 -0
  79. package/dist/templates/basic/src/server/db/types.ts +3 -0
  80. package/dist/templates/basic/src/server/providers/email.ts +3 -0
  81. package/dist/templates/basic/src/server/router/caller.ts +10 -0
  82. package/dist/templates/basic/src/server/router/client.ts +9 -0
  83. package/dist/templates/basic/src/server/router/init.ts +7 -0
  84. package/dist/templates/basic/src/server/router/register.ts +4 -0
  85. package/dist/templates/basic/src/server/router/router.ts +11 -0
  86. package/dist/templates/basic/src/server/storage/client.ts +14 -0
  87. package/dist/templates/basic/src/server/storage/config.ts +9 -0
  88. package/dist/templates/basic/src/server/storage/init.ts +15 -0
  89. package/dist/templates/basic/tsconfig.json +12 -0
  90. package/package.json +2 -1
@@ -0,0 +1,219 @@
1
+ "use client";
2
+
3
+ import { pointerIntersection } from "@dnd-kit/collision";
4
+ import { useDroppable } from "@dnd-kit/react";
5
+ import { useSuspenseQuery } from "@tanstack/react-query";
6
+ import { InlineCommandMenu } from "@tulip-systems/core/commands/client";
7
+ import { BreadcrumbItem, BreadcrumbLink, BreadcrumbPage } from "@tulip-systems/core/components";
8
+ import {
9
+ Header,
10
+ type HeaderBreadcrumb,
11
+ HeaderBreadcrumbSeparator,
12
+ HeaderBreadcrumbs,
13
+ HeaderBreadcrumbsDesktopList,
14
+ HeaderBreadcrumbsDropdownMenu,
15
+ HeaderBreadcrumbsDropdownMenuItem,
16
+ HeaderBreadcrumbsLink,
17
+ HeaderBreadcrumbsMobileList,
18
+ HeaderTopbarBackButton,
19
+ HeaderTopbarMobileNavSwitcher,
20
+ ToggleGroup,
21
+ ToggleGroupItem,
22
+ Topbar,
23
+ TopbarTools,
24
+ } from "@tulip-systems/core/components/client";
25
+ import { cn } from "@tulip-systems/core/lib";
26
+ import { LayoutGridIcon, StretchVerticalIcon } from "lucide-react";
27
+ import Link from "next/link";
28
+ import { createSerializer, useQueryStates } from "nuqs";
29
+ import { type ComponentProps, Fragment } from "react";
30
+ import { orpc } from "@/server/router/client";
31
+ import { driveGlobalCommands } from "../_config/commands.js";
32
+ import { driveSearchParams } from "../_lib/search-params.js";
33
+ import { useDriveContext } from "./drive-context.client.js";
34
+
35
+ /**
36
+ * Drive Header
37
+ */
38
+ export function DriveHeader({
39
+ namespace,
40
+ breadcrumbs,
41
+ ...props
42
+ }: ComponentProps<"div"> & { namespace: string; breadcrumbs?: DriveHeaderBreadCrumb[] }) {
43
+ return (
44
+ <Header {...props}>
45
+ <Topbar>
46
+ <div className="flex h-full items-center gap-4">
47
+ <div className="h-full">
48
+ <HeaderTopbarBackButton />
49
+ <HeaderTopbarMobileNavSwitcher />
50
+ </div>
51
+
52
+ <DriveHeaderBreadCrumbs namespace={namespace} breadcrumbs={breadcrumbs} />
53
+ </div>
54
+
55
+ <DriveViewSwitcher />
56
+
57
+ <TopbarTools>
58
+ <InlineCommandMenu data={{ namespace }} commands={driveGlobalCommands} />
59
+ </TopbarTools>
60
+ </Topbar>
61
+ </Header>
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Drive Page Topbar
67
+ */
68
+ export function DrivePageTopbar({ className, ...props }: ComponentProps<"div">) {
69
+ const { namespace } = useDriveContext();
70
+
71
+ return (
72
+ <div
73
+ {...props}
74
+ className={cn("flex items-center justify-between overflow-x-auto pt-3", className)}
75
+ >
76
+ <DriveHeaderBreadCrumbs namespace={namespace} />
77
+
78
+ <DriveViewSwitcher />
79
+
80
+ <InlineCommandMenu data={{ namespace }} commands={driveGlobalCommands} />
81
+ </div>
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Drive view switcher
87
+ */
88
+ export function DriveViewSwitcher() {
89
+ const [query, setQuery] = useQueryStates(driveSearchParams);
90
+
91
+ return (
92
+ <ToggleGroup
93
+ type="single"
94
+ variant="outline"
95
+ value={query.view}
96
+ onValueChange={(value) => setQuery({ view: value ? (value as "list" | "grid") : null })}
97
+ >
98
+ <ToggleGroupItem value="grid" aria-label="Toggle grid view">
99
+ <LayoutGridIcon />
100
+ </ToggleGroupItem>
101
+
102
+ {/* <Separator orientation="vertical" className="mx-1" /> */}
103
+
104
+ <ToggleGroupItem value="list" aria-label="Toggle list view">
105
+ <StretchVerticalIcon />
106
+ </ToggleGroupItem>
107
+ </ToggleGroup>
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Drive Header Breadcrumbs
113
+ */
114
+ type DriveHeaderBreadCrumb = HeaderBreadcrumb & { droppable?: boolean };
115
+
116
+ function DriveHeaderBreadCrumbs(props: {
117
+ namespace: string;
118
+ breadcrumbs?: DriveHeaderBreadCrumb[];
119
+ }) {
120
+ const { namespace } = props;
121
+ const [query] = useQueryStates(driveSearchParams);
122
+ const serialize = createSerializer(driveSearchParams);
123
+
124
+ const parents = useSuspenseQuery(
125
+ orpc.drive.getFolderParents.queryOptions({ input: { id: query.parentId, namespace } }),
126
+ );
127
+
128
+ const breadcrumbs = [
129
+ ...(props.breadcrumbs ?? []),
130
+ {
131
+ label: "Drive",
132
+ href: serialize({ ...query, parentId: null }),
133
+ droppable: true,
134
+ },
135
+ ...(parents.data?.flatMap(({ name, id }) => ({
136
+ label: name,
137
+ href: serialize({ ...query, parentId: id }),
138
+ droppable: true,
139
+ })) ?? []),
140
+ ] satisfies DriveHeaderBreadCrumb[];
141
+
142
+ return (
143
+ <HeaderBreadcrumbs>
144
+ <HeaderBreadcrumbsMobileList>
145
+ {breadcrumbs.length > 1 && (
146
+ <>
147
+ <HeaderBreadcrumbsDropdownMenu>
148
+ {breadcrumbs.slice(0, -1).map((breadcrumb, index) => (
149
+ <HeaderBreadcrumbsDropdownMenuItem key={index} breadcrumb={breadcrumb} />
150
+ ))}
151
+ </HeaderBreadcrumbsDropdownMenu>
152
+
153
+ <HeaderBreadcrumbSeparator />
154
+ </>
155
+ )}
156
+
157
+ {breadcrumbs.slice(-1).map((breadcrumb, index, array) => (
158
+ <Fragment key={index}>
159
+ <HeaderBreadcrumbsLink breadcrumb={breadcrumb} />
160
+ {index < array.length - 1 && <HeaderBreadcrumbSeparator />}
161
+ </Fragment>
162
+ ))}
163
+ </HeaderBreadcrumbsMobileList>
164
+
165
+ <HeaderBreadcrumbsDesktopList>
166
+ {breadcrumbs.length > 2 && (
167
+ <>
168
+ <HeaderBreadcrumbsDropdownMenu>
169
+ {breadcrumbs.slice(0, -2).map((breadcrumb, index) => (
170
+ <HeaderBreadcrumbsDropdownMenuItem key={index} breadcrumb={breadcrumb} />
171
+ ))}
172
+ </HeaderBreadcrumbsDropdownMenu>
173
+
174
+ <HeaderBreadcrumbSeparator />
175
+ </>
176
+ )}
177
+
178
+ {breadcrumbs.slice(-2).map((breadcrumb, index, array) => (
179
+ <Fragment key={index}>
180
+ {breadcrumb.droppable ? (
181
+ <DriveHeaderBreadcrumbsLink breadcrumb={breadcrumb} />
182
+ ) : (
183
+ <HeaderBreadcrumbsLink breadcrumb={breadcrumb} />
184
+ )}
185
+ {index < array.length - 1 && <HeaderBreadcrumbSeparator />}
186
+ </Fragment>
187
+ ))}
188
+ </HeaderBreadcrumbsDesktopList>
189
+ </HeaderBreadcrumbs>
190
+ );
191
+ }
192
+
193
+ function DriveHeaderBreadcrumbsLink(props: { breadcrumb: DriveHeaderBreadCrumb }) {
194
+ const id = props.breadcrumb.href?.split("=")[1] ?? null;
195
+ const [{ parentId }] = useQueryStates(driveSearchParams);
196
+
197
+ const { ref, isDropTarget } = useDroppable({
198
+ id: id ?? "drive",
199
+ collisionDetector: pointerIntersection,
200
+ });
201
+
202
+ return (
203
+ <BreadcrumbItem
204
+ ref={ref}
205
+ data-over={isDropTarget && parentId !== id}
206
+ className="data-[over=true]:bg-primary/35"
207
+ >
208
+ {props.breadcrumb.href ? (
209
+ <BreadcrumbLink asChild>
210
+ <Link href={props.breadcrumb.href} className="truncate">
211
+ {props.breadcrumb.label}
212
+ </Link>
213
+ </BreadcrumbLink>
214
+ ) : (
215
+ <BreadcrumbPage className="truncate">{props.breadcrumb.label}</BreadcrumbPage>
216
+ )}
217
+ </BreadcrumbItem>
218
+ );
219
+ }
@@ -0,0 +1,61 @@
1
+ // "use client";
2
+
3
+ // import { asyncDataLoaderFeature, hotkeysCoreFeature } from "@headless-tree/core";
4
+ // import { useTree } from "@headless-tree/react";
5
+
6
+ // import { AppRouter } from "@/trpc/router";
7
+ // import {
8
+ // Sidebar,
9
+ // SidebarContent,
10
+ // Tree,
11
+ // TreeItem,
12
+ // TreeItemLabel,
13
+ // } from "@tulip-systems/core/components/client";
14
+ // import { useTRPCClient } from "@tulip-systems/core/config/client";
15
+ // import { isFolder, Node } from "@tulip-systems/core/storage";
16
+
17
+ // const rootItemId = "--root--";
18
+ // const indent = 20;
19
+
20
+ // export default function DriveSidebar() {
21
+ // const trpcClient = useTRPCClient<AppRouter>();
22
+
23
+ // const tree = useTree<Node>({
24
+ // indent,
25
+ // rootItemId,
26
+ // getItemName: (item) => item.getItemData()?.name,
27
+ // isItemFolder: (node) => isFolder(node.getItemData() ?? {}),
28
+ // dataLoader: {
29
+ // getItem: async (id) => {
30
+ // const node = await trpcClient.drive.getNodeById.query({ id });
31
+ // if (!node) throw new Error("Error in getItem");
32
+
33
+ // return { ...node, children: node?.children.flatMap(({ id }) => id) };
34
+ // },
35
+ // getChildren: async (itemId) => {
36
+ // const children = await trpcClient.drive.getNodesByParentId.query({
37
+ // parentId: itemId === rootItemId ? null : itemId,
38
+ // });
39
+
40
+ // return children.filter(isFolder).flatMap(({ id }) => id);
41
+ // },
42
+ // },
43
+ // features: [asyncDataLoaderFeature, hotkeysCoreFeature],
44
+ // });
45
+
46
+ // return (
47
+ // <Sidebar className="ml-[var(--sidebar-width)] mt-[var(--header-top-bar-height)]">
48
+ // <SidebarContent className="px-2 py-4">
49
+ // <Tree indent={indent} tree={tree}>
50
+ // {tree.getItems().map((item) => {
51
+ // return (
52
+ // <TreeItem key={item.getId()} item={item}>
53
+ // <TreeItemLabel className="bg-transparent" />
54
+ // </TreeItem>
55
+ // );
56
+ // })}
57
+ // </Tree>
58
+ // </SidebarContent>
59
+ // </Sidebar>
60
+ // );
61
+ // }
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import { TableLayout, TableSkeleton } from "@tulip-systems/core/data-tables";
4
+ import { cn } from "@tulip-systems/core/lib";
5
+ import { useQueryStates } from "nuqs";
6
+ import { type ComponentProps, Suspense } from "react";
7
+ import { driveSearchParams } from "../_lib/search-params.js";
8
+ import { DriveGrid, DriveGridLoading } from "./grid.client.js";
9
+ import { DriveList } from "./list.client.js";
10
+ import { DriveToolbar } from "./toolbars.js";
11
+
12
+ /**
13
+ * DriveGridView
14
+ */
15
+ export function DriveView({
16
+ className,
17
+ ...props
18
+ }: ComponentProps<"div"> & Pick<ComponentProps<typeof DriveToolbar>, "filtersVisibility">) {
19
+ const [{ view }] = useQueryStates(driveSearchParams);
20
+
21
+ return (
22
+ <div {...props} className={cn("flex h-full flex-1 flex-col gap-y-12", className)}>
23
+ {view === "grid" && (
24
+ <Suspense fallback={<DriveGridLoading />}>
25
+ <TableLayout>
26
+ <DriveToolbar />
27
+ <DriveGrid />
28
+ </TableLayout>
29
+ </Suspense>
30
+ )}
31
+ {view === "list" && (
32
+ <Suspense fallback={<TableSkeleton />}>
33
+ <DriveList />
34
+ </Suspense>
35
+ )}
36
+ </div>
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Drive Content
42
+ */
43
+ export function DriveContent({ className, ...props }: ComponentProps<"div">) {
44
+ return (
45
+ <div {...props} className={cn("space-y-6", className)}>
46
+ {props.children}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,372 @@
1
+ "use client";
2
+
3
+ import { useDraggable, useDroppable } from "@dnd-kit/react";
4
+ import { useSuspenseInfiniteQuery } from "@tanstack/react-query";
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
+ EmptyPage,
17
+ EmptyPageTitle,
18
+ findStatus,
19
+ Skeleton,
20
+ } from "@tulip-systems/core/components";
21
+ import { tableSearchParams } from "@tulip-systems/core/data-tables";
22
+ import { cn } from "@tulip-systems/core/lib";
23
+ import {
24
+ type FileNode,
25
+ type FolderNode,
26
+ getFileUrl,
27
+ type ImageVariant,
28
+ isFile,
29
+ isFolder,
30
+ } from "@tulip-systems/core/storage";
31
+ import { FolderIcon } from "lucide-react";
32
+ import { useInView } from "motion/react";
33
+ import Image from "next/image";
34
+ import { useRouter } from "next/navigation";
35
+ import { useQueryStates } from "nuqs";
36
+ import {
37
+ type ComponentProps,
38
+ type PropsWithChildren,
39
+ useDeferredValue,
40
+ useEffect,
41
+ useMemo,
42
+ useRef,
43
+ } from "react";
44
+ import { orpc } from "@/server/router/client";
45
+ import { driveCommands } from "../_config/commands.js";
46
+ import { nodeSubtypeConfig, nodeSubtypeVariants } from "../_config/types.js";
47
+ import { driveFilterSearchParams, driveSearchParams } from "../_lib/search-params.js";
48
+ import { useDriveContext } from "./drive-context.client.js";
49
+
50
+ /**
51
+ * DriveGrid
52
+ */
53
+ export function DriveGrid(props: ComponentProps<"div">) {
54
+ const [filters] = useQueryStates(driveFilterSearchParams);
55
+ const [{ parentId }] = useQueryStates(driveSearchParams);
56
+ const { namespace, where } = useDriveContext();
57
+
58
+ const [query] = useQueryStates(tableSearchParams);
59
+ const input = useDeferredValue({
60
+ ...query,
61
+ filters: { ...filters, ...where, parentId, namespace },
62
+ });
63
+ // const { data } = useSuspenseQuery(orpc.drive.getNodesByParentId.queryOptions({ input }));
64
+
65
+ const response = useSuspenseInfiniteQuery(
66
+ orpc.drive.list.infiniteOptions({
67
+ initialPageParam: 0,
68
+ input: (cursor: number | undefined) => ({ ...input, cursor }),
69
+ getNextPageParam: ({ pagination }) => pagination.nextCursor,
70
+ getPreviousPageParam: ({ pagination }) => pagination.previousCursor,
71
+ }),
72
+ );
73
+
74
+ const queryData = useMemo(
75
+ () => response.data.pages.flatMap(({ data }) => data) ?? [],
76
+ [response.data],
77
+ );
78
+
79
+ return (
80
+ <DriveGridContent {...props}>
81
+ {queryData.length > 0 ? (
82
+ queryData
83
+ .filter(Boolean)
84
+ .filter((node) => !node.hidden)
85
+ .flatMap((node) => {
86
+ if (node.isPending) {
87
+ return <DriveGridCardSkeleton key={node.id} />;
88
+ }
89
+
90
+ if (isFolder(node)) {
91
+ const folder = node as FolderNode;
92
+ return <DriveGridFolderCard key={folder.id} folder={folder} />;
93
+ }
94
+
95
+ if (isFile(node)) {
96
+ const file = node as FileNode;
97
+ return <DriveGridFileCard key={file.id} file={file} />;
98
+ }
99
+
100
+ return null;
101
+ })
102
+ ) : (
103
+ <EmptyPage className="col-span-full min-h-[80dvh]">
104
+ <EmptyPageTitle>Geen resultaten gevonden</EmptyPageTitle>
105
+ </EmptyPage>
106
+ )}
107
+
108
+ {response.isFetchingNextPage &&
109
+ Array.from({ length: response.data.pages[0]?.pagination.limit ?? 0 }).map((_, index) => (
110
+ <DriveGridCardSkeleton key={index} />
111
+ ))}
112
+
113
+ <DriveGridBottombar
114
+ hasNextPage={response.hasNextPage}
115
+ fetchNextPage={response.fetchNextPage}
116
+ isFetching={response.isFetching}
117
+ isFetchingNextPage={response.isFetchingNextPage}
118
+ />
119
+ </DriveGridContent>
120
+ );
121
+ }
122
+
123
+ /**
124
+ * DriveGridContent
125
+ */
126
+ function DriveGridContent(props: PropsWithChildren) {
127
+ return (
128
+ <div className="grid grid-cols-1 gap-5 md:grid-cols-[repeat(auto-fill,minmax(13rem,1fr))]">
129
+ {props.children}
130
+ </div>
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Folder card
136
+ */
137
+ function DriveGridFolderCard({ folder }: { folder: FolderNode }) {
138
+ const router = useRouter();
139
+
140
+ const droppable = useDroppable({ id: folder.id });
141
+ const draggable = useDraggable({ id: folder.id });
142
+
143
+ if (folder.hidden) return null;
144
+
145
+ return (
146
+ <ContextCommandMenu>
147
+ <ContextCommandMenuTrigger asChild>
148
+ <Card
149
+ ref={draggable.ref}
150
+ data-dragging={draggable.isDragging}
151
+ className="group @container relative flex max-h-52 max-w-full cursor-pointer flex-col items-center gap-3 overflow-hidden bg-transparent p-2 hover:bg-muted/70 active:bg-muted data-[dragging=true]:opacity-50"
152
+ // onMouseOver={() =>
153
+ // queryClient.prefetchQuery(
154
+ // trpc.drive.getNodesByParentId.queryOptions({ parentId: folder.id, namespace }),
155
+ // )
156
+ // }
157
+ onDoubleClick={() => router.push(`?parentId=${folder.id}`)}
158
+ >
159
+ <div
160
+ ref={droppable.ref}
161
+ data-over={droppable.isDropTarget && !draggable.isDragging}
162
+ className="absolute inset-0 -z-10 data-[over=true]:bg-primary/35"
163
+ />
164
+
165
+ <CardHeader className="flex w-full flex-row items-center justify-between gap-2.5 p-0 px-1.5">
166
+ <FolderIcon className="w-4 min-w-4 fill-foreground" />
167
+
168
+ <CardTitle className="w-full truncate text-sm leading-[1.2]" title={folder.name}>
169
+ {folder.name}
170
+ </CardTitle>
171
+
172
+ <DropdownCommandMenu data={folder} commands={driveCommands} />
173
+ </CardHeader>
174
+
175
+ <CardContent className="h-52 w-full overflow-hidden rounded-lg bg-background/50 p-0 group-hover:bg-background">
176
+ <div className="flex h-full w-full items-center justify-center">
177
+ <FolderIcon className="size-12 fill-foreground" />
178
+ </div>
179
+ </CardContent>
180
+ </Card>
181
+ </ContextCommandMenuTrigger>
182
+
183
+ <ContextCommandMenuContent data={folder} commands={driveCommands} />
184
+ </ContextCommandMenu>
185
+ );
186
+ }
187
+
188
+ /**
189
+ * File card
190
+ */
191
+ export function DriveGridFileCard({ file }: { file: FileNode }) {
192
+ const { id } = file;
193
+
194
+ const { ref, isDragging } = useDraggable({ id });
195
+
196
+ const subtype = findStatus(nodeSubtypeConfig, file.subtype);
197
+ const Icon = subtype?.icon;
198
+ if (file.hidden) return null;
199
+
200
+ return (
201
+ <ContextCommandMenu>
202
+ <ContextCommandMenuTrigger asChild>
203
+ <Card
204
+ key={id}
205
+ ref={ref}
206
+ data-dragging={isDragging}
207
+ className="@container flex max-h-52 max-w-full cursor-pointer flex-col items-center gap-3 overflow-hidden p-2 hover:bg-muted/70 active:bg-muted data-[dragging=true]:opacity-50"
208
+ onDoubleClick={() => window.open(getFileUrl(id), "_blank")}
209
+ >
210
+ <CardHeader className="flex w-full flex-row items-center justify-between gap-2.5 p-0 px-1.5">
211
+ {Icon && (
212
+ <Icon
213
+ className={nodeSubtypeVariants({ status: file.subtype, className: "size-4" })}
214
+ />
215
+ )}
216
+
217
+ <CardTitle className="w-full truncate text-sm leading-[1.2]" title={file.name}>
218
+ {file.name}
219
+ </CardTitle>
220
+
221
+ <DropdownCommandMenu data={file} commands={driveCommands} />
222
+ </CardHeader>
223
+
224
+ <CardContent className="h-52 w-full overflow-hidden rounded-lg bg-background p-0">
225
+ {file.subtype === "image" ? (
226
+ <Image
227
+ src={getFileUrl(id)}
228
+ alt={file.name}
229
+ width={200}
230
+ height={200}
231
+ className="h-full w-full object-cover"
232
+ loader={({ width }) =>
233
+ getFileUrl(id, {
234
+ variant: `preview-${width}` as ImageVariant,
235
+ })
236
+ }
237
+ />
238
+ ) : (
239
+ <div className="flex h-full w-full items-center justify-center">
240
+ {Icon && (
241
+ <Icon
242
+ className={nodeSubtypeVariants({ status: file.subtype, className: "size-12" })}
243
+ />
244
+ )}
245
+ </div>
246
+ )}
247
+ </CardContent>
248
+ </Card>
249
+ </ContextCommandMenuTrigger>
250
+
251
+ <ContextCommandMenuContent data={file} commands={driveCommands} />
252
+ </ContextCommandMenu>
253
+ );
254
+ }
255
+
256
+ /**
257
+ * Infinite table bottombar
258
+ */
259
+ export function DriveGridBottombar(props: {
260
+ hasNextPage: boolean;
261
+ fetchNextPage: () => void;
262
+ isFetching: boolean;
263
+ isFetchingNextPage: boolean;
264
+ }) {
265
+ const scrollRef = useRef(null);
266
+ const isInView = useInView(scrollRef);
267
+
268
+ useEffect(() => {
269
+ if (isInView && props.hasNextPage && !props.isFetching && !props.isFetchingNextPage) {
270
+ props.fetchNextPage();
271
+ }
272
+ }, [
273
+ isInView,
274
+ props.fetchNextPage,
275
+ props.hasNextPage,
276
+ props.isFetching,
277
+ props.isFetchingNextPage,
278
+ ]);
279
+
280
+ return (
281
+ <div className="relative col-span-full">
282
+ <div ref={scrollRef} className="absolute bottom-0 -z-50 min-h-[500px] bg-primary" />
283
+ </div>
284
+ );
285
+ }
286
+
287
+ /**
288
+ * DriveGridSkeleton
289
+ */
290
+ export function DriveGridLoading({ className, ...props }: ComponentProps<"div">) {
291
+ return (
292
+ <div {...props} className={cn("flex flex-col gap-y-12", className)}>
293
+ <DriveGridContent>
294
+ {Array.from({ length: 12 }).map((_, index) => (
295
+ <DriveGridCardSkeleton key={index} />
296
+ ))}
297
+ </DriveGridContent>
298
+ </div>
299
+ );
300
+ }
301
+
302
+ /**
303
+ * Card Skeleton
304
+ */
305
+
306
+ function DriveGridCardSkeleton() {
307
+ return (
308
+ <Card className="flex max-h-52 cursor-pointer flex-col items-center gap-3 overflow-hidden bg-muted/70 p-3">
309
+ <Skeleton className="h-7" />
310
+ <CardContent className="h-52 w-full overflow-hidden rounded-lg bg-background p-0" />
311
+ </Card>
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Drive Grid Back Droppable
317
+ */
318
+ // function DriveGridBackDroppable({ parentId }: { parentId: string }) {
319
+ // const [isVisible, setVisible] = useState(false);
320
+
321
+ // const { ref, isDropTarget } = useDroppable({ id: parentId });
322
+
323
+ // useDragDropMonitor({
324
+ // onDragStart: () => setVisible(true),
325
+ // onDragEnd: () => setVisible(false),
326
+ // });
327
+
328
+ // return (
329
+ // <AnimatePresence>
330
+ // {isVisible && (
331
+ // <div
332
+ // ref={ref}
333
+ // data-over={isDropTarget}
334
+ // className="border-primary/35 data-[over=true]:bg-primary/35 absolute left-0 right-0 top-0 z-50 flex h-[var(--header-top-bar-height)] justify-center border"
335
+ // />
336
+ // )}
337
+ // </AnimatePresence>
338
+ // );
339
+ // }
340
+
341
+ /**
342
+ * Drive Grid Header
343
+ */
344
+ // export function DriveGridHeader() {
345
+ // const trpc = useTRPC<AppRouter>();
346
+ // const [{ parentId }] = useQueryStates(driveGridSearchParams);
347
+ // const { namespace } = useDriveGridContext();
348
+
349
+ // const parents = useSuspenseQuery(
350
+ // trpc.drive.getFolderParents.queryOptions({ id: parentId, namespace }),
351
+ // );
352
+
353
+ // return (
354
+ // <Header>
355
+ // <Topbar>
356
+ // <TopbarTitle
357
+ // breadcrumbs={[
358
+ // { label: "Drive", href: "/admin/drive" },
359
+ // ...(parents.data?.flatMap((node) => ({
360
+ // label: node.name,
361
+ // href: `?parentId=${node.id}`,
362
+ // })) ?? []),
363
+ // ]}
364
+ // />
365
+
366
+ // <TopbarTools>
367
+ // <InlineCommandMenu data={{ parentId, namespace }} commands={driveGlobalCommands} />
368
+ // </TopbarTools>
369
+ // </Topbar>
370
+ // </Header>
371
+ // );
372
+ // }