diff --git a/apps/drive-web/package.json b/apps/drive-web/package.json index 8eef969..9461630 100644 --- a/apps/drive-web/package.json +++ b/apps/drive-web/package.json @@ -20,8 +20,8 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", - "@radix-ui/react-separator": "^1.1.7", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.87.4", "@tanstack/react-router": "^1.131.41", diff --git a/apps/drive-web/src/components/crossfade-icon.tsx b/apps/drive-web/src/components/crossfade-icon.tsx new file mode 100644 index 0000000..c694376 --- /dev/null +++ b/apps/drive-web/src/components/crossfade-icon.tsx @@ -0,0 +1,84 @@ +import { + type ReactNode, + type Ref, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react" +import { cn } from "@/lib/utils" + +type CrossfadeIconProps = { + from: ReactNode + to: ReactNode + active?: boolean + className?: string + ref?: Ref +} + +export type CrossfadeIconHandle = { + trigger: () => void +} + +export function CrossfadeIcon({ + from, + to, + active = false, + className, + ref, +}: CrossfadeIconProps) { + const [forcedActive, setForcedActive] = useState(false) + const timeoutRef = useRef | null>(null) + + const clearTimer = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + }, []) + + useImperativeHandle( + ref, + () => ({ + trigger: () => { + setForcedActive(true) + clearTimer() + timeoutRef.current = setTimeout(() => { + setForcedActive(false) + timeoutRef.current = null + }, 3000) + }, + }), + [clearTimer], + ) + + useEffect(() => clearTimer, [clearTimer]) + + const isActive = active || forcedActive + + return ( +
+ + {from} + + + {to} + +
+ ) +} diff --git a/apps/drive-web/src/components/ui/button-group.tsx b/apps/drive-web/src/components/ui/button-group.tsx new file mode 100644 index 0000000..8600af0 --- /dev/null +++ b/apps/drive-web/src/components/ui/button-group.tsx @@ -0,0 +1,83 @@ +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Separator } from "@/components/ui/separator" + +const buttonGroupVariants = cva( + "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + } +) + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { + asChild?: boolean +}) { + const Comp = asChild ? Slot : "div" + + return ( + + ) +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +} diff --git a/apps/drive-web/src/directories/directory-page/directory-content-table.tsx b/apps/drive-web/src/directories/directory-page/directory-content-table.tsx index 53f382c..6d260d1 100644 --- a/apps/drive-web/src/directories/directory-page/directory-content-table.tsx +++ b/apps/drive-web/src/directories/directory-page/directory-content-table.tsx @@ -64,6 +64,7 @@ export type DirectoryContentTableProps = { fileDragInfoAtom: PrimitiveAtom loadingComponent?: React.ReactNode debugBanner?: React.ReactNode + readOnly?: boolean onContextMenu: ( row: Row, @@ -98,84 +99,97 @@ function formatFileSize(bytes: number): string { function useTableColumns( onOpenFile: (file: FileInfo) => void, directoryUrlFn: (directory: DirectoryInfo) => string, + readOnly: boolean, ): ColumnDef[] { return useMemo( - () => [ - { - id: "select", - header: ({ table }) => ( - { - table.toggleAllPageRowsSelected(!!value) - }} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - { - e.stopPropagation() - }} - onCheckedChange={row.getToggleSelectedHandler()} - aria-label="Select row" - /> - ), - enableSorting: false, - enableHiding: false, - size: 24, - }, - { - header: () => , - accessorKey: "doc.name", - cell: ({ row }) => { - switch (row.original.kind) { - case "file": - return ( - - ) - case "directory": - return ( - - ) - } + () => { + const columns: ColumnDef[] = [] + if (!readOnly) { + columns.push({ + id: "select", + header: ({ table }) => ( + { + table.toggleAllPageRowsSelected(!!value) + }} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + { + e.stopPropagation() + }} + onCheckedChange={row.getToggleSelectedHandler()} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + size: 24, + }) + } + + columns.push( + { + header: () => , + accessorKey: "doc.name", + cell: ({ row }) => { + switch (row.original.kind) { + case "file": + return ( + + ) + case "directory": + return ( + + ) + } + }, + size: 1000, }, - size: 1000, - }, - { - header: "Size", - accessorKey: "size", - cell: ({ row }) => { - switch (row.original.kind) { - case "file": - return ( -
{formatFileSize(row.original.size)}
- ) - case "directory": - return
-
- } + { + header: "Size", + accessorKey: "size", + cell: ({ row }) => { + switch (row.original.kind) { + case "file": + return ( +
+ {formatFileSize(row.original.size)} +
+ ) + case "directory": + return
-
+ } + }, }, - }, - { - header: "Created At", - accessorKey: "createdAt", - cell: ({ row }) => { - return ( -
- {new Date(row.original.createdAt).toLocaleString()} -
- ) + { + header: "Created At", + accessorKey: "createdAt", + cell: ({ row }) => { + return ( +
+ {new Date( + row.original.createdAt, + ).toLocaleString()} +
+ ) + }, }, - }, - ], - [onOpenFile, directoryUrlFn], + ) + + return columns + }, + [onOpenFile, directoryUrlFn, readOnly], ) } @@ -188,6 +202,7 @@ export function DirectoryContentTable({ onOpenFile, onContextMenu, onSortChange, + readOnly = false, }: DirectoryContentTableProps) { const { data: directoryContent, @@ -221,6 +236,7 @@ export function DirectoryContentTable({ items: import("@/vfs/vfs").DirectoryItem[], targetDirectory: import("@/vfs/vfs").DirectoryInfo | string, ) => { + if (readOnly) return moveDroppedItems({ targetDirectory, items, @@ -235,10 +251,10 @@ export function DirectoryContentTable({ ) || [], [directoryContent], ), - columns: useTableColumns(onOpenFile, directoryUrlFn), + columns: useTableColumns(onOpenFile, directoryUrlFn, readOnly), getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), - enableRowSelection: true, + enableRowSelection: !readOnly, enableGlobalFilter: true, globalFilterFn: ( row, @@ -298,6 +314,7 @@ export function DirectoryContentTable({ row: Row, _event: React.MouseEvent, ) => { + if (readOnly) return if (!row.getIsSelected()) { selectRow(row) } @@ -305,6 +322,7 @@ export function DirectoryContentTable({ } const selectRow = (row: Row) => { + if (readOnly) return const keyboardModifiers = store.get(keyboardModifierAtom) const isMultiSelectMode = isControlOrCommandKeyActive(keyboardModifiers) const isRowSelected = row.getIsSelected() @@ -329,7 +347,7 @@ export function DirectoryContentTable({ const handleRowDoubleClick = (row: Row) => { if (row.original.kind === "directory") { navigate({ - to: `/directories/${row.original.id}`, + to: directoryUrlFn(row.original), }) } } @@ -349,6 +367,7 @@ export function DirectoryContentTable({ table={table} row={row} onClick={() => selectRow(row)} + readOnly={readOnly} fileDragInfoAtom={fileDragInfoAtom} onContextMenu={(e) => handleRowContextMenu(row, e)} onDoubleClick={() => { @@ -402,7 +421,9 @@ export function DirectoryContentTable({ {rows.length > 0 ? ( virtualItems.map(renderRow) ) : ( - + )} @@ -412,10 +433,10 @@ export function DirectoryContentTable({ ) } -function NoResultsRow() { +function NoResultsRow({ colSpan }: { colSpan: number }) { return ( - + No results. @@ -428,6 +449,7 @@ function FileItemRow({ onClick, onContextMenu, onDoubleClick, + readOnly, fileDragInfoAtom, onFileDrop, ...rowProps @@ -437,6 +459,7 @@ function FileItemRow({ onClick: () => void onContextMenu: (e: React.MouseEvent) => void onDoubleClick: () => void + readOnly: boolean fileDragInfoAtom: PrimitiveAtom onFileDrop: ( items: import("@/vfs/vfs").DirectoryItem[], @@ -447,13 +470,14 @@ function FileItemRow({ const setFileDragInfo = useSetAtom(fileDragInfoAtom) const { isDraggedOver, dropHandlers } = useFileDrop({ - enabled: row.original.kind === "directory", + enabled: !readOnly && row.original.kind === "directory", destDir: row.original.kind === "directory" ? row.original : undefined, dragInfoAtom: fileDragInfoAtom, onDrop: onFileDrop, }) const handleDragStart = (_e: React.DragEvent) => { + if (readOnly) return let draggedItems: DirectoryItem[] // drag all selections, but only if the currently dragged row is also selected if (row.getIsSelected()) { @@ -479,12 +503,13 @@ function FileItemRow({ } const handleDragEnd = () => { + if (readOnly) return setFileDragInfo(null) } return ( ( if (!response.ok) { throw new ApiError(response.status, await response.text()) } - const body = await response.json() - const result = init.returns(body) - if (result instanceof type.errors) { - throw result + // @ts-expect-error if the response returns nothing, then init.returns *is Nothing*, but typescript thinks its not right for some reason + if (init.returns !== Nothing) { + const body = await response.json() + const result = init.returns(body) + if (result instanceof type.errors) { + throw result + } + return [response, result] } - return [response, result] + return [response, null] } diff --git a/apps/drive-web/src/lib/clipboard.ts b/apps/drive-web/src/lib/clipboard.ts new file mode 100644 index 0000000..163c21c --- /dev/null +++ b/apps/drive-web/src/lib/clipboard.ts @@ -0,0 +1,36 @@ +import { mutationOptions } from "@tanstack/react-query" + +export const copyToClipboardMutation = mutationOptions({ + mutationFn: async (text: string) => { + if ( + typeof navigator !== "undefined" && + navigator.clipboard?.writeText + ) { + await navigator.clipboard.writeText(text) + return text + } + + if (typeof document !== "undefined") { + const textarea = document.createElement("textarea") + textarea.value = text + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + textarea.style.left = "-9999px" + + document.body.appendChild(textarea) + textarea.select() + + const succeeded = document.execCommand("copy") + document.body.removeChild(textarea) + + if (!succeeded) { + throw new Error("Failed to copy to clipboard") + } + + return text + } + + throw new Error("Clipboard is not available") + }, +}) diff --git a/apps/drive-web/src/routeTree.gen.ts b/apps/drive-web/src/routeTree.gen.ts index 1793be6..a962981 100644 --- a/apps/drive-web/src/routeTree.gen.ts +++ b/apps/drive-web/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/ import { Route as LoginCallbackRouteImport } from './routes/login_.callback' import { Route as AuthenticatedSidebarLayoutRouteImport } from './routes/_authenticated/_sidebar-layout' import { Route as AuthenticatedSidebarLayoutHomeRouteImport } from './routes/_authenticated/_sidebar-layout/home' +import { Route as SharesShareIdDirectoriesDirectoryIdRouteImport } from './routes/shares/$shareId.directories.$directoryId' import { Route as AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/directories.$directoryId' const SignUpRoute = SignUpRouteImport.update({ @@ -53,6 +54,12 @@ const AuthenticatedSidebarLayoutHomeRoute = path: '/home', getParentRoute: () => AuthenticatedSidebarLayoutRoute, } as any) +const SharesShareIdDirectoriesDirectoryIdRoute = + SharesShareIdDirectoriesDirectoryIdRouteImport.update({ + id: '/shares/$shareId/directories/$directoryId', + path: '/shares/$shareId/directories/$directoryId', + getParentRoute: () => rootRouteImport, + } as any) const AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute = AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport.update({ id: '/directories/$directoryId', @@ -67,6 +74,7 @@ export interface FileRoutesByFullPath { '/': typeof AuthenticatedIndexRoute '/home': typeof AuthenticatedSidebarLayoutHomeRoute '/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute + '/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute @@ -75,6 +83,7 @@ export interface FileRoutesByTo { '/': typeof AuthenticatedIndexRoute '/home': typeof AuthenticatedSidebarLayoutHomeRoute '/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute + '/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -86,6 +95,7 @@ export interface FileRoutesById { '/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/_sidebar-layout/home': typeof AuthenticatedSidebarLayoutHomeRoute '/_authenticated/_sidebar-layout/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute + '/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -96,6 +106,7 @@ export interface FileRouteTypes { | '/' | '/home' | '/directories/$directoryId' + | '/shares/$shareId/directories/$directoryId' fileRoutesByTo: FileRoutesByTo to: | '/login' @@ -104,6 +115,7 @@ export interface FileRouteTypes { | '/' | '/home' | '/directories/$directoryId' + | '/shares/$shareId/directories/$directoryId' id: | '__root__' | '/_authenticated' @@ -114,6 +126,7 @@ export interface FileRouteTypes { | '/_authenticated/' | '/_authenticated/_sidebar-layout/home' | '/_authenticated/_sidebar-layout/directories/$directoryId' + | '/shares/$shareId/directories/$directoryId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -121,6 +134,7 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute SignUpRoute: typeof SignUpRoute LoginCallbackRoute: typeof LoginCallbackRoute + SharesShareIdDirectoriesDirectoryIdRoute: typeof SharesShareIdDirectoriesDirectoryIdRoute } declare module '@tanstack/react-router' { @@ -174,6 +188,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSidebarLayoutHomeRouteImport parentRoute: typeof AuthenticatedSidebarLayoutRoute } + '/shares/$shareId/directories/$directoryId': { + id: '/shares/$shareId/directories/$directoryId' + path: '/shares/$shareId/directories/$directoryId' + fullPath: '/shares/$shareId/directories/$directoryId' + preLoaderRoute: typeof SharesShareIdDirectoriesDirectoryIdRouteImport + parentRoute: typeof rootRouteImport + } '/_authenticated/_sidebar-layout/directories/$directoryId': { id: '/_authenticated/_sidebar-layout/directories/$directoryId' path: '/directories/$directoryId' @@ -220,6 +241,8 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, SignUpRoute: SignUpRoute, LoginCallbackRoute: LoginCallbackRoute, + SharesShareIdDirectoriesDirectoryIdRoute: + SharesShareIdDirectoriesDirectoryIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx b/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx index 87b9e92..52fbf71 100644 --- a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx +++ b/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx @@ -8,6 +8,7 @@ import { ChevronDownIcon, PlusIcon, ScissorsIcon, + Share2Icon, TextCursorInputIcon, TrashIcon, } from "lucide-react" @@ -68,6 +69,7 @@ import type { DirectoryItem, FileInfo, } from "@/vfs/vfs" +import { ItemShareDialog } from "../../../sharing/item-share-dialog" // Conditional lazy import - Vite will tree-shake this entire import in production // because import.meta.env.DEV is evaluated at build time @@ -100,6 +102,7 @@ export const Route = createFileRoute( enum DialogKind { NewDirectory = "NewDirectory", UploadFile = "UploadFile", + ItemShare = "ItemShare", } type NewDirectoryDialogData = { @@ -111,7 +114,15 @@ type UploadFileDialogData = { directory: DirectoryInfoWithPath } -type ActiveDialogData = NewDirectoryDialogData | UploadFileDialogData +type ItemShareDialogData = { + kind: DialogKind.ItemShare + item: DirectoryItem +} + +type ActiveDialogData = + | NewDirectoryDialogData + | UploadFileDialogData + | ItemShareDialogData // MARK: atoms const contextMenuTargetItemsAtom = atom([]) @@ -197,6 +208,22 @@ function RouteComponent() { )} + + {(data, setData) => { + return ( + setData(null)} + /> + ) + }} + + {(itemBeingRenamed, setItemBeingRenamed) => { if (!itemBeingRenamed) return null @@ -354,6 +381,7 @@ function DirectoryContentContextMenu({ const account = useAtomValue(currentAccountAtom) const { directory } = useContext(DirectoryPageContext) const search = Route.useSearch() + const setActiveDialogData = useSetAtom(activeDialogDataAtom) const moveToTrashMutation = useAtomValue(moveToTrashMutationAtom) const { mutate: moveToTrash } = useMutation({ @@ -398,6 +426,14 @@ function DirectoryContentContextMenu({ } } + const openShareDialog = () => { + const selectedItems = store.get(contextMenuTargetItemsAtom) + setActiveDialogData({ + kind: DialogKind.ItemShare, + item: selectedItems[0]!, + }) + } + return ( { @@ -414,6 +450,10 @@ function DirectoryContentContextMenu({ Cut + + + Share + { setActiveDialogData({ diff --git a/apps/drive-web/src/routes/shares/$shareId.directories.$directoryId.tsx b/apps/drive-web/src/routes/shares/$shareId.directories.$directoryId.tsx new file mode 100644 index 0000000..14c64c7 --- /dev/null +++ b/apps/drive-web/src/routes/shares/$shareId.directories.$directoryId.tsx @@ -0,0 +1,139 @@ +import { useQuery } from "@tanstack/react-query" +import { createFileRoute } from "@tanstack/react-router" +import { type } from "arktype" +import { atom, useAtomValue } from "jotai" +import { useCallback, useMemo } from "react" +import { currentAccountAtom } from "@/account/account" +import { DirectoryPageContext } from "@/directories/directory-page/context" +import { + DEFAULT_DIRECTORY_CONTENT_ORDER_BY, + DEFAULT_DIRECTORY_CONTENT_ORDER_DIRECTION, +} from "@/directories/directory-page/defaults" +import { DirectoryContentTable } from "@/directories/directory-page/directory-content-table" +import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton" +import { DirectoryPathBreadcrumb } from "@/directories/directory-path-breadcrumb" +import type { FileDragInfo } from "@/files/use-file-drop" +import { + shareDirectoryContentQuery, + shareDirectoryInfoQuery, + shareFileContentUrl, +} from "@/shares/api" +import { + DIRECTORY_CONTENT_ORDER_BY, + DIRECTORY_CONTENT_ORDER_DIRECTION, + type DirectoryContentOrderBy, + type DirectoryContentOrderDirection, +} from "@/vfs/api" +import type { DirectoryInfo, FileInfo } from "@/vfs/vfs" + +const DirectoryContentPageParams = type({ + orderBy: type + .valueOf(DIRECTORY_CONTENT_ORDER_BY) + .default(DEFAULT_DIRECTORY_CONTENT_ORDER_BY), + direction: type + .valueOf(DIRECTORY_CONTENT_ORDER_DIRECTION) + .default(DEFAULT_DIRECTORY_CONTENT_ORDER_DIRECTION), +}) + +const fileDragInfoAtom = atom(null) + +export const Route = createFileRoute( + "/shares/$shareId/directories/$directoryId", +)({ + validateSearch: DirectoryContentPageParams, + component: RouteComponent, +}) + +function RouteComponent() { + const { shareId, directoryId } = Route.useParams() + const search = Route.useSearch() + const navigate = Route.useNavigate() + const account = useAtomValue(currentAccountAtom) + const accountId = account?.id + + const { data: directoryInfo, isLoading: isLoadingDirectoryInfo } = useQuery( + shareDirectoryInfoQuery({ shareId, directoryId, accountId }), + ) + + const directoryUrlById = useCallback( + (targetDirectoryId: string) => + `/shares/${shareId}/directories/${targetDirectoryId}`, + [shareId], + ) + + const directoryUrlFn = useCallback( + (directory: DirectoryInfo) => directoryUrlById(directory.id), + [directoryUrlById], + ) + + const query = useMemo( + () => + shareDirectoryContentQuery({ + shareId, + directoryId, + orderBy: search.orderBy, + direction: search.direction, + limit: 100, + accountId, + }), + [shareId, directoryId, search.orderBy, search.direction, accountId], + ) + + const applySorting = useCallback( + ( + orderBy: DirectoryContentOrderBy, + direction: DirectoryContentOrderDirection, + ) => { + navigate({ + search: { + orderBy, + direction, + }, + }) + }, + [navigate], + ) + + const onTableOpenFile = useCallback( + (file: FileInfo) => { + const url = shareFileContentUrl({ + shareId, + fileId: file.id, + accountId, + }) + window.open(url, "_blank", "noopener,noreferrer") + }, + [shareId, accountId], + ) + + if (isLoadingDirectoryInfo) { + return + } + + if (!directoryInfo) { + return null + } + + return ( + +
+ +
+
+ {}} + onOpenFile={onTableOpenFile} + onSortChange={applySorting} + readOnly + /> +
+
+ ) +} diff --git a/apps/drive-web/src/shares/api.ts b/apps/drive-web/src/shares/api.ts new file mode 100644 index 0000000..c8a124f --- /dev/null +++ b/apps/drive-web/src/shares/api.ts @@ -0,0 +1,174 @@ +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query" +import { fetchApi } from "@/lib/api" +import { + type DirectoryContentOrderBy, + type DirectoryContentOrderDirection, + DirectoryContentResponse, +} from "@/vfs/api" +import { DirectoryInfoWithPath } from "@/vfs/vfs" + +const baseApiUrl = new URL( + import.meta.env.VITE_API_URL ?? + `${location.protocol}//${location.host}/api`, +) + +function buildShareApiUrl(path: string): URL { + const basePath = baseApiUrl.pathname.endsWith("/") + ? baseApiUrl.pathname.slice(0, -1) + : baseApiUrl.pathname + return new URL(`${basePath}${path}`, baseApiUrl) +} + +function buildQueryString(params: Record): string { + const searchParams = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value) { + searchParams.set(key, value) + } + } + const queryString = searchParams.toString() + return queryString ? `?${queryString}` : "" +} + +type ShareDirectoryInfoQueryParams = { + shareId: string + directoryId: string + accountId?: string +} + +export const shareDirectoryInfoQueryKey = ( + shareId: string, + directoryId: string, + accountId?: string, +): readonly unknown[] => [ + "shares", + shareId, + "directories", + directoryId, + "info", + accountId ?? "public", +] + +export function shareDirectoryInfoQuery({ + shareId, + directoryId, + accountId, +}: ShareDirectoryInfoQueryParams) { + const queryString = buildQueryString({ + include: "path", + accountId, + }) + + return queryOptions({ + queryKey: shareDirectoryInfoQueryKey(shareId, directoryId, accountId), + queryFn: () => + fetchApi( + "GET", + `/shares/${shareId}/directories/${directoryId}${queryString}`, + { returns: DirectoryInfoWithPath }, + ).then(([_, result]) => result), + }) +} + +type ShareDirectoryContentQueryParams = { + shareId: string + directoryId: string + orderBy: DirectoryContentOrderBy + direction: DirectoryContentOrderDirection + limit: number + accountId?: string +} + +export const shareDirectoryContentQueryKey = ( + shareId: string, + directoryId: string, + params?: { + orderBy?: DirectoryContentOrderBy + direction?: DirectoryContentOrderDirection + accountId?: string + }, +): readonly unknown[] => [ + "shares", + shareId, + "directories", + directoryId, + "content", + ...(params + ? [ + { + orderBy: params.orderBy, + direction: params.direction, + accountId: params.accountId, + }, + ] + : []), +] + +type ShareDirectoryContentPageParam = { + orderBy: DirectoryContentOrderBy + direction: DirectoryContentOrderDirection + limit: number + cursor: string +} + +export function shareDirectoryContentQuery({ + shareId, + directoryId, + orderBy, + direction, + limit, + accountId, +}: ShareDirectoryContentQueryParams) { + return infiniteQueryOptions({ + queryKey: shareDirectoryContentQueryKey(shareId, directoryId, { + orderBy, + direction, + accountId, + }), + initialPageParam: { + orderBy, + direction, + limit, + cursor: "", + } satisfies ShareDirectoryContentPageParam, + queryFn: ({ pageParam }) => { + const queryString = buildQueryString({ + orderBy: pageParam.orderBy, + dir: pageParam.direction, + limit: String(pageParam.limit), + cursor: pageParam.cursor || undefined, + accountId, + }) + return fetchApi( + "GET", + `/shares/${shareId}/directories/${directoryId}/content${queryString}`, + { returns: DirectoryContentResponse }, + ).then(([_, result]) => result) + }, + getNextPageParam: (lastPage, _pages, lastPageParam) => + lastPage.nextCursor + ? { + ...lastPageParam, + cursor: lastPage.nextCursor, + } + : null, + }) +} + +type ShareFileContentUrlParams = { + shareId: string + fileId: string + accountId?: string +} + +export function shareFileContentUrl({ + shareId, + fileId, + accountId, +}: ShareFileContentUrlParams): string { + const url = buildShareApiUrl(`/shares/${shareId}/files/${fileId}/content`) + if (accountId) { + url.searchParams.set("accountId", accountId) + } + return url.toString() +} diff --git a/apps/drive-web/src/sharing/api.ts b/apps/drive-web/src/sharing/api.ts new file mode 100644 index 0000000..df2c229 --- /dev/null +++ b/apps/drive-web/src/sharing/api.ts @@ -0,0 +1,75 @@ +import { mutationOptions, queryOptions, skipToken } from "@tanstack/react-query" +import { atom } from "jotai" +import { atomFamily } from "jotai/utils" +import { currentAccountAtom } from "@/account/account" +import { fetchApi, Nothing } from "@/lib/api" +import { Share } from "./share" + +export const fileSharesQueryAtom = atomFamily((fileId: string) => + atom((get) => { + const account = get(currentAccountAtom) + return queryOptions({ + queryKey: ["accounts", account?.id, "shares", { fileId }], + queryFn: account + ? () => + fetchApi( + "GET", + `/accounts/${account.id}/files/${fileId}/shares`, + { returns: Share.array() }, + ).then(([_, result]) => result) + : skipToken, + }) + }), +) + +export const directorySharesQueryAtom = atomFamily((directoryId: string) => + atom((get) => { + const account = get(currentAccountAtom) + return queryOptions({ + queryKey: ["accounts", account?.id, "shares", { directoryId }], + queryFn: account + ? () => + fetchApi( + "GET", + `/accounts/${account.id}/directories/${directoryId}/shares`, + { returns: Share.array() }, + ).then(([_, result]) => result) + : skipToken, + }) + }), +) + +export const createShareMutationAtom = atom((get) => + mutationOptions({ + mutationFn: async ({ items }: { items: string[] }) => { + const account = get(currentAccountAtom) + if (!account) throw new Error("No account selected") + + const [_, result] = await fetchApi( + "POST", + `/accounts/${account.id}/shares`, + { + body: JSON.stringify({ items }), + returns: Share, + }, + ) + + return result + }, + }), +) + +export const deleteShareMutationAtom = atom((get) => + mutationOptions({ + mutationFn: async ({ shareId }: { shareId: string }) => { + const account = get(currentAccountAtom) + if (!account) throw new Error("No account selected") + + await fetchApi( + "DELETE", + `/accounts/${account.id}/shares/${shareId}`, + { returns: Nothing }, + ) + }, + }), +) diff --git a/apps/drive-web/src/sharing/item-share-dialog.tsx b/apps/drive-web/src/sharing/item-share-dialog.tsx new file mode 100644 index 0000000..8f45c9d --- /dev/null +++ b/apps/drive-web/src/sharing/item-share-dialog.tsx @@ -0,0 +1,354 @@ +import { useMutation, useQuery } from "@tanstack/react-query" +import { useAtomValue, useStore } from "jotai" +import { + CheckIcon, + CopyIcon, + EllipsisIcon, + LinkIcon, + LockKeyholeIcon, +} from "lucide-react" +import { createContext, useContext, useRef } from "react" +import { + CrossfadeIcon, + type CrossfadeIconHandle, +} from "@/components/crossfade-icon" +import { Button } from "@/components/ui/button" +import { ButtonGroup } from "@/components/ui/button-group" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { copyToClipboardMutation } from "@/lib/clipboard" +import { + createShareMutationAtom, + deleteShareMutationAtom, + directorySharesQueryAtom, + fileSharesQueryAtom, +} from "@/sharing/api" +import type { DirectoryItem } from "@/vfs/vfs" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "../components/ui/tooltip" +import type { Share } from "./share" + +type ItemShareDialogProps = { + item: DirectoryItem | null + open: boolean + onClose: () => void +} + +const ItemShareDialogContext = createContext<{ + item: DirectoryItem +}>( + null as unknown as { + item: DirectoryItem + }, +) + +export function ItemShareDialog({ item, open, onClose }: ItemShareDialogProps) { + let description: string + switch (item?.kind) { + case "file": + description = "Configure external access to this file." + break + case "directory": + description = "Configure external access to this directory." + break + default: + description = "Configure external access to this item." + break + } + + return ( + + + + Share {item?.name} + {description} + + {item && ( + +
+ +
+
+ )} +
+
+ ) +} + +function PublicAccessSection({ item }: { item: DirectoryItem }) { + const fileSharesQuery = useAtomValue(fileSharesQueryAtom(item.id)) + const directorySharesQuery = useAtomValue(directorySharesQueryAtom(item.id)) + + const { data: fileShares, isLoading: isLoadingFileShares } = useQuery({ + ...fileSharesQuery, + enabled: item.kind === "file", + }) + const { data: directoryShares, isLoading: isLoadingDirectoryShares } = + useQuery({ + ...directorySharesQuery, + enabled: item.kind === "directory", + }) + + let shares: Share[] = [] + if (fileShares) { + shares = fileShares + } else if (directoryShares) { + shares = directoryShares + } + + let content: React.ReactNode = null + if (isLoadingFileShares || isLoadingDirectoryShares) { + content =
Loading...
+ } else if (shares.length === 0) { + content = ( +
+
+ +
+
+

No share link created

+

+ Only you can access this item. +

+
+ +
+ ) + } else { + content = ( +
    + {shares.map((share) => ( + + ))} +
+ ) + } + + return ( +
+

Public Access

+ {content} +
+ ) +} + +function ShareLinkListItem({ share }: { share: Share }) { + const { item } = useContext(ItemShareDialogContext) + const copyLinkButtonRef = useRef(null) + const copyIconRef = useRef(null) + + const { mutate: copyToClipboard } = useMutation({ + ...copyToClipboardMutation, + onSuccess: () => { + copyIconRef.current?.trigger() + }, + }) + + const copyItemShareLinkToClipboard = () => { + let link: string + switch (item.kind) { + case "file": + link = `${window.location.origin}/shares/${share.id}/files/${item.id}` + break + case "directory": + link = `${window.location.origin}/shares/${share.id}/directories/${item.id}` + break + default: + link = "" + break + } + if (link) { + copyToClipboard(link) + } + } + + return ( +
  • + {/** biome-ignore lint/a11y/noStaticElementInteractions: this is strictly for convenience. the normal copy link button is still accessible. */} + {/** biome-ignore lint/a11y/useKeyWithClickEvents: this is strictly for convenience. the normal copy link button is still accessible. */} +
    { + copyLinkButtonRef.current?.click() + }} + > + +
    + {/** biome-ignore lint/a11y/noStaticElementInteractions: this is strictly for convenience. the normal copy link button is still accessible. */} + {/** biome-ignore lint/a11y/useKeyWithClickEvents: this is strictly for convenience. the normal copy link button is still accessible. */} +
    { + copyLinkButtonRef.current?.click() + }} + > +

    Share link

    +

    + {share.expiresAt + ? `Expires at ${share.expiresAt}` + : "Never expires"} +

    +
    + + + + + + Copy share link + + + + + + + + +
  • + ) +} + +function ShareLinkOptionsMenu({ share }: { share: Share }) { + const { item } = useContext(ItemShareDialogContext) + const store = useStore() + + const { mutate: deleteShare } = useMutation({ + ...useAtomValue(deleteShareMutationAtom), + onMutate: ({ shareId }, { client }) => { + let queryKey: readonly unknown[] | null + switch (item.kind) { + case "file": + queryKey = store.get(fileSharesQueryAtom(item.id)).queryKey + break + case "directory": + queryKey = store.get( + directorySharesQueryAtom(item.id), + ).queryKey + break + default: + queryKey = null + break + } + if (queryKey) { + const prevShares = client.getQueryData(queryKey) + client.setQueryData( + queryKey, + (old) => old?.filter((s) => s.id !== shareId) ?? old, + ) + return { queryKey, prevShares } + } + return null + }, + onSuccess: (_data, _vars, mutateResult, { client }) => { + if (mutateResult) { + client.invalidateQueries({ + queryKey: mutateResult.queryKey, + }) + } + }, + onError: (error, _vars, mutateResult, { client }) => { + console.error(error) + if (mutateResult) { + client.setQueryData( + mutateResult.queryKey, + mutateResult.prevShares, + ) + } + }, + }) + + return ( + + + Rename link + Set expiration + { + deleteShare({ shareId: share.id }) + }} + > + Delete link + + + + ) +} + +function CreateShareLinkButton() { + const { item } = useContext(ItemShareDialogContext) + const store = useStore() + const { mutate: createShare, isPending: isCreatingShare } = useMutation({ + ...useAtomValue(createShareMutationAtom), + onSuccess: (_createdShare, _vars, _, { client }) => { + let queryKey: readonly unknown[] | null + switch (item.kind) { + case "file": + queryKey = store.get(fileSharesQueryAtom(item.id)).queryKey + break + case "directory": + queryKey = store.get( + directorySharesQueryAtom(item.id), + ).queryKey + break + default: + queryKey = null + break + } + if (queryKey) { + client.invalidateQueries({ + queryKey, + }) + } + }, + }) + + return ( + + ) +} diff --git a/apps/drive-web/src/sharing/share.ts b/apps/drive-web/src/sharing/share.ts new file mode 100644 index 0000000..f9aa0c1 --- /dev/null +++ b/apps/drive-web/src/sharing/share.ts @@ -0,0 +1,9 @@ +import { type } from "arktype" + +export const Share = type({ + id: "string", + expiresAt: "string.date.iso.parse | null", + createdAt: "string.date.iso.parse", + updatedAt: "string.date.iso.parse", +}) +export type Share = typeof Share.infer diff --git a/apps/drive-web/src/vfs/api.ts b/apps/drive-web/src/vfs/api.ts index f17a321..81c136b 100644 --- a/apps/drive-web/src/vfs/api.ts +++ b/apps/drive-web/src/vfs/api.ts @@ -10,7 +10,6 @@ import { atom } from "jotai" import { atomFamily } from "jotai/utils" import { currentAccountAtom } from "@/account/account" import { fetchApi } from "@/lib/api" -import type { AtomValue } from "@/lib/jotai-utils" import { DirectoryContent, DirectoryInfo, diff --git a/bun.lock b/bun.lock index 9182a2f..72b272e 100644 --- a/bun.lock +++ b/bun.lock @@ -43,8 +43,8 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", - "@radix-ui/react-separator": "^1.1.7", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.87.4", "@tanstack/react-router": "^1.131.41", @@ -380,9 +380,9 @@ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], - "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], @@ -864,10 +864,22 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@drexa/auth/@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], + "@drexa/auth/@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], "@fileone/web/arktype": ["arktype@2.1.28", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.4" } }, "sha512-LVZqXl2zWRpNFnbITrtFmqeqNkPPo+KemuzbGSY6jvJwCb4v8NsDzrWOLHnQgWl26TkJeWWcUNUeBpq2Mst1/Q=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], @@ -902,7 +914,7 @@ "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], - "@drexa/auth/@types/bun/bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + "@drexa/auth/@types/bun/bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], "@fileone/web/arktype/@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="],