@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
@@ -0,0 +1,182 @@
1
+ "use client";
2
+
3
+ import { toast } from "@tulip-systems/core/components/client";
4
+ import { cn } from "@tulip-systems/core/lib";
5
+ import { useAction } from "@tulip-systems/core/lib/client";
6
+ import type { StorageAsset } from "@tulip-systems/core/storage";
7
+ import { type ComponentProps, useCallback } from "react";
8
+ import { useDropzone } from "react-dropzone";
9
+ import { inferLocalDriveNodeSubtype } from "@/providers/local/lib/helpers";
10
+ import type {
11
+ LocalDriveUploadFileRequest,
12
+ LocalDriveUploadHooks,
13
+ PrepareLocalDriveUploadInput,
14
+ } from "@/providers/local/lib/upload.client";
15
+ import type { LocalDriveFileNode, LocalDriveNodeWithAsset } from "@/providers/local/lib/validators";
16
+ import {
17
+ LocalDriveUploadZoneContext,
18
+ type LocalDriveUploadZoneContextValue,
19
+ } from "./upload-zone-context.client";
20
+
21
+ export type LocalDriveUploadZoneProps = ComponentProps<"div"> &
22
+ Pick<LocalDriveUploadZoneContextValue, "optimistic" | "driveUploadClient" | "disabled"> & {
23
+ variables: Pick<
24
+ PrepareLocalDriveUploadInput,
25
+ "namespace" | "parentId" | "readonly" | "hidden" | "visibility"
26
+ >;
27
+ uploadHooks?: LocalDriveUploadHooks;
28
+
29
+ onUploadCompleted?: (node: LocalDriveFileNode) => Promise<void> | void;
30
+ onUploadFailed?: (error: unknown) => Promise<void> | void;
31
+ };
32
+
33
+ export type DriveUploadZoneProps = LocalDriveUploadZoneProps;
34
+
35
+ export function LocalDriveUploadZone({
36
+ variables,
37
+ optimistic,
38
+ driveUploadClient,
39
+ uploadHooks,
40
+ onUploadCompleted,
41
+ onUploadFailed,
42
+ disabled = false,
43
+ children,
44
+ className,
45
+ ...props
46
+ }: LocalDriveUploadZoneProps) {
47
+ /**
48
+ * Delete mutation
49
+ */
50
+ const deleteMutation = useAction({
51
+ mutationFn: async (ids: string[]) => driveUploadClient.deleteFiles(ids),
52
+ onMutate: async (ids) => {
53
+ await optimistic?.cancel?.();
54
+ await optimistic?.remove?.(ids);
55
+ },
56
+ onError: async (error) => {
57
+ console.error("Delete failed upload error: ", error);
58
+ await onUploadFailed?.(error);
59
+ },
60
+ onSettled: () => {
61
+ optimistic?.invalidate?.();
62
+ },
63
+ });
64
+
65
+ /**
66
+ * Upload mutation
67
+ */
68
+ const uploadMutation = useAction({
69
+ mutationFn: async (params: LocalDriveUploadFileRequest) =>
70
+ driveUploadClient.upload(params, uploadHooks),
71
+ onMutate: async (variables) => {
72
+ await optimistic?.cancel?.();
73
+
74
+ // Generate a new node
75
+ const newNode = {
76
+ id: variables.uploadId,
77
+ provider: "local",
78
+ createdAt: new Date(),
79
+ updatedAt: new Date(),
80
+ type: "file",
81
+ name: variables.file.name,
82
+ namespace: variables.namespace,
83
+ parentId: variables.parentId ?? null,
84
+ size: variables.file.size,
85
+ contentType: variables.file.type || "application/octet-stream",
86
+ readonly: variables.readonly ?? false,
87
+ hidden: variables.hidden ?? false,
88
+ archivedAt: null,
89
+ assetId: null,
90
+ subtype: inferLocalDriveNodeSubtype({
91
+ name: variables.file.name,
92
+ contentType: variables.file.type || "application/octet-stream",
93
+ }),
94
+ links: {
95
+ view: null,
96
+ download: null,
97
+ preview: null,
98
+ thumbnail: null,
99
+ },
100
+ availability: "pending",
101
+ asset: {
102
+ status: "pending",
103
+ name: variables.file.name,
104
+ contentType: variables.file.type || "application/octet-stream",
105
+ size: variables.file.size,
106
+ visibility: variables.visibility || "private",
107
+ } as StorageAsset,
108
+ } as LocalDriveNodeWithAsset;
109
+
110
+ await optimistic?.add?.(newNode);
111
+ },
112
+ onSuccess: async (data) => {
113
+ await onUploadCompleted?.(data);
114
+ toast.success(`Succesvol geupload: ${data.name}`);
115
+ },
116
+ onError: async (error) => {
117
+ await onUploadFailed?.(error);
118
+
119
+ console.error("Upload error: ", error);
120
+ toast.error("Bestand uploaden mislukt", {
121
+ description: error instanceof Error ? error.message : undefined,
122
+ });
123
+ },
124
+ onSettled: () => {
125
+ optimistic?.invalidate?.();
126
+ },
127
+ });
128
+
129
+ /**
130
+ * Upload file handler
131
+ */
132
+ const onUpload = useCallback(
133
+ async (file: File) => {
134
+ console.info("Uploading file", file);
135
+ const req = driveUploadClient.prepareUpload({ ...variables, file });
136
+ return await uploadMutation.mutateAsync(req);
137
+ },
138
+ [driveUploadClient, variables, uploadMutation],
139
+ );
140
+
141
+ /**
142
+ * Drop handler
143
+ */
144
+ const onDrop = useCallback(
145
+ async (acceptedFiles: File[]) => {
146
+ if (acceptedFiles.length === 0) return;
147
+ await Promise.allSettled(acceptedFiles.map(onUpload));
148
+ },
149
+ [onUpload],
150
+ );
151
+
152
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
153
+ onDrop,
154
+ noClick: true,
155
+ disabled,
156
+ });
157
+
158
+ return (
159
+ <div {...props} {...getRootProps()} className={cn("relative z-0", className)}>
160
+ <input {...getInputProps()} />
161
+
162
+ {/* Show drag overlay when drag is active */}
163
+ {isDragActive && (
164
+ <div className="absolute inset-0 z-10 rounded-md bg-primary/20 opacity-70 backdrop-blur-3xl" />
165
+ )}
166
+
167
+ <LocalDriveUploadZoneContext
168
+ value={{
169
+ driveUploadClient,
170
+ onUpload,
171
+ onRemove: deleteMutation.mutateAsync,
172
+ optimistic,
173
+ disabled,
174
+ }}
175
+ >
176
+ {children}
177
+ </LocalDriveUploadZoneContext>
178
+ </div>
179
+ );
180
+ }
181
+
182
+ export const DriveUploadZone = LocalDriveUploadZone;
@@ -0,0 +1,145 @@
1
+ "use client";
2
+
3
+ import type { VisibilityState } from "@tanstack/react-table";
4
+ import type { CommandDef } from "@tulip-systems/core/commands";
5
+ import { FloatingCommandMenu } from "@tulip-systems/core/commands/client";
6
+ import { type TableColumnDef, TableLayout, TableSkeleton } from "@tulip-systems/core/data-tables";
7
+ import {
8
+ createTableConfig,
9
+ DataTable,
10
+ type InfiniteStrategyMeta,
11
+ TableConfigProvider,
12
+ type useInfiniteStrategy,
13
+ useTableConfigContext,
14
+ } from "@tulip-systems/core/data-tables/client";
15
+ import { type ComponentProps, useMemo } from "react";
16
+ import {
17
+ DriveGrid,
18
+ DriveGridBottombar,
19
+ DriveGridEmpty,
20
+ DriveGridLoading,
21
+ } from "@/components/grid.client";
22
+ import { DriveGridCard, DriveGridCardSkeleton } from "@/components/grid-card.client";
23
+ import { useDriveSelectionContext } from "@/components/selection.client";
24
+ import { useDriveViewContext } from "@/components/view.client";
25
+ import { localDriveColumns } from "../config/columns-data";
26
+ import type { LocalDriveNodeWithAsset } from "../lib/validators";
27
+ import { useLocalDriveContext } from "./provider.client";
28
+ import { useLocalDriveUploadZone } from "./upload-zone-context.client";
29
+
30
+ type LocalDriveViewProviderProps<TData extends LocalDriveNodeWithAsset = LocalDriveNodeWithAsset> =
31
+ ComponentProps<"div"> & {
32
+ queryData: TData[];
33
+ columns?: TableColumnDef<TData>[];
34
+ strategy: ReturnType<typeof useInfiniteStrategy>;
35
+ commands?: CommandDef<TData>[];
36
+ columnVisibility?: VisibilityState;
37
+ };
38
+
39
+ export function LocalDriveViewProvider<
40
+ TData extends LocalDriveNodeWithAsset = LocalDriveNodeWithAsset,
41
+ >({
42
+ queryData,
43
+ columns = localDriveColumns as TableColumnDef<TData>[],
44
+ strategy,
45
+ commands,
46
+ columnVisibility,
47
+ children,
48
+ ...props
49
+ }: LocalDriveViewProviderProps<TData>) {
50
+ const { meta } = useLocalDriveContext();
51
+ const { selection } = useDriveSelectionContext();
52
+
53
+ const config = createTableConfig<TData>({
54
+ queryData,
55
+ columns,
56
+ strategy,
57
+ commands,
58
+ meta,
59
+ columnVisibility,
60
+ selection,
61
+ });
62
+
63
+ return (
64
+ <TableConfigProvider config={config}>
65
+ <TableLayout {...props}>{children}</TableLayout>
66
+ </TableConfigProvider>
67
+ );
68
+ }
69
+
70
+ export function LocalDriveView() {
71
+ const { view } = useDriveViewContext();
72
+
73
+ if (view === "grid") return <LocalDriveGrid />;
74
+ if (view === "list") return <LocalDriveList />;
75
+
76
+ return null;
77
+ }
78
+
79
+ function LocalDriveGrid(props: ComponentProps<"div">) {
80
+ const { queryData, strategy, commands, selection, meta } =
81
+ useTableConfigContext<LocalDriveNodeWithAsset>();
82
+ const { optimistic } = useLocalDriveUploadZone();
83
+
84
+ const { rowCount, paginationState } = strategy;
85
+ const { isFetching, isFetchingNextPage, fetchNextPage } = strategy.meta as InfiniteStrategyMeta;
86
+
87
+ const hasNextPage = rowCount == null ? false : queryData.length < rowCount;
88
+
89
+ const selectedData = useMemo(
90
+ () => queryData.filter((node) => selection?.rowSelection?.[node.id] === true),
91
+ [queryData, selection?.rowSelection],
92
+ );
93
+
94
+ return (
95
+ <DriveGrid {...props}>
96
+ {queryData.length > 0 ? (
97
+ queryData
98
+ .filter((node) => !node.hidden)
99
+ .map((node) => <DriveGridCard key={node.id} node={node} commands={commands} />)
100
+ ) : (
101
+ <DriveGridEmpty title="Geen resultaten gevonden" />
102
+ )}
103
+
104
+ {isFetchingNextPage &&
105
+ Array.from({ length: paginationState?.pageSize ?? 0 }).map((_, index) => (
106
+ <DriveGridCardSkeleton key={index} />
107
+ ))}
108
+
109
+ <DriveGridBottombar
110
+ hasNextPage={hasNextPage}
111
+ fetchNextPage={fetchNextPage}
112
+ isFetching={isFetching}
113
+ isFetchingNextPage={isFetchingNextPage}
114
+ />
115
+
116
+ {commands && commands.length > 0 && (
117
+ <FloatingCommandMenu
118
+ data={selectedData as never}
119
+ commands={commands as never}
120
+ meta={meta}
121
+ state={selectedData.length > 0 ? "open" : "closed"}
122
+ onSuccess={() => {
123
+ selection?.setRowSelection?.({});
124
+ void optimistic?.invalidate?.();
125
+ }}
126
+ />
127
+ )}
128
+ </DriveGrid>
129
+ );
130
+ }
131
+
132
+ function LocalDriveList(props: ComponentProps<typeof DataTable>) {
133
+ return <DataTable {...props} />;
134
+ }
135
+
136
+ export function LocalDriveViewLoading({ ...props }: ComponentProps<"div">) {
137
+ const { view } = useDriveViewContext();
138
+
139
+ return (
140
+ <div {...props}>
141
+ {view === "grid" && <DriveGridLoading {...props} />}
142
+ {view === "list" && <TableSkeleton {...props} />}
143
+ </div>
144
+ );
145
+ }
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import { findStatus } from "@tulip-systems/core/components";
4
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@tulip-systems/core/components/client";
5
+ import { type TableColumnDef, TableColumnHeader } from "@tulip-systems/core/data-tables";
6
+ import { createTableSelectCell, TableTextCell } from "@tulip-systems/core/data-tables/client";
7
+ import { FolderIcon } from "lucide-react";
8
+ import Link from "next/link";
9
+ import { createSerializer, useQueryStates } from "nuqs";
10
+ import { nodeSubtypeConfig, nodeSubtypeVariants } from "@/config/types";
11
+ import { driveTreeSearchParams } from "@/lib/search-params";
12
+ import type { LocalDriveNodeWithAsset } from "../lib/validators";
13
+
14
+ export const localDriveColumns: TableColumnDef<LocalDriveNodeWithAsset>[] = [
15
+ createTableSelectCell(),
16
+ {
17
+ id: "icon",
18
+ accessorKey: "icon",
19
+ header: () => null,
20
+ cell: ({ row }) => {
21
+ if (row.original.type === "file") {
22
+ const subtype = findStatus(nodeSubtypeConfig, row.original.subtype);
23
+ if (!subtype) return null;
24
+
25
+ return (
26
+ <TableTextCell className="w-4">
27
+ <Tooltip>
28
+ <TooltipTrigger>
29
+ <subtype.icon
30
+ className={nodeSubtypeVariants({
31
+ status: row.original.subtype,
32
+ className: "size-4",
33
+ })}
34
+ />
35
+ </TooltipTrigger>
36
+
37
+ <TooltipContent>{subtype.label}</TooltipContent>
38
+ </Tooltip>
39
+ </TableTextCell>
40
+ );
41
+ }
42
+
43
+ if (row.original.type === "folder") {
44
+ return (
45
+ <TableTextCell className="w-4">
46
+ <FolderIcon className="w-4 min-w-4" />
47
+ </TableTextCell>
48
+ );
49
+ }
50
+
51
+ return null;
52
+ },
53
+ enableSorting: false,
54
+ },
55
+ {
56
+ id: "name",
57
+ accessorKey: "name",
58
+ header: ({ column }) => <TableColumnHeader column={column} title="Naam" />,
59
+ cell: ({ row }) => {
60
+ const [query] = useQueryStates(driveTreeSearchParams);
61
+ const serialize = createSerializer(driveTreeSearchParams);
62
+
63
+ return (
64
+ <TableTextCell className="w-full min-w-64">
65
+ <Link
66
+ href={
67
+ row.original.type === "file"
68
+ ? ((row.original.links.view as string) ?? "#")
69
+ : serialize({ ...query, parentId: row.original.id })
70
+ }
71
+ className="truncate hover:underline"
72
+ target={row.original.type === "file" ? "_blank" : "_self"}
73
+ >
74
+ {row.getValue("name")}
75
+ </Link>
76
+ </TableTextCell>
77
+ );
78
+ },
79
+ enableSorting: false,
80
+ },
81
+ ];
@@ -0,0 +1,14 @@
1
+ import {
2
+ createTableFilters,
3
+ parseFilterArray,
4
+ parseFilterBoolean,
5
+ } from "@tulip-systems/core/data-tables";
6
+ import z from "zod";
7
+ import { driveTreeFilters } from "@/config/filters";
8
+ import { nodeSubtypes } from "../lib/constants";
9
+
10
+ export const localDriveFilters = createTableFilters({
11
+ ...driveTreeFilters,
12
+ subtypes: parseFilterArray(z.array(z.enum(nodeSubtypes))),
13
+ hidden: parseFilterBoolean(z.boolean()),
14
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Components
3
+ */
4
+
5
+ export * from "./components/command-file-update";
6
+ export * from "./components/command-file-upload";
7
+ export * from "./components/command-folder-create";
8
+ export * from "./components/command-folder-update";
9
+ export * from "./components/content.client";
10
+ export * from "./components/navigation.client";
11
+ export * from "./components/provider.client";
12
+ export * from "./components/upload-zone.client";
13
+ export * from "./components/upload-zone-context.client";
14
+ export * from "./components/view.client";
15
+ /**
16
+ * Lib
17
+ */
18
+ export * from "./lib/upload.client";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Lib
3
+ */
4
+
5
+ export * from "./lib/route-handler.server";
6
+ export * from "./lib/router.server";
7
+ export * from "./lib/service.server";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Config
3
+ */
4
+ export * from "./config/columns-data";
5
+ export * from "./config/filters";
6
+
7
+ /**
8
+ * Lib
9
+ */
10
+ export * from "./lib/constants";
11
+ export * from "./lib/helpers";
12
+ export * from "./lib/schema";
13
+ export * from "./lib/search-params";
14
+ export * from "./lib/validators";
@@ -0,0 +1,23 @@
1
+ // Device sizes
2
+ export const deviceSizes = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
3
+ export const imageSizes = deviceSizes;
4
+
5
+ // Image variants
6
+ export const imageVariants = [
7
+ "main",
8
+ ...deviceSizes.map((size) => `preview-${size}` as const),
9
+ ] as const;
10
+ export type ImageVariant = (typeof imageVariants)[number];
11
+
12
+ /**
13
+ * Node subtype
14
+ */
15
+ export const nodeSubtypes = [
16
+ "image",
17
+ "document",
18
+ "spreadsheet",
19
+ "video",
20
+ "audio",
21
+ "archive",
22
+ "other",
23
+ ] as const;
@@ -0,0 +1,105 @@
1
+ import type { ImageLoaderProps } from "next/image";
2
+ import type { nodeSubtypes } from "./constants";
3
+ import type { GetLocalFileURLSchema, LocalDriveNode, LocalNode } from "./validators";
4
+
5
+ /**
6
+ * Convert a LocalNode to LocalDriveNode by adding provider and links
7
+ * @param node The LocalNode to convert
8
+ * @returns The converted LocalDriveNode
9
+ */
10
+ export function toLocalDriveNode<TNode extends LocalNode>(node: TNode): LocalDriveNode {
11
+ return {
12
+ ...node,
13
+ type: node.type as LocalDriveNode["type"],
14
+ readonly: node.readonly ?? false,
15
+ hidden: node.hidden ?? false,
16
+ provider: "local" as const,
17
+ links: {
18
+ view: node.type === "file" ? getLocalDriveFileUrl(node.id, { disposition: "inline" }) : null,
19
+ download:
20
+ node.type === "file" ? getLocalDriveFileUrl(node.id, { disposition: "attachment" }) : null,
21
+ preview: node.type === "file" ? getLocalDriveFileUrl(node.id) : null,
22
+ thumbnail:
23
+ node.type === "file" ? getLocalDriveFileUrl(node.id, { variant: "preview-256" }) : null,
24
+ },
25
+ availability: "ready" as const,
26
+ } satisfies LocalDriveNode;
27
+ }
28
+
29
+ /**
30
+ * Check if the node is a file
31
+ */
32
+ export function isLocalDriveFile(node: Pick<LocalNode, "type">): node is LocalNode {
33
+ return node.type === "file";
34
+ }
35
+
36
+ /**
37
+ * Check if the node is a folder
38
+ */
39
+ export function isLocalDriveFolder(node: Pick<LocalNode, "type">): node is LocalNode {
40
+ return node.type === "folder";
41
+ }
42
+
43
+ /**
44
+ * Get file url
45
+ */
46
+ export function getLocalDriveFileUrl(id: string, options: Partial<GetLocalFileURLSchema> = {}) {
47
+ const searchParams = new URLSearchParams();
48
+ if (options?.variant) searchParams.set("variant", options.variant);
49
+ if (options?.disposition) searchParams.set("disposition", options.disposition);
50
+
51
+ const queryString = searchParams.toString();
52
+ return `/api/drive/files/${id}${queryString ? `?${queryString}` : ""}`;
53
+ }
54
+
55
+ /**
56
+ * Image loader
57
+ */
58
+ export function localDriveImageLoader({ src, width }: ImageLoaderProps) {
59
+ const url = new URL(src, window.location.origin);
60
+ url.searchParams.set("variant", `preview-${width}`);
61
+ return url.toString();
62
+ }
63
+
64
+ /**
65
+ * Node subtype inference
66
+ */
67
+ export type LocalDriveNodeSubtype = (typeof nodeSubtypes)[number];
68
+
69
+ export function inferLocalDriveNodeSubtype({
70
+ name,
71
+ contentType,
72
+ }: Partial<Pick<LocalDriveNode, "name" | "contentType">>): LocalDriveNodeSubtype {
73
+ if (!contentType) return "other";
74
+ if (contentType?.startsWith("image/")) return "image";
75
+ if (contentType === "application/pdf") return "document";
76
+ if (contentType?.includes("spreadsheet")) return "spreadsheet";
77
+ if (contentType?.startsWith("video/")) return "video";
78
+ if (contentType?.startsWith("audio/")) return "audio";
79
+ if (contentType?.includes("zip") || contentType?.includes("archive")) return "archive";
80
+
81
+ const ext = name?.split(".").pop()?.toLowerCase();
82
+ if (["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(ext ?? "")) return "image";
83
+ if (["pdf", "doc", "docx", "txt"].includes(ext ?? "")) return "document";
84
+ if (["xls", "xlsx", "ods"].includes(ext ?? "")) return "spreadsheet";
85
+ if (["zip", "rar", "7z"].includes(ext ?? "")) return "archive";
86
+
87
+ return "other";
88
+ }
89
+
90
+ /**
91
+ * Render bytes
92
+ */
93
+ export function renderBytes(bytes: number) {
94
+ const units = ["B", "KB", "MB", "GB", "TB", "PB"];
95
+
96
+ let size = bytes;
97
+ let unitIndex = 0;
98
+
99
+ while (size >= 1024 && unitIndex < units.length - 1) {
100
+ size /= 1024;
101
+ unitIndex++;
102
+ }
103
+
104
+ return `${size.toFixed(2)}${units[unitIndex]}`;
105
+ }