mirror of
https://github.com/get-drexa/drive.git
synced 2026-02-02 13:41:18 +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-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.87.4",
|
"@tanstack/react-query": "^5.87.4",
|
||||||
"@tanstack/react-router": "^1.131.41",
|
"@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>
|
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
|
||||||
loadingComponent?: React.ReactNode
|
loadingComponent?: React.ReactNode
|
||||||
debugBanner?: React.ReactNode
|
debugBanner?: React.ReactNode
|
||||||
|
readOnly?: boolean
|
||||||
|
|
||||||
onContextMenu: (
|
onContextMenu: (
|
||||||
row: Row<DirectoryItem>,
|
row: Row<DirectoryItem>,
|
||||||
@@ -98,10 +99,13 @@ function formatFileSize(bytes: number): string {
|
|||||||
function useTableColumns(
|
function useTableColumns(
|
||||||
onOpenFile: (file: FileInfo) => void,
|
onOpenFile: (file: FileInfo) => void,
|
||||||
directoryUrlFn: (directory: DirectoryInfo) => string,
|
directoryUrlFn: (directory: DirectoryInfo) => string,
|
||||||
|
readOnly: boolean,
|
||||||
): ColumnDef<DirectoryItem>[] {
|
): ColumnDef<DirectoryItem>[] {
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => [
|
() => {
|
||||||
{
|
const columns: ColumnDef<DirectoryItem>[] = []
|
||||||
|
if (!readOnly) {
|
||||||
|
columns.push({
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -125,7 +129,10 @@ function useTableColumns(
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
size: 24,
|
size: 24,
|
||||||
},
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
columns.push(
|
||||||
{
|
{
|
||||||
header: () => <NameHeaderCell />,
|
header: () => <NameHeaderCell />,
|
||||||
accessorKey: "doc.name",
|
accessorKey: "doc.name",
|
||||||
@@ -156,7 +163,9 @@ function useTableColumns(
|
|||||||
switch (row.original.kind) {
|
switch (row.original.kind) {
|
||||||
case "file":
|
case "file":
|
||||||
return (
|
return (
|
||||||
<div>{formatFileSize(row.original.size)}</div>
|
<div>
|
||||||
|
{formatFileSize(row.original.size)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
case "directory":
|
case "directory":
|
||||||
return <div className="font-mono">-</div>
|
return <div className="font-mono">-</div>
|
||||||
@@ -169,13 +178,18 @@ function useTableColumns(
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{new Date(row.original.createdAt).toLocaleString()}
|
{new Date(
|
||||||
|
row.original.createdAt,
|
||||||
|
).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
)
|
||||||
[onOpenFile, directoryUrlFn],
|
|
||||||
|
return columns
|
||||||
|
},
|
||||||
|
[onOpenFile, directoryUrlFn, readOnly],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +202,7 @@ export function DirectoryContentTable({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
|
readOnly = false,
|
||||||
}: DirectoryContentTableProps) {
|
}: DirectoryContentTableProps) {
|
||||||
const {
|
const {
|
||||||
data: directoryContent,
|
data: directoryContent,
|
||||||
@@ -221,6 +236,7 @@ export function DirectoryContentTable({
|
|||||||
items: import("@/vfs/vfs").DirectoryItem[],
|
items: import("@/vfs/vfs").DirectoryItem[],
|
||||||
targetDirectory: import("@/vfs/vfs").DirectoryInfo | string,
|
targetDirectory: import("@/vfs/vfs").DirectoryInfo | string,
|
||||||
) => {
|
) => {
|
||||||
|
if (readOnly) return
|
||||||
moveDroppedItems({
|
moveDroppedItems({
|
||||||
targetDirectory,
|
targetDirectory,
|
||||||
items,
|
items,
|
||||||
@@ -235,10 +251,10 @@ export function DirectoryContentTable({
|
|||||||
) || [],
|
) || [],
|
||||||
[directoryContent],
|
[directoryContent],
|
||||||
),
|
),
|
||||||
columns: useTableColumns(onOpenFile, directoryUrlFn),
|
columns: useTableColumns(onOpenFile, directoryUrlFn, readOnly),
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
enableRowSelection: true,
|
enableRowSelection: !readOnly,
|
||||||
enableGlobalFilter: true,
|
enableGlobalFilter: true,
|
||||||
globalFilterFn: (
|
globalFilterFn: (
|
||||||
row,
|
row,
|
||||||
@@ -298,6 +314,7 @@ export function DirectoryContentTable({
|
|||||||
row: Row<DirectoryItem>,
|
row: Row<DirectoryItem>,
|
||||||
_event: React.MouseEvent,
|
_event: React.MouseEvent,
|
||||||
) => {
|
) => {
|
||||||
|
if (readOnly) return
|
||||||
if (!row.getIsSelected()) {
|
if (!row.getIsSelected()) {
|
||||||
selectRow(row)
|
selectRow(row)
|
||||||
}
|
}
|
||||||
@@ -305,6 +322,7 @@ export function DirectoryContentTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectRow = (row: Row<DirectoryItem>) => {
|
const selectRow = (row: Row<DirectoryItem>) => {
|
||||||
|
if (readOnly) return
|
||||||
const keyboardModifiers = store.get(keyboardModifierAtom)
|
const keyboardModifiers = store.get(keyboardModifierAtom)
|
||||||
const isMultiSelectMode = isControlOrCommandKeyActive(keyboardModifiers)
|
const isMultiSelectMode = isControlOrCommandKeyActive(keyboardModifiers)
|
||||||
const isRowSelected = row.getIsSelected()
|
const isRowSelected = row.getIsSelected()
|
||||||
@@ -329,7 +347,7 @@ export function DirectoryContentTable({
|
|||||||
const handleRowDoubleClick = (row: Row<DirectoryItem>) => {
|
const handleRowDoubleClick = (row: Row<DirectoryItem>) => {
|
||||||
if (row.original.kind === "directory") {
|
if (row.original.kind === "directory") {
|
||||||
navigate({
|
navigate({
|
||||||
to: `/directories/${row.original.id}`,
|
to: directoryUrlFn(row.original),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,6 +367,7 @@ export function DirectoryContentTable({
|
|||||||
table={table}
|
table={table}
|
||||||
row={row}
|
row={row}
|
||||||
onClick={() => selectRow(row)}
|
onClick={() => selectRow(row)}
|
||||||
|
readOnly={readOnly}
|
||||||
fileDragInfoAtom={fileDragInfoAtom}
|
fileDragInfoAtom={fileDragInfoAtom}
|
||||||
onContextMenu={(e) => handleRowContextMenu(row, e)}
|
onContextMenu={(e) => handleRowContextMenu(row, e)}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
@@ -402,7 +421,9 @@ export function DirectoryContentTable({
|
|||||||
{rows.length > 0 ? (
|
{rows.length > 0 ? (
|
||||||
virtualItems.map(renderRow)
|
virtualItems.map(renderRow)
|
||||||
) : (
|
) : (
|
||||||
<NoResultsRow />
|
<NoResultsRow
|
||||||
|
colSpan={table.getAllLeafColumns().length}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -412,10 +433,10 @@ export function DirectoryContentTable({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoResultsRow() {
|
function NoResultsRow({ colSpan }: { colSpan: number }) {
|
||||||
return (
|
return (
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableCell colSpan={4} className="text-center">
|
<TableCell colSpan={colSpan} className="text-center">
|
||||||
No results.
|
No results.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -428,6 +449,7 @@ function FileItemRow({
|
|||||||
onClick,
|
onClick,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
onDoubleClick,
|
onDoubleClick,
|
||||||
|
readOnly,
|
||||||
fileDragInfoAtom,
|
fileDragInfoAtom,
|
||||||
onFileDrop,
|
onFileDrop,
|
||||||
...rowProps
|
...rowProps
|
||||||
@@ -437,6 +459,7 @@ function FileItemRow({
|
|||||||
onClick: () => void
|
onClick: () => void
|
||||||
onContextMenu: (e: React.MouseEvent) => void
|
onContextMenu: (e: React.MouseEvent) => void
|
||||||
onDoubleClick: () => void
|
onDoubleClick: () => void
|
||||||
|
readOnly: boolean
|
||||||
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
|
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
|
||||||
onFileDrop: (
|
onFileDrop: (
|
||||||
items: import("@/vfs/vfs").DirectoryItem[],
|
items: import("@/vfs/vfs").DirectoryItem[],
|
||||||
@@ -447,13 +470,14 @@ function FileItemRow({
|
|||||||
const setFileDragInfo = useSetAtom(fileDragInfoAtom)
|
const setFileDragInfo = useSetAtom(fileDragInfoAtom)
|
||||||
|
|
||||||
const { isDraggedOver, dropHandlers } = useFileDrop({
|
const { isDraggedOver, dropHandlers } = useFileDrop({
|
||||||
enabled: row.original.kind === "directory",
|
enabled: !readOnly && row.original.kind === "directory",
|
||||||
destDir: row.original.kind === "directory" ? row.original : undefined,
|
destDir: row.original.kind === "directory" ? row.original : undefined,
|
||||||
dragInfoAtom: fileDragInfoAtom,
|
dragInfoAtom: fileDragInfoAtom,
|
||||||
onDrop: onFileDrop,
|
onDrop: onFileDrop,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDragStart = (_e: React.DragEvent) => {
|
const handleDragStart = (_e: React.DragEvent) => {
|
||||||
|
if (readOnly) return
|
||||||
let draggedItems: DirectoryItem[]
|
let draggedItems: DirectoryItem[]
|
||||||
// drag all selections, but only if the currently dragged row is also selected
|
// drag all selections, but only if the currently dragged row is also selected
|
||||||
if (row.getIsSelected()) {
|
if (row.getIsSelected()) {
|
||||||
@@ -479,12 +503,13 @@ function FileItemRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
|
if (readOnly) return
|
||||||
setFileDragInfo(null)
|
setFileDragInfo(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
draggable
|
draggable={!readOnly}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export type ApiRoute =
|
|||||||
| `/accounts/${string}/directories`
|
| `/accounts/${string}/directories`
|
||||||
| `/accounts/${string}/directories/${string}`
|
| `/accounts/${string}/directories/${string}`
|
||||||
| `/accounts/${string}/directories/${string}/content`
|
| `/accounts/${string}/directories/${string}/content`
|
||||||
|
| `/shares/${string}`
|
||||||
|
| `/shares/${string}/directories${string}`
|
||||||
|
| `/shares/${string}/files${string}`
|
||||||
| "/users/me"
|
| "/users/me"
|
||||||
|
|
||||||
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
|
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
|
||||||
@@ -62,6 +65,8 @@ export async function fetchApi<Schema extends type.Any>(
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ApiError(response.status, await response.text())
|
throw new ApiError(response.status, await response.text())
|
||||||
}
|
}
|
||||||
|
// @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 body = await response.json()
|
||||||
const result = init.returns(body)
|
const result = init.returns(body)
|
||||||
if (result instanceof type.errors) {
|
if (result instanceof type.errors) {
|
||||||
@@ -69,3 +74,5 @@ export async function fetchApi<Schema extends type.Any>(
|
|||||||
}
|
}
|
||||||
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 LoginCallbackRouteImport } from './routes/login_.callback'
|
||||||
import { Route as AuthenticatedSidebarLayoutRouteImport } from './routes/_authenticated/_sidebar-layout'
|
import { Route as AuthenticatedSidebarLayoutRouteImport } from './routes/_authenticated/_sidebar-layout'
|
||||||
import { Route as AuthenticatedSidebarLayoutHomeRouteImport } from './routes/_authenticated/_sidebar-layout/home'
|
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'
|
import { Route as AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/directories.$directoryId'
|
||||||
|
|
||||||
const SignUpRoute = SignUpRouteImport.update({
|
const SignUpRoute = SignUpRouteImport.update({
|
||||||
@@ -53,6 +54,12 @@ const AuthenticatedSidebarLayoutHomeRoute =
|
|||||||
path: '/home',
|
path: '/home',
|
||||||
getParentRoute: () => AuthenticatedSidebarLayoutRoute,
|
getParentRoute: () => AuthenticatedSidebarLayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const SharesShareIdDirectoriesDirectoryIdRoute =
|
||||||
|
SharesShareIdDirectoriesDirectoryIdRouteImport.update({
|
||||||
|
id: '/shares/$shareId/directories/$directoryId',
|
||||||
|
path: '/shares/$shareId/directories/$directoryId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute =
|
const AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute =
|
||||||
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport.update({
|
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport.update({
|
||||||
id: '/directories/$directoryId',
|
id: '/directories/$directoryId',
|
||||||
@@ -67,6 +74,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof AuthenticatedIndexRoute
|
'/': typeof AuthenticatedIndexRoute
|
||||||
'/home': typeof AuthenticatedSidebarLayoutHomeRoute
|
'/home': typeof AuthenticatedSidebarLayoutHomeRoute
|
||||||
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
|
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
|
||||||
|
'/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
@@ -75,6 +83,7 @@ export interface FileRoutesByTo {
|
|||||||
'/': typeof AuthenticatedIndexRoute
|
'/': typeof AuthenticatedIndexRoute
|
||||||
'/home': typeof AuthenticatedSidebarLayoutHomeRoute
|
'/home': typeof AuthenticatedSidebarLayoutHomeRoute
|
||||||
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
|
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
|
||||||
|
'/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -86,6 +95,7 @@ export interface FileRoutesById {
|
|||||||
'/_authenticated/': typeof AuthenticatedIndexRoute
|
'/_authenticated/': typeof AuthenticatedIndexRoute
|
||||||
'/_authenticated/_sidebar-layout/home': typeof AuthenticatedSidebarLayoutHomeRoute
|
'/_authenticated/_sidebar-layout/home': typeof AuthenticatedSidebarLayoutHomeRoute
|
||||||
'/_authenticated/_sidebar-layout/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
|
'/_authenticated/_sidebar-layout/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
|
||||||
|
'/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -96,6 +106,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/home'
|
| '/home'
|
||||||
| '/directories/$directoryId'
|
| '/directories/$directoryId'
|
||||||
|
| '/shares/$shareId/directories/$directoryId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/login'
|
| '/login'
|
||||||
@@ -104,6 +115,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/home'
|
| '/home'
|
||||||
| '/directories/$directoryId'
|
| '/directories/$directoryId'
|
||||||
|
| '/shares/$shareId/directories/$directoryId'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_authenticated'
|
| '/_authenticated'
|
||||||
@@ -114,6 +126,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_authenticated/'
|
| '/_authenticated/'
|
||||||
| '/_authenticated/_sidebar-layout/home'
|
| '/_authenticated/_sidebar-layout/home'
|
||||||
| '/_authenticated/_sidebar-layout/directories/$directoryId'
|
| '/_authenticated/_sidebar-layout/directories/$directoryId'
|
||||||
|
| '/shares/$shareId/directories/$directoryId'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -121,6 +134,7 @@ export interface RootRouteChildren {
|
|||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
SignUpRoute: typeof SignUpRoute
|
SignUpRoute: typeof SignUpRoute
|
||||||
LoginCallbackRoute: typeof LoginCallbackRoute
|
LoginCallbackRoute: typeof LoginCallbackRoute
|
||||||
|
SharesShareIdDirectoriesDirectoryIdRoute: typeof SharesShareIdDirectoriesDirectoryIdRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -174,6 +188,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedSidebarLayoutHomeRouteImport
|
preLoaderRoute: typeof AuthenticatedSidebarLayoutHomeRouteImport
|
||||||
parentRoute: typeof AuthenticatedSidebarLayoutRoute
|
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': {
|
'/_authenticated/_sidebar-layout/directories/$directoryId': {
|
||||||
id: '/_authenticated/_sidebar-layout/directories/$directoryId'
|
id: '/_authenticated/_sidebar-layout/directories/$directoryId'
|
||||||
path: '/directories/$directoryId'
|
path: '/directories/$directoryId'
|
||||||
@@ -220,6 +241,8 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
SignUpRoute: SignUpRoute,
|
SignUpRoute: SignUpRoute,
|
||||||
LoginCallbackRoute: LoginCallbackRoute,
|
LoginCallbackRoute: LoginCallbackRoute,
|
||||||
|
SharesShareIdDirectoriesDirectoryIdRoute:
|
||||||
|
SharesShareIdDirectoriesDirectoryIdRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
ScissorsIcon,
|
ScissorsIcon,
|
||||||
|
Share2Icon,
|
||||||
TextCursorInputIcon,
|
TextCursorInputIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -68,6 +69,7 @@ import type {
|
|||||||
DirectoryItem,
|
DirectoryItem,
|
||||||
FileInfo,
|
FileInfo,
|
||||||
} from "@/vfs/vfs"
|
} from "@/vfs/vfs"
|
||||||
|
import { ItemShareDialog } from "../../../sharing/item-share-dialog"
|
||||||
|
|
||||||
// Conditional lazy import - Vite will tree-shake this entire import in production
|
// Conditional lazy import - Vite will tree-shake this entire import in production
|
||||||
// because import.meta.env.DEV is evaluated at build time
|
// because import.meta.env.DEV is evaluated at build time
|
||||||
@@ -100,6 +102,7 @@ export const Route = createFileRoute(
|
|||||||
enum DialogKind {
|
enum DialogKind {
|
||||||
NewDirectory = "NewDirectory",
|
NewDirectory = "NewDirectory",
|
||||||
UploadFile = "UploadFile",
|
UploadFile = "UploadFile",
|
||||||
|
ItemShare = "ItemShare",
|
||||||
}
|
}
|
||||||
|
|
||||||
type NewDirectoryDialogData = {
|
type NewDirectoryDialogData = {
|
||||||
@@ -111,7 +114,15 @@ type UploadFileDialogData = {
|
|||||||
directory: DirectoryInfoWithPath
|
directory: DirectoryInfoWithPath
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActiveDialogData = NewDirectoryDialogData | UploadFileDialogData
|
type ItemShareDialogData = {
|
||||||
|
kind: DialogKind.ItemShare
|
||||||
|
item: DirectoryItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActiveDialogData =
|
||||||
|
| NewDirectoryDialogData
|
||||||
|
| UploadFileDialogData
|
||||||
|
| ItemShareDialogData
|
||||||
|
|
||||||
// MARK: atoms
|
// MARK: atoms
|
||||||
const contextMenuTargetItemsAtom = atom<DirectoryItem[]>([])
|
const contextMenuTargetItemsAtom = atom<DirectoryItem[]>([])
|
||||||
@@ -197,6 +208,22 @@ function RouteComponent() {
|
|||||||
)}
|
)}
|
||||||
</WithAtom>
|
</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}>
|
<WithAtom atom={itemBeingRenamedAtom}>
|
||||||
{(itemBeingRenamed, setItemBeingRenamed) => {
|
{(itemBeingRenamed, setItemBeingRenamed) => {
|
||||||
if (!itemBeingRenamed) return null
|
if (!itemBeingRenamed) return null
|
||||||
@@ -354,6 +381,7 @@ function DirectoryContentContextMenu({
|
|||||||
const account = useAtomValue(currentAccountAtom)
|
const account = useAtomValue(currentAccountAtom)
|
||||||
const { directory } = useContext(DirectoryPageContext)
|
const { directory } = useContext(DirectoryPageContext)
|
||||||
const search = Route.useSearch()
|
const search = Route.useSearch()
|
||||||
|
const setActiveDialogData = useSetAtom(activeDialogDataAtom)
|
||||||
|
|
||||||
const moveToTrashMutation = useAtomValue(moveToTrashMutationAtom)
|
const moveToTrashMutation = useAtomValue(moveToTrashMutationAtom)
|
||||||
const { mutate: moveToTrash } = useMutation({
|
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 (
|
return (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@@ -414,6 +450,10 @@ function DirectoryContentContextMenu({
|
|||||||
<ScissorsIcon />
|
<ScissorsIcon />
|
||||||
Cut
|
Cut
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={openShareDialog}>
|
||||||
|
<Share2Icon />
|
||||||
|
Share
|
||||||
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
@@ -492,6 +532,7 @@ function UploadFileButton() {
|
|||||||
function NewDirectoryItemDropdown() {
|
function NewDirectoryItemDropdown() {
|
||||||
const [activeDialogData, setActiveDialogData] =
|
const [activeDialogData, setActiveDialogData] =
|
||||||
useAtom(activeDialogDataAtom)
|
useAtom(activeDialogDataAtom)
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
const addNewDirectory = () => {
|
const addNewDirectory = () => {
|
||||||
setActiveDialogData({
|
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 { atomFamily } from "jotai/utils"
|
||||||
import { currentAccountAtom } from "@/account/account"
|
import { currentAccountAtom } from "@/account/account"
|
||||||
import { fetchApi } from "@/lib/api"
|
import { fetchApi } from "@/lib/api"
|
||||||
import type { AtomValue } from "@/lib/jotai-utils"
|
|
||||||
import {
|
import {
|
||||||
DirectoryContent,
|
DirectoryContent,
|
||||||
DirectoryInfo,
|
DirectoryInfo,
|
||||||
|
|||||||
24
bun.lock
24
bun.lock
@@ -43,8 +43,8 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.87.4",
|
"@tanstack/react-query": "^5.87.4",
|
||||||
"@tanstack/react-router": "^1.131.41",
|
"@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-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=="],
|
"@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=="],
|
"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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"@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