mirror of
https://github.com/get-drexa/drive.git
synced 2026-02-02 19:31:17 +00:00
feat: initial share dialog impl
This commit is contained in:
@@ -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",
|
||||
|
||||
84
apps/drive-web/src/components/crossfade-icon.tsx
Normal file
84
apps/drive-web/src/components/crossfade-icon.tsx
Normal file
@@ -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<CrossfadeIconHandle>
|
||||
}
|
||||
|
||||
export type CrossfadeIconHandle = {
|
||||
trigger: () => void
|
||||
}
|
||||
|
||||
export function CrossfadeIcon({
|
||||
from,
|
||||
to,
|
||||
active = false,
|
||||
className,
|
||||
ref,
|
||||
}: CrossfadeIconProps) {
|
||||
const [forcedActive, setForcedActive] = useState(false)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div className={cn("relative grid place-items-center", className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"col-start-1 row-start-1 grid place-items-center transition-all duration-200 ease-out",
|
||||
isActive
|
||||
? "opacity-0 scale-50 blur-sm"
|
||||
: "opacity-100 scale-100 blur-0",
|
||||
)}
|
||||
>
|
||||
{from}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"col-start-1 row-start-1 grid place-items-center transition-all duration-200 ease-out",
|
||||
isActive
|
||||
? "opacity-100 scale-100 blur-0"
|
||||
: "opacity-0 scale-0 blur-sm",
|
||||
)}
|
||||
>
|
||||
{to}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
apps/drive-web/src/components/ui/button-group.tsx
Normal file
83
apps/drive-web/src/components/ui/button-group.tsx
Normal file
@@ -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<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export type DirectoryContentTableProps = {
|
||||
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
|
||||
loadingComponent?: React.ReactNode
|
||||
debugBanner?: React.ReactNode
|
||||
readOnly?: boolean
|
||||
|
||||
onContextMenu: (
|
||||
row: Row<DirectoryItem>,
|
||||
@@ -98,84 +99,97 @@ function formatFileSize(bytes: number): string {
|
||||
function useTableColumns(
|
||||
onOpenFile: (file: FileInfo) => void,
|
||||
directoryUrlFn: (directory: DirectoryInfo) => string,
|
||||
readOnly: boolean,
|
||||
): ColumnDef<DirectoryItem>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => {
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onCheckedChange={row.getToggleSelectedHandler()}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 24,
|
||||
},
|
||||
{
|
||||
header: () => <NameHeaderCell />,
|
||||
accessorKey: "doc.name",
|
||||
cell: ({ row }) => {
|
||||
switch (row.original.kind) {
|
||||
case "file":
|
||||
return (
|
||||
<FileNameCell
|
||||
file={row.original}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)
|
||||
case "directory":
|
||||
return (
|
||||
<DirectoryNameCell
|
||||
directory={row.original}
|
||||
directoryUrlFn={directoryUrlFn}
|
||||
/>
|
||||
)
|
||||
}
|
||||
() => {
|
||||
const columns: ColumnDef<DirectoryItem>[] = []
|
||||
if (!readOnly) {
|
||||
columns.push({
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => {
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onCheckedChange={row.getToggleSelectedHandler()}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 24,
|
||||
})
|
||||
}
|
||||
|
||||
columns.push(
|
||||
{
|
||||
header: () => <NameHeaderCell />,
|
||||
accessorKey: "doc.name",
|
||||
cell: ({ row }) => {
|
||||
switch (row.original.kind) {
|
||||
case "file":
|
||||
return (
|
||||
<FileNameCell
|
||||
file={row.original}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)
|
||||
case "directory":
|
||||
return (
|
||||
<DirectoryNameCell
|
||||
directory={row.original}
|
||||
directoryUrlFn={directoryUrlFn}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
size: 1000,
|
||||
},
|
||||
size: 1000,
|
||||
},
|
||||
{
|
||||
header: "Size",
|
||||
accessorKey: "size",
|
||||
cell: ({ row }) => {
|
||||
switch (row.original.kind) {
|
||||
case "file":
|
||||
return (
|
||||
<div>{formatFileSize(row.original.size)}</div>
|
||||
)
|
||||
case "directory":
|
||||
return <div className="font-mono">-</div>
|
||||
}
|
||||
{
|
||||
header: "Size",
|
||||
accessorKey: "size",
|
||||
cell: ({ row }) => {
|
||||
switch (row.original.kind) {
|
||||
case "file":
|
||||
return (
|
||||
<div>
|
||||
{formatFileSize(row.original.size)}
|
||||
</div>
|
||||
)
|
||||
case "directory":
|
||||
return <div className="font-mono">-</div>
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Created At",
|
||||
accessorKey: "createdAt",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div>
|
||||
{new Date(row.original.createdAt).toLocaleString()}
|
||||
</div>
|
||||
)
|
||||
{
|
||||
header: "Created At",
|
||||
accessorKey: "createdAt",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div>
|
||||
{new Date(
|
||||
row.original.createdAt,
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[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<DirectoryItem>,
|
||||
_event: React.MouseEvent,
|
||||
) => {
|
||||
if (readOnly) return
|
||||
if (!row.getIsSelected()) {
|
||||
selectRow(row)
|
||||
}
|
||||
@@ -305,6 +322,7 @@ export function DirectoryContentTable({
|
||||
}
|
||||
|
||||
const selectRow = (row: Row<DirectoryItem>) => {
|
||||
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<DirectoryItem>) => {
|
||||
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)
|
||||
) : (
|
||||
<NoResultsRow />
|
||||
<NoResultsRow
|
||||
colSpan={table.getAllLeafColumns().length}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -412,10 +433,10 @@ export function DirectoryContentTable({
|
||||
)
|
||||
}
|
||||
|
||||
function NoResultsRow() {
|
||||
function NoResultsRow({ colSpan }: { colSpan: number }) {
|
||||
return (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={4} className="text-center">
|
||||
<TableCell colSpan={colSpan} className="text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -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<FileDragInfo | null>
|
||||
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 (
|
||||
<TableRow
|
||||
draggable
|
||||
draggable={!readOnly}
|
||||
ref={ref}
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
|
||||
@@ -14,6 +14,9 @@ export type ApiRoute =
|
||||
| `/accounts/${string}/directories`
|
||||
| `/accounts/${string}/directories/${string}`
|
||||
| `/accounts/${string}/directories/${string}/content`
|
||||
| `/shares/${string}`
|
||||
| `/shares/${string}/directories${string}`
|
||||
| `/shares/${string}/files${string}`
|
||||
| "/users/me"
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
|
||||
@@ -62,10 +65,14 @@ export async function fetchApi<Schema extends type.Any>(
|
||||
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]
|
||||
}
|
||||
|
||||
36
apps/drive-web/src/lib/clipboard.ts
Normal file
36
apps/drive-web/src/lib/clipboard.ts
Normal file
@@ -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")
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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<DirectoryItem[]>([])
|
||||
@@ -197,6 +208,22 @@ function RouteComponent() {
|
||||
)}
|
||||
</WithAtom>
|
||||
|
||||
<WithAtom atom={activeDialogDataAtom}>
|
||||
{(data, setData) => {
|
||||
return (
|
||||
<ItemShareDialog
|
||||
item={
|
||||
data?.kind === DialogKind.ItemShare
|
||||
? data.item
|
||||
: null
|
||||
}
|
||||
open={data?.kind === DialogKind.ItemShare}
|
||||
onClose={() => setData(null)}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</WithAtom>
|
||||
|
||||
<WithAtom atom={itemBeingRenamedAtom}>
|
||||
{(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 (
|
||||
<ContextMenu
|
||||
onOpenChange={(open) => {
|
||||
@@ -414,6 +450,10 @@ function DirectoryContentContextMenu({
|
||||
<ScissorsIcon />
|
||||
Cut
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={openShareDialog}>
|
||||
<Share2Icon />
|
||||
Share
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
@@ -492,6 +532,7 @@ function UploadFileButton() {
|
||||
function NewDirectoryItemDropdown() {
|
||||
const [activeDialogData, setActiveDialogData] =
|
||||
useAtom(activeDialogDataAtom)
|
||||
const store = useStore()
|
||||
|
||||
const addNewDirectory = () => {
|
||||
setActiveDialogData({
|
||||
|
||||
@@ -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<FileDragInfo | null>(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 <DirectoryPageSkeleton />
|
||||
}
|
||||
|
||||
if (!directoryInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DirectoryPageContext value={{ directory: directoryInfo }}>
|
||||
<header className="flex py-2 shrink-0 items-center gap-2 border-b px-4 w-full">
|
||||
<DirectoryPathBreadcrumb
|
||||
directory={directoryInfo}
|
||||
rootLabel="Shared"
|
||||
directoryUrlFn={directoryUrlById}
|
||||
/>
|
||||
</header>
|
||||
<div className="w-full min-h-0">
|
||||
<DirectoryContentTable
|
||||
query={query}
|
||||
directoryUrlFn={directoryUrlFn}
|
||||
fileDragInfoAtom={fileDragInfoAtom}
|
||||
onContextMenu={() => {}}
|
||||
onOpenFile={onTableOpenFile}
|
||||
onSortChange={applySorting}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</DirectoryPageContext>
|
||||
)
|
||||
}
|
||||
174
apps/drive-web/src/shares/api.ts
Normal file
174
apps/drive-web/src/shares/api.ts
Normal file
@@ -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, string | undefined>): 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()
|
||||
}
|
||||
75
apps/drive-web/src/sharing/api.ts
Normal file
75
apps/drive-web/src/sharing/api.ts
Normal file
@@ -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 },
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
354
apps/drive-web/src/sharing/item-share-dialog.tsx
Normal file
354
apps/drive-web/src/sharing/item-share-dialog.tsx
Normal file
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share {item?.name}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{item && (
|
||||
<ItemShareDialogContext value={{ item }}>
|
||||
<div>
|
||||
<PublicAccessSection item={item} />
|
||||
</div>
|
||||
</ItemShareDialogContext>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
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 = <div>Loading...</div>
|
||||
} else if (shares.length === 0) {
|
||||
content = (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted rounded-md p-1.5 translate-y-px border border-border shadow-xs">
|
||||
<LockKeyholeIcon size={16} />
|
||||
</div>
|
||||
<div className="flex grow flex-col text-sm">
|
||||
<p className="font-medium">No share link created</p>
|
||||
<p className="text-muted-foreground">
|
||||
Only you can access this item.
|
||||
</p>
|
||||
</div>
|
||||
<CreateShareLinkButton />
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
content = (
|
||||
<ul>
|
||||
{shares.map((share) => (
|
||||
<ShareLinkListItem key={share.id} share={share} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-md mb-2">Public Access</p>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShareLinkListItem({ share }: { share: Share }) {
|
||||
const { item } = useContext(ItemShareDialogContext)
|
||||
const copyLinkButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const copyIconRef = useRef<CrossfadeIconHandle>(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 (
|
||||
<li key={share.id} className="group flex items-center gap-3">
|
||||
{/** 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. */}
|
||||
<div
|
||||
className="bg-muted rounded-md p-1.5 translate-y-px border border-border shadow-xs"
|
||||
onClick={() => {
|
||||
copyLinkButtonRef.current?.click()
|
||||
}}
|
||||
>
|
||||
<LinkIcon size={16} />
|
||||
</div>
|
||||
{/** 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. */}
|
||||
<div
|
||||
className="flex grow flex-col text-sm"
|
||||
onClick={() => {
|
||||
copyLinkButtonRef.current?.click()
|
||||
}}
|
||||
>
|
||||
<p>Share link</p>
|
||||
<p className="text-muted-foreground">
|
||||
{share.expiresAt
|
||||
? `Expires at ${share.expiresAt}`
|
||||
: "Never expires"}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonGroup>
|
||||
<Tooltip defaultOpen={false} delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
ref={copyLinkButtonRef}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="group-[&:hover:not(:has(.share-options-trigger:hover))]:bg-accent group-[&:hover:not(:has(.share-options-trigger:hover))]:text-accent-foreground dark:group-[&:hover:not(:has(.share-options-trigger:hover))]:bg-input/50"
|
||||
onClick={copyItemShareLinkToClipboard}
|
||||
>
|
||||
<CrossfadeIcon
|
||||
ref={copyIconRef}
|
||||
from={<CopyIcon />}
|
||||
to={<CheckIcon />}
|
||||
/>
|
||||
<span className="sr-only">Copy share link</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy share link</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="share-options-trigger aria-expanded:bg-accent!"
|
||||
>
|
||||
<EllipsisIcon />
|
||||
<span className="sr-only">Share link options</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<ShareLinkOptionsMenu share={share} />
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
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<Share[]>(queryKey)
|
||||
client.setQueryData<Share[]>(
|
||||
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<Share[]>(
|
||||
mutateResult.queryKey,
|
||||
mutateResult.prevShares,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>Rename link</DropdownMenuItem>
|
||||
<DropdownMenuItem>Set expiration</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
deleteShare({ shareId: share.id })
|
||||
}}
|
||||
>
|
||||
Delete link
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={isCreatingShare}
|
||||
disabled={isCreatingShare}
|
||||
onClick={() => {
|
||||
createShare({ items: [item.id] })
|
||||
}}
|
||||
>
|
||||
Create share link
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
9
apps/drive-web/src/sharing/share.ts
Normal file
9
apps/drive-web/src/sharing/share.ts
Normal file
@@ -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
|
||||
@@ -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,
|
||||
|
||||
24
bun.lock
24
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=="],
|
||||
|
||||
|
||||
Reference in New Issue
Block a user