feat: initial share dialog impl

This commit is contained in:
2025-12-27 19:27:31 +00:00
parent 1a1fc4743a
commit bac21166fb
15 changed files with 1157 additions and 96 deletions

View File

@@ -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",

View 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>
)
}

View 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,
}

View File

@@ -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"}

View File

@@ -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,10 +65,14 @@ 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) {
throw result throw result
} }
return [response, result] return [response, result]
}
return [response, null]
} }

View 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")
},
})

View File

@@ -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)

View File

@@ -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({

View File

@@ -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>
)
}

View 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()
}

View 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 },
)
},
}),
)

View 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>
)
}

View 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

View File

@@ -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,

View File

@@ -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=="],