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-router": "^1.131.41",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.13", "@tanstack/react-virtual": "^3.13.13",
"@tanstack/router-devtools": "^1.131.42", "@tanstack/router-devtools": "^1.144.0",
"arktype": "^2.1.28", "arktype": "^2.1.28",
"better-auth": "1.3.8", "better-auth": "1.3.8",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@@ -3,13 +3,16 @@ import { type } from "arktype"
import { Account } from "@/account/account" import { Account } from "@/account/account"
import { accountsQuery } from "@/account/api" import { accountsQuery } from "@/account/api"
import { fetchApi } from "@/lib/api" import { fetchApi } from "@/lib/api"
import { Organization, ORGANIZATION_KIND } from "@/organization/organization"
import { currentUserQuery } from "@/user/api" import { currentUserQuery } from "@/user/api"
import { User } from "@/user/user" import { User } from "@/user/user"
import { drivesQuery } from "@/drive/api" import { drivesQuery } from "@/drive/api"
import { Drive } from "@/drive/drive" import { Drive } from "@/drive/drive"
import { listOrganizationsQuery } from "@/organization/api"
const LoginResponseSchema = type({ const LoginResponseSchema = type({
user: User, user: User,
organizations: Organization.array(),
}) })
const SignUpResponse = type({ const SignUpResponse = type({
@@ -30,9 +33,12 @@ export const loginMutation = mutationOptions({
return result return result
}, },
onSuccess: (data, _, __, context) => { onSuccess: (data, _, __, context) => {
context.client.setQueryData(
listOrganizationsQuery.queryKey,
data.organizations,
)
context.client.setQueryData(currentUserQuery.queryKey, data.user) context.client.setQueryData(currentUserQuery.queryKey, data.user)
context.client.invalidateQueries(accountsQuery) context.client.invalidateQueries(accountsQuery)
context.client.invalidateQueries(drivesQuery)
}, },
}) })
@@ -54,6 +60,5 @@ export const signUpMutation = mutationOptions({
onSuccess: (data, _, __, context) => { onSuccess: (data, _, __, context) => {
context.client.setQueryData(currentUserQuery.queryKey, data.user) context.client.setQueryData(currentUserQuery.queryKey, data.user)
context.client.setQueryData(accountsQuery.queryKey, [data.account]) 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 { 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 { useAtom, useAtomValue, useSetAtom } from "jotai"
import { import {
CircleXIcon, CircleXIcon,
ClockIcon,
FilesIcon, FilesIcon,
FolderInputIcon, FolderInputIcon,
HouseIcon,
LogOutIcon, LogOutIcon,
ScissorsIcon, ScissorsIcon,
SettingsIcon, SettingsIcon,
TrashIcon,
User2Icon, User2Icon,
} from "lucide-react" } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
@@ -31,10 +35,10 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { formatError } from "@/lib/error" import { formatError } from "@/lib/error"
import { import { currentDriveAtom } from "@/drive/drive"
moveDirectoryItemsMutationAtom, import { listOrganizationDrivesQuery } from "@/organization/api"
rootDirectoryQueryAtom, import { useCurrentOrganization } from "@/organization/context"
} from "@/vfs/api" import { moveDirectoryItemsMutationOptions } from "@/vfs/api"
import { Button } from "../components/ui/button" import { Button } from "../components/ui/button"
import { LoadingSpinner } from "../components/ui/loading-spinner" import { LoadingSpinner } from "../components/ui/loading-spinner"
import { clearCutItemsAtom, cutItemsAtom } from "../files/store" import { clearCutItemsAtom, cutItemsAtom } from "../files/store"
@@ -66,46 +70,52 @@ export function DashboardSidebar() {
} }
function MainSidebarMenu() { function MainSidebarMenu() {
const location = useLocation() const org = useCurrentOrganization()
const matchingRoute = getRouteApi(
const isActive = (path: string) => { "/_authenticated/$orgSlug/_sidebar-layout/",
if (path === "/") { )
return location.pathname === "/" const isActive = useRouterState({
} select: (state) =>
return location.pathname.startsWith(path) state.matches.some((match) => match.routeId === matchingRoute.id),
} })
return ( return (
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive("/recent")}> <SidebarMenuButton asChild isActive={isActive}>
<Link to="/recent"> <Link to="/$orgSlug" params={{ orgSlug: org.slug }}>
<ClockIcon /> <HouseIcon />
<span>Recent</span> <span>Home</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<AllFilesItem /> <AllFilesItem />
<TrashItem />
</SidebarMenu> </SidebarMenu>
) )
} }
function AllFilesItem() { function AllFilesItem() {
const location = useLocation() const org = useCurrentOrganization()
const { data: rootDirectory } = useQuery( const matchingRoute = getRouteApi(
useAtomValue(rootDirectoryQueryAtom), "/_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 ( return (
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton asChild isActive={isActive}>
asChild <Link
isActive={location.pathname.startsWith("/directories")} to="/$orgSlug/drives/$driveId"
> params={{ orgSlug: org.slug, driveId: drive.id }}
<Link to={`/directories/${rootDirectory.id}`}> >
<FilesIcon /> <FilesIcon />
<span>All Files</span> <span>All Files</span>
</Link> </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() { function BackgroundTaskProgressItem() {
const backgroundTaskProgress = useAtomValue(backgroundTaskProgressAtom) const backgroundTaskProgress = useAtomValue(backgroundTaskProgressAtom)
@@ -160,9 +147,12 @@ function CutItemsCard() {
const clearCutItems = useSetAtom(clearCutItemsAtom) const clearCutItems = useSetAtom(clearCutItemsAtom)
const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom) const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom)
const moveDirectoryItemsMutation = useAtomValue( const org = useCurrentOrganization()
moveDirectoryItemsMutationAtom, const drive = useAtomValue(currentDriveAtom)
) const moveDirectoryItemsMutation = moveDirectoryItemsMutationOptions({
org,
drive,
})
const { mutate: moveItems } = useMutation({ const { mutate: moveItems } = useMutation({
...moveDirectoryItemsMutation, ...moveDirectoryItemsMutation,

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ import { useMutation } from "@tanstack/react-query"
import { useAtomValue } from "jotai" import { useAtomValue } from "jotai"
import { useId } from "react" import { useId } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { currentDriveAtom } from "@/drive/drive"
import { useCurrentOrganization } from "@/organization/context"
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@@ -11,7 +13,10 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { renameDirectoryMutationAtom, renameFileMutationAtom } from "@/vfs/api" import {
renameDirectoryMutationOptions,
renameFileMutationOptions,
} from "@/vfs/api"
import type { DirectoryItem } from "@/vfs/vfs" import type { DirectoryItem } from "@/vfs/vfs"
type RenameFileDialogProps = { type RenameFileDialogProps = {
@@ -27,8 +32,10 @@ export function RenameFileDialog({
}: RenameFileDialogProps) { }: RenameFileDialogProps) {
const formId = useId() const formId = useId()
const renameFileMutation = useAtomValue(renameFileMutationAtom) const org = useCurrentOrganization()
const renameDirectoryMutation = useAtomValue(renameDirectoryMutationAtom) const drive = useAtomValue(currentDriveAtom)
const renameFileMutation = renameFileMutationOptions({ org, drive })
const renameDirectoryMutation = renameDirectoryMutationOptions({ org, drive })
const { mutate: renameFile, isPending: isRenamingFile } = useMutation({ const { mutate: renameFile, isPending: isRenamingFile } = useMutation({
...renameFileMutation, ...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 { fetchApi } from "@/lib/api"
import { Drive } from "./drive" import { Drive } from "./drive"
import type { Organization } from "@/organization/organization"
export const drivesQuery = queryOptions({ export const drivesQuery = (org: Organization | null) =>
queryKey: ["drives"], queryOptions({
queryFn: async () => queryKey: ["organizations", org?.slug, "drives"],
fetchApi("GET", "/drives", { queryFn: org
returns: Drive.array(), ? () =>
}).then(([_, result]) => result), fetchApi("GET", `/${org.slug}/drives`, {
}) returns: Drive.array(),
})
: skipToken,
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,25 +5,27 @@ export type ApiRoute =
| "/auth/tokens" | "/auth/tokens"
| "/accounts" | "/accounts"
| `/accounts/${string}` | `/accounts/${string}`
| "/drives" | `/${string}/drives`
| `/drives/${string}` | `/${string}/drives/${string}`
| `/drives/${string}/uploads` | `/${string}/drives/${string}/uploads`
| `/drives/${string}/uploads/${string}/content` | `/${string}/drives/${string}/uploads/${string}/content`
| `/drives/${string}/uploads/${string}` | `/${string}/drives/${string}/uploads/${string}`
| `/drives/${string}/files${string}` | `/${string}/drives/${string}/files${string}`
| `/drives/${string}/files/${string}` | `/${string}/drives/${string}/files/${string}`
| `/drives/${string}/files/${string}/content` | `/${string}/drives/${string}/files/${string}/content`
| `/drives/${string}/files/${string}/shares${string}` | `/${string}/drives/${string}/files/${string}/shares${string}`
| `/drives/${string}/directories` | `/${string}/drives/${string}/directories${string}`
| `/drives/${string}/directories/${string}` | `/${string}/drives/${string}/directories/${string}`
| `/drives/${string}/directories/${string}/content` | `/${string}/drives/${string}/directories/${string}/content`
| `/drives/${string}/directories/${string}/shares${string}` | `/${string}/drives/${string}/directories/${string}/shares${string}`
| `/drives/${string}/shares` | `/${string}/drives/${string}/shares`
| `/drives/${string}/shares/${string}` | `/${string}/drives/${string}/shares/${string}`
| `/shares/${string}` | `/shares/${string}`
| `/shares/${string}/directories${string}` | `/shares/${string}/directories${string}`
| `/shares/${string}/files${string}` | `/shares/${string}/files${string}`
| `/organizations/${string}`
| "/users/me" | "/users/me"
| "/users/me/organizations"
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
@@ -44,11 +46,7 @@ export class ApiError extends Error {
export const Nothing = type({}) export const Nothing = type({})
export type Nothing = typeof Nothing.infer export type Nothing = typeof Nothing.infer
export async function fetchApi<Schema extends type.Any>( export function buildApiUrl(route: ApiRoute): URL {
method: HttpMethod,
route: ApiRoute,
init: RequestInit & { returns: Schema },
): Promise<[response: Response, data: Schema["inferOut"]]> {
let path: string let path: string
if (baseApiUrl.pathname) { if (baseApiUrl.pathname) {
if (baseApiUrl.pathname.endsWith("/")) { if (baseApiUrl.pathname.endsWith("/")) {
@@ -59,7 +57,15 @@ export async function fetchApi<Schema extends type.Any>(
} else { } else {
path = route 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, { const response = await fetch(url, {
credentials: "include", credentials: "include",
...init, ...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. // 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. // 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 rootRouteImport } from './routes/__root'
import { Route as SignUpRouteImport } from './routes/sign-up' import { Route as SignUpRouteImport } from './routes/sign-up'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
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 AuthenticatedOrgSlugSidebarLayoutRouteImport } from './routes/_authenticated/$orgSlug/_sidebar-layout'
import { Route as AuthenticatedSidebarLayoutHomeRouteImport } from './routes/_authenticated/_sidebar-layout/home' import { Route as AuthenticatedOrgSlugSidebarLayoutIndexRouteImport } from './routes/_authenticated/$orgSlug/_sidebar-layout/index'
import { Route as SharesShareIdDirectoriesDirectoryIdRouteImport } from './routes/shares/$shareId.directories.$directoryId' 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({ const SignUpRoute = SignUpRouteImport.update({
id: '/sign-up', id: '/sign-up',
@@ -33,6 +40,11 @@ const AuthenticatedRoute = AuthenticatedRouteImport.update({
id: '/_authenticated', id: '/_authenticated',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AuthenticatedOrgSlugRoute = AuthenticatedOrgSlugRouteImport.update({
id: '/$orgSlug',
path: '/$orgSlug',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({ const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@@ -43,16 +55,16 @@ const LoginCallbackRoute = LoginCallbackRouteImport.update({
path: '/login/callback', path: '/login/callback',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AuthenticatedSidebarLayoutRoute = const AuthenticatedOrgSlugSidebarLayoutRoute =
AuthenticatedSidebarLayoutRouteImport.update({ AuthenticatedOrgSlugSidebarLayoutRouteImport.update({
id: '/_sidebar-layout', id: '/_sidebar-layout',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedOrgSlugRoute,
} as any) } as any)
const AuthenticatedSidebarLayoutHomeRoute = const AuthenticatedOrgSlugSidebarLayoutIndexRoute =
AuthenticatedSidebarLayoutHomeRouteImport.update({ AuthenticatedOrgSlugSidebarLayoutIndexRouteImport.update({
id: '/home', id: '/',
path: '/home', path: '/',
getParentRoute: () => AuthenticatedSidebarLayoutRoute, getParentRoute: () => AuthenticatedOrgSlugSidebarLayoutRoute,
} as any) } as any)
const SharesShareIdDirectoriesDirectoryIdRoute = const SharesShareIdDirectoriesDirectoryIdRoute =
SharesShareIdDirectoriesDirectoryIdRouteImport.update({ SharesShareIdDirectoriesDirectoryIdRouteImport.update({
@@ -60,11 +72,17 @@ const SharesShareIdDirectoriesDirectoryIdRoute =
path: '/shares/$shareId/directories/$directoryId', path: '/shares/$shareId/directories/$directoryId',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute = const AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute =
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport.update({ AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRouteImport.update({
id: '/drives/$driveId',
path: '/drives/$driveId',
getParentRoute: () => AuthenticatedOrgSlugSidebarLayoutRoute,
} as any)
const AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute =
AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRouteImport.update({
id: '/directories/$directoryId', id: '/directories/$directoryId',
path: '/directories/$directoryId', path: '/directories/$directoryId',
getParentRoute: () => AuthenticatedSidebarLayoutRoute, getParentRoute: () => AuthenticatedOrgSlugSidebarLayoutRoute,
} as any) } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
@@ -72,30 +90,35 @@ export interface FileRoutesByFullPath {
'/sign-up': typeof SignUpRoute '/sign-up': typeof SignUpRoute
'/login/callback': typeof LoginCallbackRoute '/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute '/$orgSlug': typeof AuthenticatedOrgSlugSidebarLayoutRouteWithChildren
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute '/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute
'/$orgSlug/': typeof AuthenticatedOrgSlugSidebarLayoutIndexRoute
'/$orgSlug/directories/$directoryId': typeof AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute
'/$orgSlug/drives/$driveId': typeof AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/sign-up': typeof SignUpRoute '/sign-up': typeof SignUpRoute
'/login/callback': typeof LoginCallbackRoute '/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute '/$orgSlug': typeof AuthenticatedOrgSlugSidebarLayoutIndexRoute
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute '/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute
'/$orgSlug/directories/$directoryId': typeof AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute
'/$orgSlug/drives/$driveId': typeof AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/_authenticated': typeof AuthenticatedRouteWithChildren '/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/sign-up': typeof SignUpRoute '/sign-up': typeof SignUpRoute
'/_authenticated/_sidebar-layout': typeof AuthenticatedSidebarLayoutRouteWithChildren
'/login_/callback': typeof LoginCallbackRoute '/login_/callback': typeof LoginCallbackRoute
'/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/_sidebar-layout/home': typeof AuthenticatedSidebarLayoutHomeRoute '/_authenticated/$orgSlug': typeof AuthenticatedOrgSlugRouteWithChildren
'/_authenticated/_sidebar-layout/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute '/_authenticated/$orgSlug/_sidebar-layout': typeof AuthenticatedOrgSlugSidebarLayoutRouteWithChildren
'/shares/$shareId/directories/$directoryId': typeof SharesShareIdDirectoriesDirectoryIdRoute '/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 { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -104,29 +127,34 @@ export interface FileRouteTypes {
| '/sign-up' | '/sign-up'
| '/login/callback' | '/login/callback'
| '/' | '/'
| '/home' | '/$orgSlug'
| '/directories/$directoryId'
| '/shares/$shareId/directories/$directoryId' | '/shares/$shareId/directories/$directoryId'
| '/$orgSlug/'
| '/$orgSlug/directories/$directoryId'
| '/$orgSlug/drives/$driveId'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/login' | '/login'
| '/sign-up' | '/sign-up'
| '/login/callback' | '/login/callback'
| '/' | '/'
| '/home' | '/$orgSlug'
| '/directories/$directoryId'
| '/shares/$shareId/directories/$directoryId' | '/shares/$shareId/directories/$directoryId'
| '/$orgSlug/directories/$directoryId'
| '/$orgSlug/drives/$driveId'
id: id:
| '__root__' | '__root__'
| '/_authenticated' | '/_authenticated'
| '/login' | '/login'
| '/sign-up' | '/sign-up'
| '/_authenticated/_sidebar-layout'
| '/login_/callback' | '/login_/callback'
| '/_authenticated/' | '/_authenticated/'
| '/_authenticated/_sidebar-layout/home' | '/_authenticated/$orgSlug'
| '/_authenticated/_sidebar-layout/directories/$directoryId' | '/_authenticated/$orgSlug/_sidebar-layout'
| '/shares/$shareId/directories/$directoryId' | '/shares/$shareId/directories/$directoryId'
| '/_authenticated/$orgSlug/_sidebar-layout/'
| '/_authenticated/$orgSlug/_sidebar-layout/directories/$directoryId'
| '/_authenticated/$orgSlug/_sidebar-layout/drives/$driveId'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -160,6 +188,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedRouteImport preLoaderRoute: typeof AuthenticatedRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_authenticated/$orgSlug': {
id: '/_authenticated/$orgSlug'
path: '/$orgSlug'
fullPath: '/$orgSlug'
preLoaderRoute: typeof AuthenticatedOrgSlugRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/': { '/_authenticated/': {
id: '/_authenticated/' id: '/_authenticated/'
path: '/' path: '/'
@@ -174,19 +209,19 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginCallbackRouteImport preLoaderRoute: typeof LoginCallbackRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_authenticated/_sidebar-layout': { '/_authenticated/$orgSlug/_sidebar-layout': {
id: '/_authenticated/_sidebar-layout' id: '/_authenticated/$orgSlug/_sidebar-layout'
path: '' path: '/$orgSlug'
fullPath: '' fullPath: '/$orgSlug'
preLoaderRoute: typeof AuthenticatedSidebarLayoutRouteImport preLoaderRoute: typeof AuthenticatedOrgSlugSidebarLayoutRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedOrgSlugRoute
} }
'/_authenticated/_sidebar-layout/home': { '/_authenticated/$orgSlug/_sidebar-layout/': {
id: '/_authenticated/_sidebar-layout/home' id: '/_authenticated/$orgSlug/_sidebar-layout/'
path: '/home' path: '/'
fullPath: '/home' fullPath: '/$orgSlug/'
preLoaderRoute: typeof AuthenticatedSidebarLayoutHomeRouteImport preLoaderRoute: typeof AuthenticatedOrgSlugSidebarLayoutIndexRouteImport
parentRoute: typeof AuthenticatedSidebarLayoutRoute parentRoute: typeof AuthenticatedOrgSlugSidebarLayoutRoute
} }
'/shares/$shareId/directories/$directoryId': { '/shares/$shareId/directories/$directoryId': {
id: '/shares/$shareId/directories/$directoryId' id: '/shares/$shareId/directories/$directoryId'
@@ -195,41 +230,64 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SharesShareIdDirectoriesDirectoryIdRouteImport preLoaderRoute: typeof SharesShareIdDirectoriesDirectoryIdRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_authenticated/_sidebar-layout/directories/$directoryId': { '/_authenticated/$orgSlug/_sidebar-layout/drives/$driveId': {
id: '/_authenticated/_sidebar-layout/directories/$directoryId' 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' path: '/directories/$directoryId'
fullPath: '/directories/$directoryId' fullPath: '/$orgSlug/directories/$directoryId'
preLoaderRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport preLoaderRoute: typeof AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRouteImport
parentRoute: typeof AuthenticatedSidebarLayoutRoute parentRoute: typeof AuthenticatedOrgSlugSidebarLayoutRoute
} }
} }
} }
interface AuthenticatedSidebarLayoutRouteChildren { interface AuthenticatedOrgSlugSidebarLayoutRouteChildren {
AuthenticatedSidebarLayoutHomeRoute: typeof AuthenticatedSidebarLayoutHomeRoute AuthenticatedOrgSlugSidebarLayoutIndexRoute: typeof AuthenticatedOrgSlugSidebarLayoutIndexRoute
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute: typeof AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute
AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute: typeof AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute
} }
const AuthenticatedSidebarLayoutRouteChildren: AuthenticatedSidebarLayoutRouteChildren = const AuthenticatedOrgSlugSidebarLayoutRouteChildren: AuthenticatedOrgSlugSidebarLayoutRouteChildren =
{ {
AuthenticatedSidebarLayoutHomeRoute: AuthenticatedSidebarLayoutHomeRoute, AuthenticatedOrgSlugSidebarLayoutIndexRoute:
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute: AuthenticatedOrgSlugSidebarLayoutIndexRoute,
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute, AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute:
AuthenticatedOrgSlugSidebarLayoutDirectoriesDirectoryIdRoute,
AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute:
AuthenticatedOrgSlugSidebarLayoutDrivesDriveIdRoute,
} }
const AuthenticatedSidebarLayoutRouteWithChildren = const AuthenticatedOrgSlugSidebarLayoutRouteWithChildren =
AuthenticatedSidebarLayoutRoute._addFileChildren( AuthenticatedOrgSlugSidebarLayoutRoute._addFileChildren(
AuthenticatedSidebarLayoutRouteChildren, AuthenticatedOrgSlugSidebarLayoutRouteChildren,
) )
interface AuthenticatedOrgSlugRouteChildren {
AuthenticatedOrgSlugSidebarLayoutRoute: typeof AuthenticatedOrgSlugSidebarLayoutRouteWithChildren
}
const AuthenticatedOrgSlugRouteChildren: AuthenticatedOrgSlugRouteChildren = {
AuthenticatedOrgSlugSidebarLayoutRoute:
AuthenticatedOrgSlugSidebarLayoutRouteWithChildren,
}
const AuthenticatedOrgSlugRouteWithChildren =
AuthenticatedOrgSlugRoute._addFileChildren(AuthenticatedOrgSlugRouteChildren)
interface AuthenticatedRouteChildren { interface AuthenticatedRouteChildren {
AuthenticatedSidebarLayoutRoute: typeof AuthenticatedSidebarLayoutRouteWithChildren
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedOrgSlugRoute: typeof AuthenticatedOrgSlugRouteWithChildren
} }
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedSidebarLayoutRoute: AuthenticatedSidebarLayoutRouteWithChildren,
AuthenticatedIndexRoute: AuthenticatedIndexRoute, AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedOrgSlugRoute: AuthenticatedOrgSlugRouteWithChildren,
} }
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( 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 "@/styles/globals.css"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClientProvider } from "@tanstack/react-query"
import { createRootRoute, Outlet } from "@tanstack/react-router" import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"
import { Provider } from "jotai" import { Provider } from "jotai"
import { useHydrateAtoms } from "jotai/utils" import { useHydrateAtoms } from "jotai/utils"
import { queryClientAtom } from "jotai-tanstack-query" import { queryClientAtom } from "jotai-tanstack-query"
import type React from "react" import type React from "react"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import { defaultOnError } from "@/lib/error"
import { useKeyboardModifierListener } from "@/lib/keyboard" 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, component: RootLayout,
}) })
const queryClient = new QueryClient({
defaultOptions: {
queries: {
throwOnError: false,
},
mutations: {
onError: defaultOnError,
throwOnError: false,
},
},
})
function HydrateAtoms({ children }: React.PropsWithChildren) { function HydrateAtoms({ children }: React.PropsWithChildren) {
useHydrateAtoms(new Map([[queryClientAtom, queryClient]])) useHydrateAtoms(new Map([[queryClientAtom, queryClient]]))
return children return children

View File

@@ -1,31 +1,22 @@
import { useQuery } from "@tanstack/react-query"
import { createFileRoute, Navigate, Outlet } from "@tanstack/react-router" 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 { LoadingSpinner } from "@/components/ui/loading-spinner"
import { drivesQuery } from "@/drive/api" import { listOrganizationsQuery } from "@/organization/api"
import { currentDriveAtom } from "@/drive/drive" import { ORGANIZATION_KIND } from "@/organization/organization"
export const Route = createFileRoute("/_authenticated")({ export const Route = createFileRoute("/_authenticated")({
component: AuthenticatedLayout, component: AuthenticatedLayout,
}) loader: ({ context }) => {
context.queryClient.ensureQueryData(listOrganizationsQuery)
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)
}
}) })
function AuthenticatedLayout() { function AuthenticatedLayout() {
const { data: drives, isLoading: isLoadingDrives } = const { data: orgs, isLoading: isLoadingOrgs } = useQuery(
useAtomValue(drivesAtom) listOrganizationsQuery,
)
useAtomValue(selectFirstDriveEffect) if (isLoadingOrgs) {
if (isLoadingDrives) {
return ( return (
<div className="flex h-screen w-full items-center justify-center"> <div className="flex h-screen w-full items-center justify-center">
<LoadingSpinner className="size-10" /> <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" /> 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 { lazy, Suspense, useCallback, useContext } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { currentDriveAtom } from "@/drive/drive" import { currentDriveAtom } from "@/drive/drive"
import { useCurrentOrganization } from "@/organization/context"
import { DirectoryIcon } from "@/components/icons/directory-icon" import { DirectoryIcon } from "@/components/icons/directory-icon"
import { TextFileIcon } from "@/components/icons/text-file-icon" import { TextFileIcon } from "@/components/icons/text-file-icon"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -54,10 +55,10 @@ import type {
import { import {
DIRECTORY_CONTENT_ORDER_BY, DIRECTORY_CONTENT_ORDER_BY,
DIRECTORY_CONTENT_ORDER_DIRECTION, DIRECTORY_CONTENT_ORDER_DIRECTION,
directoryContentQueryAtom, directoryContentQuery,
directoryContentQueryKey, directoryContentQueryKey,
directoryInfoQueryAtom, directoryInfoQuery,
moveToTrashMutationAtom, moveToTrashMutationOptions,
} from "@/vfs/api" } from "@/vfs/api"
import { import {
optimisticallyRemoveDirectoryItems, optimisticallyRemoveDirectoryItems,
@@ -69,7 +70,7 @@ import type {
DirectoryItem, DirectoryItem,
FileInfo, FileInfo,
} from "@/vfs/vfs" } 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 // 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
@@ -93,7 +94,7 @@ const DirectoryContentPageParams = type({
}) })
export const Route = createFileRoute( export const Route = createFileRoute(
"/_authenticated/_sidebar-layout/directories/$directoryId", "/_authenticated/$orgSlug/_sidebar-layout/directories/$directoryId",
)({ )({
validateSearch: DirectoryContentPageParams, validateSearch: DirectoryContentPageParams,
component: RouteComponent, component: RouteComponent,
@@ -144,14 +145,16 @@ const mockTableAtom = import.meta.env.DEV
// MARK: page entry // MARK: page entry
function RouteComponent() { function RouteComponent() {
const { directoryId } = Route.useParams() const { directoryId, orgSlug } = Route.useParams()
const drive = useAtomValue(currentDriveAtom)
const org = useCurrentOrganization()
const { data: directoryInfo, isLoading: isLoadingDirectoryInfo } = useQuery( const { data: directoryInfo, isLoading: isLoadingDirectoryInfo } = useQuery(
useAtomValue(directoryInfoQueryAtom(directoryId)), directoryInfoQuery({ org, drive, directoryId }),
) )
const directoryUrlById = useCallback( const directoryUrlById = useCallback(
(directoryId: string) => `/directories/${directoryId}`, (directoryId: string) => `/${orgSlug}/directories/${directoryId}`,
[], [orgSlug],
) )
if (isLoadingDirectoryInfo) { if (isLoadingDirectoryInfo) {
@@ -260,16 +263,18 @@ function _DirectoryContentTable() {
const useMock = useAtomValue(mockTableAtom) const useMock = useAtomValue(mockTableAtom)
const { directory } = useContext(DirectoryPageContext) const { directory } = useContext(DirectoryPageContext)
const navigate = Route.useNavigate() const navigate = Route.useNavigate()
const drive = useAtomValue(currentDriveAtom)
const org = useCurrentOrganization()
const search = Route.useSearch() const search = Route.useSearch()
const query = useAtomValue( const query = directoryContentQuery({
directoryContentQueryAtom({ org,
directoryId: directory.id, drive,
orderBy: search.orderBy, directoryId: directory.id,
direction: search.direction, orderBy: search.orderBy,
limit: 100, direction: search.direction,
}), limit: 100,
) })
const setOpenedFile = useSetAtom(openedFileAtom) const setOpenedFile = useSetAtom(openedFileAtom)
const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom) const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom)
@@ -281,9 +286,10 @@ function _DirectoryContentTable() {
[setOpenedFile], [setOpenedFile],
) )
const { orgSlug } = Route.useParams()
const directoryUrlFn = useCallback( const directoryUrlFn = useCallback(
(directory: DirectoryInfo) => `/directories/${directory.id}`, (directory: DirectoryInfo) => `/${orgSlug}/directories/${directory.id}`,
[], [orgSlug],
) )
const handleContextMenuRequest = useCallback( const handleContextMenuRequest = useCallback(
@@ -379,11 +385,12 @@ function DirectoryContentContextMenu({
const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom) const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom)
const setCutItems = useSetAtom(cutItemsAtom) const setCutItems = useSetAtom(cutItemsAtom)
const drive = useAtomValue(currentDriveAtom) const drive = useAtomValue(currentDriveAtom)
const org = useCurrentOrganization()
const { directory } = useContext(DirectoryPageContext) const { directory } = useContext(DirectoryPageContext)
const search = Route.useSearch() const search = Route.useSearch()
const setActiveDialogData = useSetAtom(activeDialogDataAtom) const setActiveDialogData = useSetAtom(activeDialogDataAtom)
const moveToTrashMutation = useAtomValue(moveToTrashMutationAtom) const moveToTrashMutation = moveToTrashMutationOptions({ org, drive })
const { mutate: moveToTrash } = useMutation({ const { mutate: moveToTrash } = useMutation({
...moveToTrashMutation, ...moveToTrashMutation,
onMutate: (items, { client }) => { onMutate: (items, { client }) => {
@@ -394,7 +401,7 @@ function DirectoryContentContextMenu({
return null return null
} }
return optimisticallyRemoveDirectoryItems(client, { return optimisticallyRemoveDirectoryItems(client, {
queryKey: directoryContentQueryKey(drive.id, directory.id, { queryKey: directoryContentQueryKey(org.slug, drive.id, directory.id, {
orderBy: search.orderBy, orderBy: search.orderBy,
direction: search.direction, 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 { 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/")({ export const Route = createFileRoute("/_authenticated/")({
component: RouteComponent, component: RouteComponent,
loader: ({ context }) => {
void context.queryClient.ensureQueryData(listOrganizationsQuery)
},
}) })
function RouteComponent() { 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" } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils" 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")({ export const Route = createFileRoute("/login")({
component: RouteComponent, component: RouteComponent,
@@ -73,10 +76,19 @@ function LoginForm() {
...loginMutation, ...loginMutation,
onSuccess: (data, vars, result, context) => { onSuccess: (data, vars, result, context) => {
loginMutation.onSuccess?.(data, vars, result, context) loginMutation.onSuccess?.(data, vars, result, context)
navigate({ const personalOrg = data.organizations.find(
to: "/", (org) => org.kind === ORGANIZATION_KIND.personal,
replace: true, )
}) if (personalOrg) {
context.client.ensureQueryData(
organizationQuery(personalOrg.slug),
)
navigate({
to: "/$orgSlug",
params: { orgSlug: personalOrg.slug },
replace: true,
})
}
}, },
}) })

View File

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

View File

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

View File

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

View File

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