feat(fronend): wip org prefixed routing

This commit is contained in:
2026-01-04 17:54:58 +00:00
parent 86e90af5c2
commit 0c02929019
32 changed files with 835 additions and 519 deletions

View File

@@ -29,7 +29,7 @@
"@tanstack/react-router": "^1.131.41",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.13",
"@tanstack/router-devtools": "^1.131.42",
"@tanstack/router-devtools": "^1.144.0",
"arktype": "^2.1.28",
"better-auth": "1.3.8",
"class-variance-authority": "^0.7.1",

View File

@@ -3,13 +3,16 @@ import { type } from "arktype"
import { Account } from "@/account/account"
import { accountsQuery } from "@/account/api"
import { fetchApi } from "@/lib/api"
import { Organization, ORGANIZATION_KIND } from "@/organization/organization"
import { currentUserQuery } from "@/user/api"
import { User } from "@/user/user"
import { drivesQuery } from "@/drive/api"
import { Drive } from "@/drive/drive"
import { listOrganizationsQuery } from "@/organization/api"
const LoginResponseSchema = type({
user: User,
organizations: Organization.array(),
})
const SignUpResponse = type({
@@ -30,9 +33,12 @@ export const loginMutation = mutationOptions({
return result
},
onSuccess: (data, _, __, context) => {
context.client.setQueryData(
listOrganizationsQuery.queryKey,
data.organizations,
)
context.client.setQueryData(currentUserQuery.queryKey, data.user)
context.client.invalidateQueries(accountsQuery)
context.client.invalidateQueries(drivesQuery)
},
})
@@ -54,6 +60,5 @@ export const signUpMutation = mutationOptions({
onSuccess: (data, _, __, context) => {
context.client.setQueryData(currentUserQuery.queryKey, data.user)
context.client.setQueryData(accountsQuery.queryKey, [data.account])
context.client.setQueryData(drivesQuery.queryKey, [data.drive])
},
})

View File

@@ -1,15 +1,19 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { Link, useLocation, useParams } from "@tanstack/react-router"
import {
getRouteApi,
Link,
useParams,
useRouterState,
} from "@tanstack/react-router"
import { useAtom, useAtomValue, useSetAtom } from "jotai"
import {
CircleXIcon,
ClockIcon,
FilesIcon,
FolderInputIcon,
HouseIcon,
LogOutIcon,
ScissorsIcon,
SettingsIcon,
TrashIcon,
User2Icon,
} from "lucide-react"
import { toast } from "sonner"
@@ -31,10 +35,10 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { formatError } from "@/lib/error"
import {
moveDirectoryItemsMutationAtom,
rootDirectoryQueryAtom,
} from "@/vfs/api"
import { currentDriveAtom } from "@/drive/drive"
import { listOrganizationDrivesQuery } from "@/organization/api"
import { useCurrentOrganization } from "@/organization/context"
import { moveDirectoryItemsMutationOptions } from "@/vfs/api"
import { Button } from "../components/ui/button"
import { LoadingSpinner } from "../components/ui/loading-spinner"
import { clearCutItemsAtom, cutItemsAtom } from "../files/store"
@@ -66,46 +70,52 @@ export function DashboardSidebar() {
}
function MainSidebarMenu() {
const location = useLocation()
const isActive = (path: string) => {
if (path === "/") {
return location.pathname === "/"
}
return location.pathname.startsWith(path)
}
const org = useCurrentOrganization()
const matchingRoute = getRouteApi(
"/_authenticated/$orgSlug/_sidebar-layout/",
)
const isActive = useRouterState({
select: (state) =>
state.matches.some((match) => match.routeId === matchingRoute.id),
})
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive("/recent")}>
<Link to="/recent">
<ClockIcon />
<span>Recent</span>
<SidebarMenuButton asChild isActive={isActive}>
<Link to="/$orgSlug" params={{ orgSlug: org.slug }}>
<HouseIcon />
<span>Home</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<AllFilesItem />
<TrashItem />
</SidebarMenu>
)
}
function AllFilesItem() {
const location = useLocation()
const { data: rootDirectory } = useQuery(
useAtomValue(rootDirectoryQueryAtom),
const org = useCurrentOrganization()
const matchingRoute = getRouteApi(
"/_authenticated/$orgSlug/_sidebar-layout/drives/$driveId",
)
const isActive = useRouterState({
select: (state) =>
state.matches.some((match) => match.routeId === matchingRoute.id),
})
if (!rootDirectory) return null
const { data: drives } = useQuery(listOrganizationDrivesQuery(org.slug))
const drive = drives?.at(0)
if (!drive) return null
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={location.pathname.startsWith("/directories")}
<SidebarMenuButton asChild isActive={isActive}>
<Link
to="/$orgSlug/drives/$driveId"
params={{ orgSlug: org.slug, driveId: drive.id }}
>
<Link to={`/directories/${rootDirectory.id}`}>
<FilesIcon />
<span>All Files</span>
</Link>
@@ -114,29 +124,6 @@ function AllFilesItem() {
)
}
function TrashItem() {
const location = useLocation()
const { data: rootDirectory } = useQuery(
useAtomValue(rootDirectoryQueryAtom),
)
if (!rootDirectory) return null
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={location.pathname.startsWith("/trash/directories")}
>
<Link to={`/trash/directories/${rootDirectory.id}`}>
<TrashIcon />
<span>Trash</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
function BackgroundTaskProgressItem() {
const backgroundTaskProgress = useAtomValue(backgroundTaskProgressAtom)
@@ -160,9 +147,12 @@ function CutItemsCard() {
const clearCutItems = useSetAtom(clearCutItemsAtom)
const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom)
const moveDirectoryItemsMutation = useAtomValue(
moveDirectoryItemsMutationAtom,
)
const org = useCurrentOrganization()
const drive = useAtomValue(currentDriveAtom)
const moveDirectoryItemsMutation = moveDirectoryItemsMutationOptions({
org,
drive,
})
const { mutate: moveItems } = useMutation({
...moveDirectoryItemsMutation,

View File

@@ -40,9 +40,11 @@ import {
type DirectoryContentOrderDirection,
type DirectoryContentQuery,
type MoveDirectoryItemsResult,
moveDirectoryItemsMutationAtom,
moveDirectoryItemsMutationOptions,
} from "@/vfs/api"
import type { DirectoryInfo, DirectoryItem, FileInfo } from "@/vfs/vfs"
import { currentDriveAtom } from "@/drive/drive"
import { useCurrentOrganization } from "@/organization/context"
import {
DEFAULT_DIRECTORY_CONTENT_ORDER_BY,
DEFAULT_DIRECTORY_CONTENT_ORDER_DIRECTION,
@@ -205,9 +207,12 @@ export function DirectoryContentTable({
const store = useStore()
const navigate = useNavigate()
const moveDirectoryItemsMutation = useAtomValue(
moveDirectoryItemsMutationAtom,
)
const org = useCurrentOrganization()
const drive = useAtomValue(currentDriveAtom)
const moveDirectoryItemsMutation = moveDirectoryItemsMutationOptions({
org,
drive,
})
const { mutate: moveDroppedItems } = useMutation({
...moveDirectoryItemsMutation,

View File

@@ -3,6 +3,8 @@ import { useAtomValue } from "jotai"
import { useId } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { currentDriveAtom } from "@/drive/drive"
import { useCurrentOrganization } from "@/organization/context"
import {
Dialog,
DialogClose,
@@ -12,7 +14,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { createDirectoryMutationAtom } from "@/vfs/api"
import { createDirectoryMutationOptions } from "@/vfs/api"
import type { DirectoryInfo } from "@/vfs/vfs"
export function NewDirectoryDialog({
@@ -26,7 +28,9 @@ export function NewDirectoryDialog({
}) {
const formId = useId()
const createDirectoryMutation = useAtomValue(createDirectoryMutationAtom)
const org = useCurrentOrganization()
const drive = useAtomValue(currentDriveAtom)
const createDirectoryMutation = createDirectoryMutationOptions({ org, drive })
const { mutate: createDirectory, isPending: isCreating } = useMutation({
...createDirectoryMutation,

View File

@@ -2,6 +2,8 @@ import { useMutation } from "@tanstack/react-query"
import { useAtomValue } from "jotai"
import { useId } from "react"
import { Button } from "@/components/ui/button"
import { currentDriveAtom } from "@/drive/drive"
import { useCurrentOrganization } from "@/organization/context"
import {
Dialog,
DialogClose,
@@ -11,7 +13,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { renameDirectoryMutationAtom, renameFileMutationAtom } from "@/vfs/api"
import {
renameDirectoryMutationOptions,
renameFileMutationOptions,
} from "@/vfs/api"
import type { DirectoryItem } from "@/vfs/vfs"
type RenameFileDialogProps = {
@@ -27,8 +32,10 @@ export function RenameFileDialog({
}: RenameFileDialogProps) {
const formId = useId()
const renameFileMutation = useAtomValue(renameFileMutationAtom)
const renameDirectoryMutation = useAtomValue(renameDirectoryMutationAtom)
const org = useCurrentOrganization()
const drive = useAtomValue(currentDriveAtom)
const renameFileMutation = renameFileMutationOptions({ org, drive })
const renameDirectoryMutation = renameDirectoryMutationOptions({ org, drive })
const { mutate: renameFile, isPending: isRenamingFile } = useMutation({
...renameFileMutation,

View File

@@ -1,11 +1,15 @@
import { queryOptions } from "@tanstack/react-query"
import { queryOptions, skipToken } from "@tanstack/react-query"
import { fetchApi } from "@/lib/api"
import { Drive } from "./drive"
import type { Organization } from "@/organization/organization"
export const drivesQuery = queryOptions({
queryKey: ["drives"],
queryFn: async () =>
fetchApi("GET", "/drives", {
export const drivesQuery = (org: Organization | null) =>
queryOptions({
queryKey: ["organizations", org?.slug, "drives"],
queryFn: org
? () =>
fetchApi("GET", `/${org.slug}/drives`, {
returns: Drive.array(),
}).then(([_, result]) => result),
})
})
: skipToken,
})

View File

@@ -3,12 +3,10 @@ import { atom } from "jotai"
export const Drive = type({
id: "string",
publicId: "string",
orgId: "string",
"ownerAccountId?": "string",
name: "string",
createdAt: "string.date.iso.parse",
updatedAt: "string.date.iso.parse",
rootDirId: "string",
storageUsageBytes: "number",
storageQuotaBytes: "number",
})

View File

@@ -5,21 +5,19 @@
* It is included in `src/index.html`.
*/
import { createRouter, RouterProvider } from "@tanstack/react-router"
import { RouterProvider } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/router-devtools"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { ThemeProvider } from "@/components/theme-provider"
// Import the generated route tree
import { routeTree } from "./routeTree.gen"
// Create a new router instance
const router = createRouter({ routeTree })
import { router } from "./router"
const elem = document.getElementById("root")!
const app = (
<StrictMode>
<ThemeProvider defaultTheme="system" storageKey="fileone-ui-theme">
<RouterProvider router={router} />
<TanStackRouterDevtools router={router} />
</ThemeProvider>
</StrictMode>
)

View File

@@ -32,6 +32,7 @@ import { formatError } from "@/lib/error"
import { directoryContentQueryKey } from "@/vfs/api"
import type { DirectoryInfoWithPath } from "@/vfs/vfs"
import { currentDriveAtom } from "@/drive/drive"
import { useCurrentOrganization } from "@/organization/context"
import {
clearAllFileUploadStatusesAtom,
clearFileUploadStatusesAtom,
@@ -63,6 +64,7 @@ function useUploadFilesAtom({
targetDirectory: DirectoryInfoWithPath
}) {
const store = useStore()
const org = useCurrentOrganization()
const options = useMemo(
() =>
@@ -70,9 +72,11 @@ function useUploadFilesAtom({
mutationFn: async (files: PickedFile[]) => {
const drive = store.get(currentDriveAtom)
if (!drive) throw new Error("No drive selected")
const orgSlug = org.slug
const promises = files.map((pickedFile) =>
uploadFile({
orgSlug,
drive,
file: pickedFile.file,
targetDirectory,
@@ -140,6 +144,7 @@ function useUploadFilesAtom({
if (drive) {
client.invalidateQueries({
queryKey: directoryContentQueryKey(
org.slug,
drive.id,
targetDirectory.id,
),
@@ -151,7 +156,7 @@ function useUploadFilesAtom({
toast.error(formatError(error))
},
}),
[store, targetDirectory],
[org.slug, store, targetDirectory],
)
return useMemo(() => atomWithMutation(() => options), [options])

View File

@@ -14,12 +14,14 @@ export const Upload = type({
export type Upload = typeof Upload.infer
export async function uploadFile({
orgSlug,
drive,
file,
targetDirectory,
onStart,
onProgress,
}: {
orgSlug: string
drive: Drive
file: File
targetDirectory: DirectoryInfoWithPath
@@ -28,7 +30,7 @@ export async function uploadFile({
}) {
const [, upload] = await fetchApi(
"POST",
`/drives/${drive.id}/uploads`,
`/${orgSlug}/drives/${drive.id}/uploads`,
{
body: JSON.stringify({
name: file.name,
@@ -45,12 +47,16 @@ export async function uploadFile({
onProgress,
})
await fetchApi("PATCH", `/drives/${drive.id}/uploads/${upload.id}`, {
await fetchApi(
"PATCH",
`/${orgSlug}/drives/${drive.id}/uploads/${upload.id}`,
{
body: JSON.stringify({
status: "completed",
}),
returns: Upload,
})
},
)
return upload
}

View File

@@ -5,25 +5,27 @@ export type ApiRoute =
| "/auth/tokens"
| "/accounts"
| `/accounts/${string}`
| "/drives"
| `/drives/${string}`
| `/drives/${string}/uploads`
| `/drives/${string}/uploads/${string}/content`
| `/drives/${string}/uploads/${string}`
| `/drives/${string}/files${string}`
| `/drives/${string}/files/${string}`
| `/drives/${string}/files/${string}/content`
| `/drives/${string}/files/${string}/shares${string}`
| `/drives/${string}/directories`
| `/drives/${string}/directories/${string}`
| `/drives/${string}/directories/${string}/content`
| `/drives/${string}/directories/${string}/shares${string}`
| `/drives/${string}/shares`
| `/drives/${string}/shares/${string}`
| `/${string}/drives`
| `/${string}/drives/${string}`
| `/${string}/drives/${string}/uploads`
| `/${string}/drives/${string}/uploads/${string}/content`
| `/${string}/drives/${string}/uploads/${string}`
| `/${string}/drives/${string}/files${string}`
| `/${string}/drives/${string}/files/${string}`
| `/${string}/drives/${string}/files/${string}/content`
| `/${string}/drives/${string}/files/${string}/shares${string}`
| `/${string}/drives/${string}/directories${string}`
| `/${string}/drives/${string}/directories/${string}`
| `/${string}/drives/${string}/directories/${string}/content`
| `/${string}/drives/${string}/directories/${string}/shares${string}`
| `/${string}/drives/${string}/shares`
| `/${string}/drives/${string}/shares/${string}`
| `/shares/${string}`
| `/shares/${string}/directories${string}`
| `/shares/${string}/files${string}`
| `/organizations/${string}`
| "/users/me"
| "/users/me/organizations"
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
@@ -44,11 +46,7 @@ export class ApiError extends Error {
export const Nothing = type({})
export type Nothing = typeof Nothing.infer
export async function fetchApi<Schema extends type.Any>(
method: HttpMethod,
route: ApiRoute,
init: RequestInit & { returns: Schema },
): Promise<[response: Response, data: Schema["inferOut"]]> {
export function buildApiUrl(route: ApiRoute): URL {
let path: string
if (baseApiUrl.pathname) {
if (baseApiUrl.pathname.endsWith("/")) {
@@ -59,7 +57,15 @@ export async function fetchApi<Schema extends type.Any>(
} else {
path = route
}
const url = new URL(path, baseApiUrl)
return new URL(path, baseApiUrl)
}
export async function fetchApi<Schema extends type.Any>(
method: HttpMethod,
route: ApiRoute,
init: RequestInit & { returns: Schema },
): Promise<[response: Response, data: Schema["inferOut"]]> {
const url = buildApiUrl(route)
const response = await fetch(url, {
credentials: "include",
...init,

View File

@@ -0,0 +1,32 @@
import { queryOptions, skipToken } from "@tanstack/react-query"
import { fetchApi } from "@/lib/api"
import { Organization } from "./organization"
import { Drive } from "@/drive/drive"
export const organizationQuery = (orgSlug: string | null) =>
queryOptions({
queryKey: ["organizations", orgSlug],
queryFn: orgSlug
? () =>
fetchApi("GET", `/organizations/${orgSlug}`, {
returns: Organization,
}).then(([, data]) => data)
: skipToken,
})
export const listOrganizationsQuery = queryOptions({
queryKey: ["organizations"],
queryFn: () =>
fetchApi("GET", "/users/me/organizations", {
returns: Organization.array(),
}).then(([, data]) => data),
})
export const listOrganizationDrivesQuery = (orgSlug: string) =>
queryOptions({
queryKey: ["organizations", orgSlug, "drives"],
queryFn: () =>
fetchApi("GET", `/${orgSlug}/drives`, {
returns: Drive.array(),
}).then(([, data]) => data),
})

View File

@@ -0,0 +1,16 @@
import { createContext, useContext } from "react"
import type { Organization } from "./organization"
export const OrganizationContext = createContext<Organization>(
null as unknown as Organization,
)
export function useCurrentOrganization() {
const org = useContext(OrganizationContext)
if (!org) {
throw new Error(
"useCurrentOrganization must be used under /$orgSlug routes",
)
}
return org
}

View File

@@ -0,0 +1,18 @@
import { type } from "arktype"
export const ORGANIZATION_KIND = {
personal: "personal",
team: "team",
} as const
export type OrganizationKind =
(typeof ORGANIZATION_KIND)[keyof typeof ORGANIZATION_KIND]
export const Organization = type({
id: "string",
kind: type.valueOf(ORGANIZATION_KIND),
name: "string",
slug: "string",
createdAt: "string.date.iso.parse",
updatedAt: "string.date.iso.parse",
})
export type Organization = typeof Organization.infer

View File

@@ -0,0 +1,14 @@
import { QueryClient } from "@tanstack/react-query"
import { defaultOnError } from "./lib/error"
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
throwOnError: false,
},
mutations: {
onError: defaultOnError,
throwOnError: false,
},
},
})

View File

@@ -8,16 +8,23 @@
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { createFileRoute } from '@tanstack/react-router'
import { Route as rootRouteImport } from './routes/__root'
import { Route as SignUpRouteImport } from './routes/sign-up'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
import { Route as LoginCallbackRouteImport } from './routes/login_.callback'
import { Route as AuthenticatedSidebarLayoutRouteImport } from './routes/_authenticated/_sidebar-layout'
import { Route as AuthenticatedSidebarLayoutHomeRouteImport } from './routes/_authenticated/_sidebar-layout/home'
import { Route as AuthenticatedOrgSlugSidebarLayoutRouteImport } from './routes/_authenticated/$orgSlug/_sidebar-layout'
import { Route as AuthenticatedOrgSlugSidebarLayoutIndexRouteImport } from './routes/_authenticated/$orgSlug/_sidebar-layout/index'
import { Route as SharesShareIdDirectoriesDirectoryIdRouteImport } from './routes/shares/$shareId.directories.$directoryId'
import { Route as AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/directories.$directoryId'
import { Route as AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRouteImport } from './routes/_authenticated/$orgSlug/_sidebar-layout/drives.$driveId'
import { Route as AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/$orgSlug/_sidebar-layout/directories.$directoryId'
const AuthenticatedOrgSlugRouteImport = createFileRoute(
'/_authenticated/$orgSlug',
)()
const SignUpRoute = SignUpRouteImport.update({
id: '/sign-up',
@@ -33,6 +40,11 @@ const AuthenticatedRoute = AuthenticatedRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedOrgSlugRoute = AuthenticatedOrgSlugRouteImport.update({
id: '/$orgSlug',
path: '/$orgSlug',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
id: '/',
path: '/',
@@ -43,16 +55,16 @@ const LoginCallbackRoute = LoginCallbackRouteImport.update({
path: '/login/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedSidebarLayoutRoute =
AuthenticatedSidebarLayoutRouteImport.update({
const AuthenticatedOrgSlugSidebarLayoutRoute =
AuthenticatedOrgSlugSidebarLayoutRouteImport.update({
id: '/_sidebar-layout',
getParentRoute: () => AuthenticatedRoute,
getParentRoute: () => AuthenticatedOrgSlugRoute,
} as any)
const AuthenticatedSidebarLayoutHomeRoute =
AuthenticatedSidebarLayoutHomeRouteImport.update({
id: '/home',
path: '/home',
getParentRoute: () => AuthenticatedSidebarLayoutRoute,
const AuthenticatedOrgSlugSidebarLayoutIndexRoute =
AuthenticatedOrgSlugSidebarLayoutIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthenticatedOrgSlugSidebarLayoutRoute,
} as any)
const SharesShareIdDirectoriesDirectoryIdRoute =
SharesShareIdDirectoriesDirectoryIdRouteImport.update({
@@ -60,11 +72,17 @@ const SharesShareIdDirectoriesDirectoryIdRoute =
path: '/shares/$shareId/directories/$directoryId',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute =
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport.update({
const AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute =
AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRouteImport.update({
id: '/drives/$driveId',
path: '/drives/$driveId',
getParentRoute: () => AuthenticatedOrgSlugSidebarLayoutRoute,
} as any)
const AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute =
AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRouteImport.update({
id: '/directories/$directoryId',
path: '/directories/$directoryId',
getParentRoute: () => AuthenticatedSidebarLayoutRoute,
getParentRoute: () => AuthenticatedOrgSlugSidebarLayoutRoute,
} as any)
export interface FileRoutesByFullPath {
@@ -72,30 +90,35 @@ export interface FileRoutesByFullPath {
'/sign-up': typeof SignUpRoute
'/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/$orgSlug': typeof AuthenticatedOrgSlugSidebarLayoutRouteWithChildren
'/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute
'/$orgSlug/': typeof AuthenticatedOrgSlugSidebarLayoutIndexRoute
'/$orgSlug/directories/$directoryId': typeof AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute
'/$orgSlug/drives/$driveId': typeof AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/sign-up': typeof SignUpRoute
'/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/$orgSlug': typeof AuthenticatedOrgSlugSidebarLayoutIndexRoute
'/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute
'/$orgSlug/directories/$directoryId': typeof AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute
'/$orgSlug/drives/$driveId': typeof AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute
'/sign-up': typeof SignUpRoute
'/_authenticated/_sidebar-layout': typeof AuthenticatedSidebarLayoutRouteWithChildren
'/login_/callback': typeof LoginCallbackRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/_sidebar-layout/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/_authenticated/_sidebar-layout/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/_authenticated/$orgSlug': typeof AuthenticatedOrgSlugRouteWithChildren
'/_authenticated/$orgSlug/_sidebar-layout': typeof AuthenticatedOrgSlugSidebarLayoutRouteWithChildren
'/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute
'/_authenticated/$orgSlug/_sidebar-layout/': typeof AuthenticatedOrgSlugSidebarLayoutIndexRoute
'/_authenticated/$orgSlug/_sidebar-layout/directories/$directoryId': typeof AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute
'/_authenticated/$orgSlug/_sidebar-layout/drives/$driveId': typeof AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -104,29 +127,34 @@ export interface FileRouteTypes {
| '/sign-up'
| '/login/callback'
| '/'
| '/home'
| '/directories/$directoryId'
| '/$orgSlug'
| '/shares/$shareId/directories/$directoryId'
| '/$orgSlug/'
| '/$orgSlug/directories/$directoryId'
| '/$orgSlug/drives/$driveId'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
| '/sign-up'
| '/login/callback'
| '/'
| '/home'
| '/directories/$directoryId'
| '/$orgSlug'
| '/shares/$shareId/directories/$directoryId'
| '/$orgSlug/directories/$directoryId'
| '/$orgSlug/drives/$driveId'
id:
| '__root__'
| '/_authenticated'
| '/login'
| '/sign-up'
| '/_authenticated/_sidebar-layout'
| '/login_/callback'
| '/_authenticated/'
| '/_authenticated/_sidebar-layout/home'
| '/_authenticated/_sidebar-layout/directories/$directoryId'
| '/_authenticated/$orgSlug'
| '/_authenticated/$orgSlug/_sidebar-layout'
| '/shares/$shareId/directories/$directoryId'
| '/_authenticated/$orgSlug/_sidebar-layout/'
| '/_authenticated/$orgSlug/_sidebar-layout/directories/$directoryId'
| '/_authenticated/$orgSlug/_sidebar-layout/drives/$driveId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -160,6 +188,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated/$orgSlug': {
id: '/_authenticated/$orgSlug'
path: '/$orgSlug'
fullPath: '/$orgSlug'
preLoaderRoute: typeof AuthenticatedOrgSlugRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/': {
id: '/_authenticated/'
path: '/'
@@ -174,19 +209,19 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated/_sidebar-layout': {
id: '/_authenticated/_sidebar-layout'
path: ''
fullPath: ''
preLoaderRoute: typeof AuthenticatedSidebarLayoutRouteImport
parentRoute: typeof AuthenticatedRoute
'/_authenticated/$orgSlug/_sidebar-layout': {
id: '/_authenticated/$orgSlug/_sidebar-layout'
path: '/$orgSlug'
fullPath: '/$orgSlug'
preLoaderRoute: typeof AuthenticatedOrgSlugSidebarLayoutRouteImport
parentRoute: typeof AuthenticatedOrgSlugRoute
}
'/_authenticated/_sidebar-layout/home': {
id: '/_authenticated/_sidebar-layout/home'
path: '/home'
fullPath: '/home'
preLoaderRoute: typeof AuthenticatedSidebarLayoutHomeRouteImport
parentRoute: typeof AuthenticatedSidebarLayoutRoute
'/_authenticated/$orgSlug/_sidebar-layout/': {
id: '/_authenticated/$orgSlug/_sidebar-layout/'
path: '/'
fullPath: '/$orgSlug/'
preLoaderRoute: typeof AuthenticatedOrgSlugSidebarLayoutIndexRouteImport
parentRoute: typeof AuthenticatedOrgSlugSidebarLayoutRoute
}
'/shares/$shareId/directories/$directoryId': {
id: '/shares/$shareId/directories/$directoryId'
@@ -195,41 +230,64 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SharesShareIdDirectoriesDirectoryIdRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated/_sidebar-layout/directories/$directoryId': {
id: '/_authenticated/_sidebar-layout/directories/$directoryId'
'/_authenticated/$orgSlug/_sidebar-layout/drives/$driveId': {
id: '/_authenticated/$orgSlug/_sidebar-layout/drives/$driveId'
path: '/drives/$driveId'
fullPath: '/$orgSlug/drives/$driveId'
preLoaderRoute: typeof AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRouteImport
parentRoute: typeof AuthenticatedOrgSlugSidebarLayoutRoute
}
'/_authenticated/$orgSlug/_sidebar-layout/directories/$directoryId': {
id: '/_authenticated/$orgSlug/_sidebar-layout/directories/$directoryId'
path: '/directories/$directoryId'
fullPath: '/directories/$directoryId'
preLoaderRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport
parentRoute: typeof AuthenticatedSidebarLayoutRoute
fullPath: '/$orgSlug/directories/$directoryId'
preLoaderRoute: typeof AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRouteImport
parentRoute: typeof AuthenticatedOrgSlugSidebarLayoutRoute
}
}
}
interface AuthenticatedSidebarLayoutRouteChildren {
AuthenticatedSidebarLayoutHomeRoute: typeof AuthenticatedSidebarLayoutHomeRoute
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
interface AuthenticatedOrgSlugSidebarLayoutRouteChildren {
AuthenticatedOrgSlugSidebarLayoutIndexRoute: typeof AuthenticatedOrgSlugSidebarLayoutIndexRoute
AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute: typeof AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute
AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute: typeof AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute
}
const AuthenticatedSidebarLayoutRouteChildren: AuthenticatedSidebarLayoutRouteChildren =
const AuthenticatedOrgSlugSidebarLayoutRouteChildren: AuthenticatedOrgSlugSidebarLayoutRouteChildren =
{
AuthenticatedSidebarLayoutHomeRoute: AuthenticatedSidebarLayoutHomeRoute,
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute:
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute,
AuthenticatedOrgSlugSidebarLayoutIndexRoute:
AuthenticatedOrgSlugSidebarLayoutIndexRoute,
AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute:
AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute,
AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute:
AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute,
}
const AuthenticatedSidebarLayoutRouteWithChildren =
AuthenticatedSidebarLayoutRoute._addFileChildren(
AuthenticatedSidebarLayoutRouteChildren,
const AuthenticatedOrgSlugSidebarLayoutRouteWithChildren =
AuthenticatedOrgSlugSidebarLayoutRoute._addFileChildren(
AuthenticatedOrgSlugSidebarLayoutRouteChildren,
)
interface AuthenticatedOrgSlugRouteChildren {
AuthenticatedOrgSlugSidebarLayoutRoute: typeof AuthenticatedOrgSlugSidebarLayoutRouteWithChildren
}
const AuthenticatedOrgSlugRouteChildren: AuthenticatedOrgSlugRouteChildren = {
AuthenticatedOrgSlugSidebarLayoutRoute:
AuthenticatedOrgSlugSidebarLayoutRouteWithChildren,
}
const AuthenticatedOrgSlugRouteWithChildren =
AuthenticatedOrgSlugRoute._addFileChildren(AuthenticatedOrgSlugRouteChildren)
interface AuthenticatedRouteChildren {
AuthenticatedSidebarLayoutRoute: typeof AuthenticatedSidebarLayoutRouteWithChildren
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedOrgSlugRoute: typeof AuthenticatedOrgSlugRouteWithChildren
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedSidebarLayoutRoute: AuthenticatedSidebarLayoutRouteWithChildren,
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedOrgSlugRoute: AuthenticatedOrgSlugRouteWithChildren,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(

View File

@@ -0,0 +1,14 @@
import { createRouter } from "@tanstack/react-router"
import { queryClient } from "./query-client"
import { routeTree } from "./routeTree.gen"
export type RouterContext = {
queryClient: typeof queryClient
}
export const router = createRouter({
routeTree,
context: {
queryClient,
},
})

View File

@@ -1,30 +1,19 @@
import "@/styles/globals.css"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { createRootRoute, Outlet } from "@tanstack/react-router"
import { QueryClientProvider } from "@tanstack/react-query"
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"
import { Provider } from "jotai"
import { useHydrateAtoms } from "jotai/utils"
import { queryClientAtom } from "jotai-tanstack-query"
import type React from "react"
import { Toaster } from "@/components/ui/sonner"
import { defaultOnError } from "@/lib/error"
import { useKeyboardModifierListener } from "@/lib/keyboard"
import { queryClient } from "@/query-client"
import type { RouterContext } from "@/router"
export const Route = createRootRoute({
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
})
const queryClient = new QueryClient({
defaultOptions: {
queries: {
throwOnError: false,
},
mutations: {
onError: defaultOnError,
throwOnError: false,
},
},
})
function HydrateAtoms({ children }: React.PropsWithChildren) {
useHydrateAtoms(new Map([[queryClientAtom, queryClient]]))
return children

View File

@@ -1,31 +1,22 @@
import { useQuery } from "@tanstack/react-query"
import { createFileRoute, Navigate, Outlet } from "@tanstack/react-router"
import { useAtomValue } from "jotai"
import { atomEffect } from "jotai-effect"
import { atomWithQuery } from "jotai-tanstack-query"
import { LoadingSpinner } from "@/components/ui/loading-spinner"
import { drivesQuery } from "@/drive/api"
import { currentDriveAtom } from "@/drive/drive"
import { listOrganizationsQuery } from "@/organization/api"
import { ORGANIZATION_KIND } from "@/organization/organization"
export const Route = createFileRoute("/_authenticated")({
component: AuthenticatedLayout,
})
const drivesAtom = atomWithQuery(() => drivesQuery)
const selectFirstDriveEffect = atomEffect((get, set) => {
const { data: drives } = get(drivesAtom)
const firstDrive = drives?.[0]
if (firstDrive && get.peek(currentDriveAtom) === null) {
set(currentDriveAtom, firstDrive)
}
loader: ({ context }) => {
context.queryClient.ensureQueryData(listOrganizationsQuery)
},
})
function AuthenticatedLayout() {
const { data: drives, isLoading: isLoadingDrives } =
useAtomValue(drivesAtom)
const { data: orgs, isLoading: isLoadingOrgs } = useQuery(
listOrganizationsQuery,
)
useAtomValue(selectFirstDriveEffect)
if (isLoadingDrives) {
if (isLoadingOrgs) {
return (
<div className="flex h-screen w-full items-center justify-center">
<LoadingSpinner className="size-10" />
@@ -33,7 +24,11 @@ function AuthenticatedLayout() {
)
}
if (!drives) {
const personalOrg = orgs?.find(
(org) => org.kind === ORGANIZATION_KIND.personal,
)
if (!personalOrg) {
return <Navigate replace to="/login" />
}

View File

@@ -0,0 +1,52 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Outlet } from "@tanstack/react-router"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { LoadingSpinner } from "@/components/ui/loading-spinner"
import { DashboardSidebar } from "@/dashboard/dashboard-sidebar"
import { listOrganizationsQuery, organizationQuery } from "@/organization/api"
import { OrganizationContext } from "@/organization/context"
export const Route = createFileRoute(
"/_authenticated/$orgSlug/_sidebar-layout",
)({
component: RouteComponent,
loader: ({ context, params }) => {
context.queryClient.ensureQueryData(organizationQuery(params.orgSlug))
},
})
function RouteComponent() {
const { orgSlug } = Route.useParams()
const client = useQueryClient()
const { data: org, isLoading } = useQuery({
...organizationQuery(orgSlug),
initialData: () =>
client
.getQueryData(listOrganizationsQuery.queryKey)
?.find((org) => org.slug === orgSlug),
})
if (isLoading) {
return (
<div className="flex h-screen w-full items-center justify-center">
<LoadingSpinner className="size-10" />
</div>
)
}
if (!org) return null
return (
<OrganizationContext value={org}>
<SidebarProvider>
<div className="flex h-screen w-full">
<DashboardSidebar />
<SidebarInset className="overflow-hidden">
<Outlet />
</SidebarInset>
</div>
</SidebarProvider>
</OrganizationContext>
)
}

View File

@@ -15,6 +15,7 @@ import {
import { lazy, Suspense, useCallback, useContext } from "react"
import { toast } from "sonner"
import { currentDriveAtom } from "@/drive/drive"
import { useCurrentOrganization } from "@/organization/context"
import { DirectoryIcon } from "@/components/icons/directory-icon"
import { TextFileIcon } from "@/components/icons/text-file-icon"
import { Button } from "@/components/ui/button"
@@ -54,10 +55,10 @@ import type {
import {
DIRECTORY_CONTENT_ORDER_BY,
DIRECTORY_CONTENT_ORDER_DIRECTION,
directoryContentQueryAtom,
directoryContentQuery,
directoryContentQueryKey,
directoryInfoQueryAtom,
moveToTrashMutationAtom,
directoryInfoQuery,
moveToTrashMutationOptions,
} from "@/vfs/api"
import {
optimisticallyRemoveDirectoryItems,
@@ -69,7 +70,7 @@ import type {
DirectoryItem,
FileInfo,
} from "@/vfs/vfs"
import { ItemShareDialog } from "../../../sharing/item-share-dialog"
import { ItemShareDialog } from "@/sharing/item-share-dialog"
// Conditional lazy import - Vite will tree-shake this entire import in production
// because import.meta.env.DEV is evaluated at build time
@@ -93,7 +94,7 @@ const DirectoryContentPageParams = type({
})
export const Route = createFileRoute(
"/_authenticated/_sidebar-layout/directories/$directoryId",
"/_authenticated/$orgSlug/_sidebar-layout/directories/$directoryId",
)({
validateSearch: DirectoryContentPageParams,
component: RouteComponent,
@@ -144,14 +145,16 @@ const mockTableAtom = import.meta.env.DEV
// MARK: page entry
function RouteComponent() {
const { directoryId } = Route.useParams()
const { directoryId, orgSlug } = Route.useParams()
const drive = useAtomValue(currentDriveAtom)
const org = useCurrentOrganization()
const { data: directoryInfo, isLoading: isLoadingDirectoryInfo } = useQuery(
useAtomValue(directoryInfoQueryAtom(directoryId)),
directoryInfoQuery({ org, drive, directoryId }),
)
const directoryUrlById = useCallback(
(directoryId: string) => `/directories/${directoryId}`,
[],
(directoryId: string) => `/${orgSlug}/directories/${directoryId}`,
[orgSlug],
)
if (isLoadingDirectoryInfo) {
@@ -260,16 +263,18 @@ function _DirectoryContentTable() {
const useMock = useAtomValue(mockTableAtom)
const { directory } = useContext(DirectoryPageContext)
const navigate = Route.useNavigate()
const drive = useAtomValue(currentDriveAtom)
const org = useCurrentOrganization()
const search = Route.useSearch()
const query = useAtomValue(
directoryContentQueryAtom({
const query = directoryContentQuery({
org,
drive,
directoryId: directory.id,
orderBy: search.orderBy,
direction: search.direction,
limit: 100,
}),
)
})
const setOpenedFile = useSetAtom(openedFileAtom)
const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom)
@@ -281,9 +286,10 @@ function _DirectoryContentTable() {
[setOpenedFile],
)
const { orgSlug } = Route.useParams()
const directoryUrlFn = useCallback(
(directory: DirectoryInfo) => `/directories/${directory.id}`,
[],
(directory: DirectoryInfo) => `/${orgSlug}/directories/${directory.id}`,
[orgSlug],
)
const handleContextMenuRequest = useCallback(
@@ -379,11 +385,12 @@ function DirectoryContentContextMenu({
const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom)
const setCutItems = useSetAtom(cutItemsAtom)
const drive = useAtomValue(currentDriveAtom)
const org = useCurrentOrganization()
const { directory } = useContext(DirectoryPageContext)
const search = Route.useSearch()
const setActiveDialogData = useSetAtom(activeDialogDataAtom)
const moveToTrashMutation = useAtomValue(moveToTrashMutationAtom)
const moveToTrashMutation = moveToTrashMutationOptions({ org, drive })
const { mutate: moveToTrash } = useMutation({
...moveToTrashMutation,
onMutate: (items, { client }) => {
@@ -394,7 +401,7 @@ function DirectoryContentContextMenu({
return null
}
return optimisticallyRemoveDirectoryItems(client, {
queryKey: directoryContentQueryKey(drive.id, directory.id, {
queryKey: directoryContentQueryKey(org.slug, drive.id, directory.id, {
orderBy: search.orderBy,
direction: search.direction,
}),

View File

@@ -0,0 +1,25 @@
import { listOrganizationDrivesQuery } from "@/organization/api"
import { useQuery } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute(
"/_authenticated/$orgSlug/_sidebar-layout/drives/$driveId",
)({
component: RouteComponent,
loader: ({ context, params }) => {
context.queryClient.ensureQueryData(
listOrganizationDrivesQuery(params.orgSlug),
)
},
})
function RouteComponent() {
const { orgSlug } = Route.useParams()
const { data: drives } = useQuery(listOrganizationDrivesQuery(orgSlug))
return (
<div>
Hello "/_authenticated/$orgSlug/_sidebar-layout/drives/$driveSlug"!
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from "@tanstack/react-router"
import { useCurrentOrganization } from "@/organization/context"
export const Route = createFileRoute(
"/_authenticated/$orgSlug/_sidebar-layout/",
)({
component: RouteComponent,
})
function RouteComponent() {
const org = useCurrentOrganization()
return <div>Home of organization "{org.name}"</div>
}

View File

@@ -1,20 +0,0 @@
import { createFileRoute, Outlet } from "@tanstack/react-router"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { DashboardSidebar } from "@/dashboard/dashboard-sidebar"
export const Route = createFileRoute("/_authenticated/_sidebar-layout")({
component: RouteComponent,
})
function RouteComponent() {
return (
<SidebarProvider>
<div className="flex h-screen w-full">
<DashboardSidebar />
<SidebarInset className="overflow-hidden">
<Outlet />
</SidebarInset>
</div>
</SidebarProvider>
)
}

View File

@@ -1,9 +0,0 @@
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/_authenticated/_sidebar-layout/home")({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_authenticated/home"!</div>
}

View File

@@ -1,9 +1,40 @@
import { useQuery } from "@tanstack/react-query"
import { createFileRoute, Navigate } from "@tanstack/react-router"
import { LoadingSpinner } from "@/components/ui/loading-spinner"
import { listOrganizationsQuery } from "@/organization/api"
import { ORGANIZATION_KIND } from "@/organization/organization"
export const Route = createFileRoute("/_authenticated/")({
component: RouteComponent,
loader: ({ context }) => {
void context.queryClient.ensureQueryData(listOrganizationsQuery)
},
})
function RouteComponent() {
return <Navigate replace to="/home" />
const { data: orgs, isLoading } = useQuery(listOrganizationsQuery)
if (isLoading) {
return (
<div className="flex h-screen w-full items-center justify-center">
<LoadingSpinner className="size-10" />
</div>
)
}
const personalOrg = orgs?.find(
(org) => org.kind === ORGANIZATION_KIND.personal,
)
if (!personalOrg) {
return <Navigate replace to="/login" />
}
return (
<Navigate
replace
to="/$orgSlug"
params={{ orgSlug: personalOrg.slug }}
/>
)
}

View File

@@ -19,6 +19,9 @@ import {
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { useStore } from "jotai"
import { ORGANIZATION_KIND } from "@/organization/organization"
import { organizationQuery } from "@/organization/api"
export const Route = createFileRoute("/login")({
component: RouteComponent,
@@ -73,10 +76,19 @@ function LoginForm() {
...loginMutation,
onSuccess: (data, vars, result, context) => {
loginMutation.onSuccess?.(data, vars, result, context)
const personalOrg = data.organizations.find(
(org) => org.kind === ORGANIZATION_KIND.personal,
)
if (personalOrg) {
context.client.ensureQueryData(
organizationQuery(personalOrg.slug),
)
navigate({
to: "/",
to: "/$orgSlug",
params: { orgSlug: personalOrg.slug },
replace: true,
})
}
},
})

View File

@@ -1,53 +1,81 @@
import { mutationOptions, queryOptions, skipToken } from "@tanstack/react-query"
import { atom } from "jotai"
import { atomFamily } from "jotai/utils"
import { fetchApi, Nothing } from "@/lib/api"
import { currentDriveAtom } from "@/drive/drive"
import { type Drive } from "@/drive/drive"
import type { Organization } from "@/organization/organization"
import { Share } from "./share"
export const fileSharesQueryAtom = atomFamily((fileId: string) =>
atom((get) => {
const drive = get(currentDriveAtom)
return queryOptions({
queryKey: ["drives", drive?.id, "shares", { fileId }],
queryFn: drive
export const fileSharesQuery = ({
org,
drive,
fileId,
}: {
org: Organization | null
drive: Drive | null
fileId: string
}) =>
queryOptions({
queryKey: [
"organizations",
org?.slug,
"drives",
drive?.id,
"shares",
{ fileId },
],
queryFn:
org && drive
? () =>
fetchApi(
"GET",
`/drives/${drive.id}/files/${fileId}/shares?includesExpired=true`,
`/${org.slug}/drives/${drive.id}/files/${fileId}/shares?includesExpired=true`,
{ returns: Share.array() },
).then(([_, result]) => result)
: skipToken,
})
}),
)
export const directorySharesQueryAtom = atomFamily((directoryId: string) =>
atom((get) => {
const drive = get(currentDriveAtom)
return queryOptions({
queryKey: ["drives", drive?.id, "shares", { directoryId }],
queryFn: drive
export const directorySharesQuery = ({
org,
drive,
directoryId,
}: {
org: Organization | null
drive: Drive | null
directoryId: string
}) =>
queryOptions({
queryKey: [
"organizations",
org?.slug,
"drives",
drive?.id,
"shares",
{ directoryId },
],
queryFn:
org && drive
? () =>
fetchApi(
"GET",
`/drives/${drive.id}/directories/${directoryId}/shares?includesExpired=true`,
`/${org.slug}/drives/${drive.id}/directories/${directoryId}/shares?includesExpired=true`,
{ returns: Share.array() },
).then(([_, result]) => result)
: skipToken,
})
}),
)
export const createShareMutationAtom = atom((get) =>
export const createShareMutationOptions = ({
org,
drive,
}: {
org: Organization
drive: Drive | null
}) =>
mutationOptions({
mutationFn: async ({ items }: { items: string[] }) => {
const drive = get(currentDriveAtom)
if (!drive) throw new Error("No drive selected")
const [_, result] = await fetchApi(
"POST",
`/drives/${drive.id}/shares`,
`/${org.slug}/drives/${drive.id}/shares`,
{
body: JSON.stringify({ items }),
returns: Share,
@@ -56,25 +84,34 @@ export const createShareMutationAtom = atom((get) =>
return result
},
}),
)
})
export const deleteShareMutationAtom = atom((get) =>
export const deleteShareMutationOptions = ({
org,
drive,
}: {
org: Organization
drive: Drive | null
}) =>
mutationOptions({
mutationFn: async ({ shareId }: { shareId: string }) => {
const drive = get(currentDriveAtom)
if (!drive) throw new Error("No drive selected")
await fetchApi(
"DELETE",
`/drives/${drive.id}/shares/${shareId}`,
`/${org.slug}/drives/${drive.id}/shares/${shareId}`,
{ returns: Nothing },
)
},
}),
)
})
export const updateShareMutationAtom = atom((get) =>
export const updateShareMutationOptions = ({
org,
drive,
}: {
org: Organization
drive: Drive | null
}) =>
mutationOptions({
mutationFn: async ({
shareId,
@@ -83,14 +120,12 @@ export const updateShareMutationAtom = atom((get) =>
shareId: string
expiresAt?: Date | null
}) => {
const drive = get(currentDriveAtom)
if (!drive) throw new Error("No drive selected")
await fetchApi(
"PATCH",
`/drives/${drive.id}/shares/${shareId}`,
`/${org.slug}/drives/${drive.id}/shares/${shareId}`,
{ body: JSON.stringify({ expiresAt }), returns: Share },
)
},
}),
)
})

View File

@@ -1,11 +1,5 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import {
atom,
type PrimitiveAtom,
useAtomValue,
useSetAtom,
useStore,
} from "jotai"
import { atom, type PrimitiveAtom, useAtomValue, useSetAtom } from "jotai"
import {
CheckIcon,
CopyIcon,
@@ -59,14 +53,17 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { currentDriveAtom, type Drive } from "@/drive/drive"
import { copyToClipboardMutation } from "@/lib/clipboard"
import { cn } from "@/lib/utils"
import type { Organization } from "@/organization/organization"
import { useCurrentOrganization } from "@/organization/context"
import {
createShareMutationAtom,
deleteShareMutationAtom,
directorySharesQueryAtom,
fileSharesQueryAtom,
updateShareMutationAtom,
createShareMutationOptions,
deleteShareMutationOptions,
directorySharesQuery,
fileSharesQuery,
updateShareMutationOptions,
} from "@/sharing/api"
import type { DirectoryItem } from "@/vfs/vfs"
import type { Share } from "./share"
@@ -118,9 +115,40 @@ export function ItemShareDialog({ item, open, onClose }: ItemShareDialogProps) {
)
}
function itemSharesQueryKey(
item: DirectoryItem,
org: Organization | null,
drive: Drive | null,
): readonly unknown[] | null {
if (!org || !drive) return null
switch (item.kind) {
case "file":
return fileSharesQuery({ org, drive, fileId: item.id }).queryKey
case "directory":
return directorySharesQuery({
org,
drive,
directoryId: item.id,
}).queryKey
default:
return null
}
}
function PublicAccessSection({ item }: { item: DirectoryItem }) {
const fileSharesQuery = useAtomValue(fileSharesQueryAtom(item.id))
const directorySharesQuery = useAtomValue(directorySharesQueryAtom(item.id))
const drive = useAtomValue(currentDriveAtom)
const org = useCurrentOrganization()
const fileSharesQueryOptions = fileSharesQuery({
org,
drive,
fileId: item.id,
})
const directorySharesQueryOptions = directorySharesQuery({
org,
drive,
directoryId: item.id,
})
const sortShares = (shares: Share[]) =>
[...shares].sort((a, b) => {
@@ -131,13 +159,13 @@ function PublicAccessSection({ item }: { item: DirectoryItem }) {
})
const { data: fileShares, isLoading: isLoadingFileShares } = useQuery({
...fileSharesQuery,
...fileSharesQueryOptions,
enabled: item.kind === "file",
select: sortShares,
})
const { data: directoryShares, isLoading: isLoadingDirectoryShares } =
useQuery({
...directorySharesQuery,
...directorySharesQueryOptions,
enabled: item.kind === "directory",
select: sortShares,
})
@@ -301,24 +329,12 @@ function ShareLinkListItem({ share }: { share: Share }) {
function AddShareLinkListItem() {
const { item } = useContext(ItemShareDialogContext)
const store = useStore()
const drive = useAtomValue(currentDriveAtom)
const org = useCurrentOrganization()
const { mutate: createShare, isPending: isCreatingShare } = useMutation({
...useAtomValue(createShareMutationAtom),
...createShareMutationOptions({ org, drive }),
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
}
const queryKey = itemSharesQueryKey(item, org, drive)
if (queryKey) {
client.invalidateQueries({
queryKey,
@@ -417,7 +433,7 @@ function RenameShareLinkPopover({
}: React.PropsWithChildren<{
share: Share
activePopoverAtom: PrimitiveAtom<ActivePopoverKind | null>
}>) {
}>) {
const setActivePopover = useSetAtom(activePopoverAtom)
const inputId = `rename-share-link-${share.id}`
@@ -486,27 +502,15 @@ function ConfigureShareLinkExpirationPopover({
share.expiresAt === null ? EXPIRATION_TYPE.never : EXPIRATION_TYPE.date,
)
const { item } = useContext(ItemShareDialogContext)
const store = useStore()
const drive = useAtomValue(currentDriveAtom)
const org = useCurrentOrganization()
const setActivePopover = useSetAtom(activePopoverAtom)
const dateInputRef = useRef<DateInputHandle>(null)
const { mutate: updateShare, isPending: isUpdatingShare } = useMutation({
...useAtomValue(updateShareMutationAtom),
...updateShareMutationOptions({ org, drive }),
onSuccess: (_updatedShare, _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
}
const queryKey = itemSharesQueryKey(item, org, drive)
if (queryKey) {
client.invalidateQueries({
queryKey,
@@ -617,28 +621,16 @@ function ShareLinkOptionsMenu({
}: React.PropsWithChildren<{
share: Share
activePopoverAtom: PrimitiveAtom<ActivePopoverKind | null>
}>) {
}>) {
const { item } = useContext(ItemShareDialogContext)
const setActivePopover = useSetAtom(activePopoverAtom)
const store = useStore()
const drive = useAtomValue(currentDriveAtom)
const org = useCurrentOrganization()
const { mutate: deleteShare } = useMutation({
...useAtomValue(deleteShareMutationAtom),
...deleteShareMutationOptions({ org, drive }),
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
}
const queryKey = itemSharesQueryKey(item, org, drive)
if (queryKey) {
const prevShares = client.getQueryData<Share[]>(queryKey)
client.setQueryData<Share[]>(
@@ -702,24 +694,12 @@ function ShareLinkOptionsMenu({
function CreateShareLinkButton() {
const { item } = useContext(ItemShareDialogContext)
const store = useStore()
const drive = useAtomValue(currentDriveAtom)
const org = useCurrentOrganization()
const { mutate: createShare, isPending: isCreatingShare } = useMutation({
...useAtomValue(createShareMutationAtom),
...createShareMutationOptions({ org, drive }),
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
}
const queryKey = itemSharesQueryKey(item, org, drive)
if (queryKey) {
client.invalidateQueries({
queryKey,

View File

@@ -6,10 +6,9 @@ import {
skipToken,
} from "@tanstack/react-query"
import { type } from "arktype"
import { atom } from "jotai"
import { atomFamily } from "jotai/utils"
import { type Drive } from "@/drive/drive"
import { fetchApi } from "@/lib/api"
import { currentDriveAtom } from "@/drive/drive"
import type { Organization } from "@/organization/organization"
import {
DirectoryContent,
DirectoryInfo,
@@ -24,51 +23,54 @@ export const DirectoryContentResponse = type({
})
export type DirectoryContentResponseType = typeof DirectoryContentResponse.infer
/**
* This atom derives the file url for a given file.
* It is recommended to use {@link useFileUrl} instead of using this atom directly.
*/
export const fileUrlAtom = atomFamily((fileId: string) =>
atom((get) => {
const drive = get(currentDriveAtom)
if (!drive) {
return ""
}
return `${import.meta.env.VITE_API_URL}/drives/${drive.id}/files/${fileId}/content`
}),
)
export const rootDirectoryQueryAtom = atom((get) => {
const drive = get(currentDriveAtom)
return queryOptions({
queryKey: ["drives", drive?.id, "directories", "root"],
queryFn: drive
export const rootDirectoryQuery = ({
org,
drive,
}: {
org: Organization | null
drive: Drive | null
}) =>
queryOptions({
queryKey: ["organizations", org?.slug, "drives", drive?.id, "root"],
queryFn:
org && drive
? () =>
fetchApi(
"GET",
`/drives/${drive.id}/directories/root?include=path`,
`/${org.slug}/drives/${drive.id}/directories/root?include=path`,
{ returns: DirectoryInfoWithPath },
).then(([_, result]) => result)
: skipToken,
})
})
export const directoryInfoQueryAtom = atomFamily((directoryId: string) =>
atom((get) => {
const drive = get(currentDriveAtom)
return queryOptions({
queryKey: ["drives", drive?.id, "directories", directoryId],
queryFn: drive
export const directoryInfoQuery = ({
org,
drive,
directoryId,
}: {
org: Organization | null
drive: Drive | null
directoryId: string
}) =>
queryOptions({
queryKey: [
"organizations",
org?.slug,
"drives",
drive?.id,
"directories",
directoryId,
],
queryFn:
org && drive
? () =>
fetchApi(
"GET",
`/drives/${drive.id}/directories/${directoryId}?include=path`,
`/${org.slug}/drives/${drive.id}/directories/${directoryId}?include=path`,
{ returns: DirectoryInfoWithPath },
).then(([_, result]) => result)
: skipToken,
})
}),
)
export const DIRECTORY_CONTENT_ORDER_BY = {
name: "name",
@@ -100,6 +102,7 @@ type DirectoryContentPageParam = {
}
export const directoryContentQueryKey = (
orgSlug: string | undefined,
driveId: string | undefined,
directoryId: string,
params?: {
@@ -107,6 +110,8 @@ export const directoryContentQueryKey = (
direction?: DirectoryContentOrderDirection
},
): readonly unknown[] => [
"organizations",
orgSlug,
"drives",
driveId,
"directories",
@@ -123,12 +128,20 @@ export type DirectoryContentQuery = ReturnType<
DirectoryContentPageParam
>
>
export const directoryContentQueryAtom = atomFamily(
({ directoryId, orderBy, direction, limit }: DirectoryContentQueryParams) =>
atom((get) => {
const drive = get(currentDriveAtom)
return infiniteQueryOptions({
queryKey: directoryContentQueryKey(drive?.id, directoryId, {
export const directoryContentQuery = ({
org,
drive,
directoryId,
orderBy,
direction,
limit,
}: {
org: Organization | null
drive: Drive | null
} & DirectoryContentQueryParams) =>
infiniteQueryOptions({
queryKey: directoryContentQueryKey(org?.slug, drive?.id, directoryId, {
orderBy,
direction,
}),
@@ -137,15 +150,16 @@ export const directoryContentQueryAtom = atomFamily(
direction,
limit,
cursor: "",
},
queryFn: ({ pageParam }) =>
drive
? fetchApi(
} satisfies DirectoryContentPageParam,
queryFn:
org && drive
? ({ pageParam }) =>
fetchApi(
"GET",
`/drives/${drive.id}/directories/${directoryId}/content?orderBy=${pageParam.orderBy}&dir=${pageParam.direction}&limit=${pageParam.limit}${pageParam.cursor ? `&cursor=${pageParam.cursor}` : ""}`,
`/${org.slug}/drives/${drive.id}/directories/${directoryId}/content?orderBy=${pageParam.orderBy}&dir=${pageParam.direction}&limit=${pageParam.limit}${pageParam.cursor ? `&cursor=${pageParam.cursor}` : ""}`,
{ returns: DirectoryContentResponse },
).then(([_, result]) => result)
: Promise.reject(new Error("No drive selected")),
: skipToken,
getNextPageParam: (lastPage, _pages, lastPageParam) =>
lastPage.nextCursor
? {
@@ -154,22 +168,20 @@ export const directoryContentQueryAtom = atomFamily(
}
: null,
})
}),
(paramsA, paramsB) =>
paramsA.directoryId === paramsB.directoryId &&
paramsA.orderBy === paramsB.orderBy &&
paramsA.direction === paramsB.direction &&
paramsA.limit === paramsB.limit,
)
export const createDirectoryMutationAtom = atom((get) => {
const drive = get(currentDriveAtom)
return mutationOptions({
export const createDirectoryMutationOptions = ({
org,
drive,
}: {
org: Organization
drive: Drive | null
}) =>
mutationOptions({
mutationFn: async (data: { name: string; parentId: string }) => {
if (!drive) throw new Error("No drive selected")
return fetchApi(
"POST",
`/drives/${drive.id}/directories?include=path`,
`/${org.slug}/drives/${drive.id}/directories?include=path`,
{
body: JSON.stringify({
name: data.name,
@@ -180,14 +192,12 @@ export const createDirectoryMutationAtom = atom((get) => {
).then(([_, result]) => result)
},
onSuccess: (_data, { parentId }, _context, { client }) => {
if (drive) {
if (!drive) return
client.invalidateQueries({
queryKey: directoryContentQueryKey(drive.id, parentId),
queryKey: directoryContentQueryKey(org.slug, drive.id, parentId),
})
}
},
})
})
export const MoveDirectoryItemsResult = type({
items: DirectoryItem.array(),
@@ -200,7 +210,13 @@ export const MoveDirectoryItemsResult = type({
})
export type MoveDirectoryItemsResult = typeof MoveDirectoryItemsResult.infer
export const moveDirectoryItemsMutationAtom = atom((get) =>
export const moveDirectoryItemsMutationOptions = ({
org,
drive,
}: {
org: Organization
drive: Drive | null
}) =>
mutationOptions({
mutationFn: async ({
targetDirectory,
@@ -209,7 +225,6 @@ export const moveDirectoryItemsMutationAtom = atom((get) =>
targetDirectory: DirectoryInfo | string
items: DirectoryItem[]
}) => {
const drive = get(currentDriveAtom)
if (!drive) {
throw new Error("Drive not found")
}
@@ -221,7 +236,7 @@ export const moveDirectoryItemsMutationAtom = atom((get) =>
const [, result] = await fetchApi(
"POST",
`/drives/${drive.id}/directories/${dirId}/content`,
`/${org.slug}/drives/${drive.id}/directories/${dirId}/content`,
{
body: JSON.stringify({
items: items.map((item) => item.id),
@@ -232,7 +247,6 @@ export const moveDirectoryItemsMutationAtom = atom((get) =>
return result
},
onSuccess: (_data, { targetDirectory, items }, _result, { client }) => {
const drive = get(currentDriveAtom)
if (!drive) return
const dirId =
@@ -241,12 +255,13 @@ export const moveDirectoryItemsMutationAtom = atom((get) =>
: targetDirectory.id
// Invalidate using base key (without params) to invalidate all queries for these directories
client.invalidateQueries({
queryKey: directoryContentQueryKey(drive.id, dirId),
queryKey: directoryContentQueryKey(org.slug, drive.id, dirId),
})
for (const item of items) {
if (item.parentId) {
client.invalidateQueries({
queryKey: directoryContentQueryKey(
org.slug,
drive.id,
item.parentId,
),
@@ -254,13 +269,17 @@ export const moveDirectoryItemsMutationAtom = atom((get) =>
}
}
},
}),
)
})
export const moveToTrashMutationAtom = atom((get) =>
export const moveToTrashMutationOptions = ({
org,
drive,
}: {
org: Organization
drive: Drive | null
}) =>
mutationOptions({
mutationFn: async (items: DirectoryItem[]) => {
const drive = get(currentDriveAtom)
if (!drive) {
throw new Error("Drive not found")
}
@@ -285,7 +304,7 @@ export const moveToTrashMutationAtom = atom((get) =>
fileDeleteParams.set("trash", "true")
deleteFilesPromise = fetchApi(
"DELETE",
`/drives/${drive.id}/files?${fileDeleteParams.toString()}`,
`/${org.slug}/drives/${drive.id}/files?${fileDeleteParams.toString()}`,
{
returns: FileInfo.array(),
},
@@ -301,7 +320,7 @@ export const moveToTrashMutationAtom = atom((get) =>
directoryDeleteParams.set("trash", "true")
deleteDirectoriesPromise = fetchApi(
"DELETE",
`/drives/${drive.id}/directories?${directoryDeleteParams.toString()}`,
`/${org.slug}/drives/${drive.id}/directories?${directoryDeleteParams.toString()}`,
{
returns: DirectoryInfo.array(),
},
@@ -318,35 +337,38 @@ export const moveToTrashMutationAtom = atom((get) =>
return [...deletedFiles, ...deletedDirectories]
},
onSuccess: (_data, items, _result, { client }) => {
const drive = get(currentDriveAtom)
if (drive) {
if (!drive) return
// Invalidate using base key (without params) to invalidate all queries for these directories
for (const item of items) {
if (item.parentId) {
client.invalidateQueries({
queryKey: directoryContentQueryKey(
org.slug,
drive.id,
item.parentId,
),
})
}
}
}
},
}),
)
})
export const renameFileMutationAtom = atom((get) =>
export const renameFileMutationOptions = ({
org,
drive,
}: {
org: Organization
drive: Drive | null
}) =>
mutationOptions({
mutationFn: async (file: FileInfo) => {
const drive = get(currentDriveAtom)
if (!drive) {
throw new Error("Drive not found")
}
const [, result] = await fetchApi(
"PATCH",
`/drives/${drive.id}/files/${file.id}`,
`/${org.slug}/drives/${drive.id}/files/${file.id}`,
{
body: JSON.stringify({ name: file.name }),
returns: FileInfo,
@@ -355,20 +377,24 @@ export const renameFileMutationAtom = atom((get) =>
return result
},
}),
)
})
export const renameDirectoryMutationAtom = atom((get) =>
export const renameDirectoryMutationOptions = ({
org,
drive,
}: {
org: Organization
drive: Drive | null
}) =>
mutationOptions({
mutationFn: async (directory: DirectoryInfo) => {
const drive = get(currentDriveAtom)
if (!drive) {
throw new Error("Drive not found")
}
const [, result] = await fetchApi(
"PATCH",
`/drives/${drive.id}/directories/${directory.id}`,
`/${org.slug}/drives/${drive.id}/directories/${directory.id}`,
{
body: JSON.stringify({ name: directory.name }),
returns: DirectoryInfo,
@@ -378,10 +404,11 @@ export const renameDirectoryMutationAtom = atom((get) =>
return result
},
onSuccess: (data, _variables, _context, { client }) => {
if (!drive) return
client.setQueryData(
get(directoryInfoQueryAtom(data.id)).queryKey,
directoryInfoQuery({ org, drive, directoryId: data.id })
.queryKey,
(prev) => (prev ? { ...prev, name: data.name } : undefined),
)
},
}),
)
})

View File

@@ -1,15 +1,14 @@
import { useAtomValue } from "jotai"
import { useEffect } from "react"
import { fileUrlAtom } from "./api"
import { currentDriveAtom } from "@/drive/drive"
import { useCurrentOrganization } from "@/organization/context"
import { buildApiUrl } from "@/lib/api"
import type { FileInfo } from "./vfs"
export function useFileUrl(file: FileInfo) {
const fileUrl = useAtomValue(fileUrlAtom(file.id))
useEffect(
() => () => {
fileUrlAtom.remove(file.id)
},
[file.id],
)
return fileUrl
const org = useCurrentOrganization()
const drive = useAtomValue(currentDriveAtom)
if (!drive) return ""
return buildApiUrl(
`/${org.slug}/drives/${drive.id}/files/${file.id}/content`,
).toString()
}