feat: basic directory navigation

This commit is contained in:
2025-09-17 00:04:12 +00:00
parent c7fb40e8eb
commit 44ce32fd84
17 changed files with 456 additions and 47 deletions

View File

@@ -6,10 +6,14 @@
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@types/bun": "latest",
"convex": "^1.27.0",
},
},
"packages/convex": {
"name": "@fileone/convex",
"dependencies": {
"@fileone/path": "workspace:*",
},
"peerDependencies": {
"convex": "^1.27.0",
"typescript": "^5",

4
convex.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://raw.githubusercontent.com/get-convex/convex-backend/refs/heads/main/npm-packages/convex/schemas/convex.schema.json",
"functions": "packages/convex"
}

View File

@@ -13,6 +13,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@types/bun": "latest"
"@types/bun": "latest",
"convex": "^1.27.0"
}
}

View File

@@ -28,9 +28,10 @@ export const fetchFiles = authenticatedQuery({
export const fetchDirectoryContent = authenticatedQuery({
args: {
directoryId: v.optional(v.id("directories")),
path: v.optional(v.string()),
},
handler: async (ctx, { directoryId }): Promise<DirectoryItem[]> => {
return await Directories.fetchContent(ctx, directoryId)
handler: async (ctx, { directoryId, path }): Promise<DirectoryItem[]> => {
return await Directories.fetchContent(ctx, { directoryId, path })
},
})

View File

@@ -21,15 +21,34 @@ export type DirectoryItemKind = DirectoryItem["kind"]
export async function fetchContent(
ctx: AuthenticatedQueryCtx,
directoryId?: Id<"directories">,
{
path,
directoryId,
}: { path?: string; directoryId?: Id<"directories"> } = {},
): Promise<DirectoryItem[]> {
let dirId: Id<"directories"> | undefined
if (path) {
dirId = await ctx.db
.query("directories")
.withIndex("byPath", (q) =>
q
.eq("userId", ctx.user._id)
.eq("path", path)
.eq("deletedAt", undefined),
)
.first()
.then((dir) => dir?._id)
} else if (directoryId) {
dirId = directoryId
}
const [files, directories] = await Promise.all([
ctx.db
.query("files")
.withIndex("byDirectoryId", (q) =>
q
.eq("userId", ctx.user._id)
.eq("directoryId", directoryId)
.eq("directoryId", dirId)
.eq("deletedAt", undefined),
)
.collect(),
@@ -38,7 +57,7 @@ export async function fetchContent(
.withIndex("byParentId", (q) =>
q
.eq("userId", ctx.user._id)
.eq("parentId", directoryId)
.eq("parentId", dirId)
.eq("deletedAt", undefined),
)
.collect(),
@@ -95,7 +114,7 @@ export async function create(
userId: ctx.user._id,
createdAt: now,
updatedAt: now,
path: parentDir ? joinPath(parentDir.path, name) : PATH_SEPARATOR,
path: parentDir ? joinPath(parentDir.path, name) : joinPath("", name),
})
}

View File

@@ -42,7 +42,7 @@ const schema = defineSchema({
"name",
"deletedAt",
])
.index("byPath", ["path", "deletedAt"]),
.index("byPath", ["userId", "path", "deletedAt"]),
})
export default schema

View File

@@ -4,6 +4,15 @@ export function baseName(path: string): string {
return path.split(PATH_SEPARATOR).pop() ?? ""
}
export function isPathAbsolute(path: string): boolean {
return path.startsWith(PATH_SEPARATOR)
}
export function joinPath(...paths: string[]): string {
return paths.join(PATH_SEPARATOR)
}
export function splitPath(path: string): string[] {
const parts = path.split(PATH_SEPARATOR)
return isPathAbsolute(path) ? parts.slice(1) : parts
}

33
packages/web/convex/_generated/api.d.ts vendored Normal file
View File

@@ -0,0 +1,33 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
declare const fullApi: ApiFromModules<{}>;
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;

View File

@@ -0,0 +1,22 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;

View File

@@ -0,0 +1,58 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { AnyDataModel } from "convex/server";
import type { GenericId } from "convex/values";
/**
* No `schema.ts` file found!
*
* This generated code has permissive types like `Doc = any` because
* Convex doesn't know your schema. If you'd like more type safety, see
* https://docs.convex.dev/using/schemas for instructions on how to add a
* schema file.
*
* After you change a schema, rerun codegen with `npx convex dev`.
*/
/**
* The names of all of your Convex tables.
*/
export type TableNames = string;
/**
* The type of a document stored in Convex.
*/
export type Doc = any;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*/
export type Id<TableName extends TableNames = TableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = AnyDataModel;

View File

@@ -0,0 +1,142 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* This function will be used to respond to HTTP requests received by a Convex
* deployment if the requests matches the path and method where this action
* is routed. Be sure to route your action in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View File

@@ -0,0 +1,89 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define a Convex HTTP action.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
* as its second.
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
*/
export const httpAction = httpActionGeneric;

View File

@@ -1,6 +1,8 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc } from "@fileone/convex/_generated/dataModel"
import type { DirectoryItem } from "@fileone/convex/model/directories"
import { useMutation } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import {
type ColumnDef,
flexRender,
@@ -81,12 +83,7 @@ const columns: ColumnDef<DirectoryItem>[] = [
case "file":
return <FileNameCell initialName={row.original.doc.name} />
case "directory":
return (
<div className="flex w-full items-center gap-2">
<DirectoryIcon className="size-4" />
{row.original.doc.name}
</div>
)
return <DirectoryNameCell directory={row.original.doc} />
}
},
size: 1000,
@@ -116,11 +113,11 @@ const columns: ColumnDef<DirectoryItem>[] = [
},
]
export function FileTable() {
export function FileTable({ path }: { path: string }) {
return (
<FileTableContextMenu>
<div className="w-full">
<FileTableContent />
<FileTableContent path={path} />
</div>
</FileTableContextMenu>
)
@@ -184,8 +181,8 @@ export function FileTableContextMenu({
)
}
export function FileTableContent() {
const directory = useQuery(api.files.fetchDirectoryContent, {})
export function FileTableContent({ path }: { path: string }) {
const directory = useQuery(api.files.fetchDirectoryContent, { path })
const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom)
const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemAtom)
@@ -377,6 +374,17 @@ function NewItemRow() {
)
}
function DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) {
return (
<div className="flex w-full items-center gap-2">
<DirectoryIcon className="size-4" />
<Link className="hover:underline" to={`/files/${directory.path}`}>
{directory.name}
</Link>
</div>
)
}
function FileNameCell({ initialName }: { initialName: string }) {
return (
<div className="flex w-full items-center gap-2">

View File

@@ -1,5 +1,7 @@
import { api } from "@fileone/convex/_generated/api"
import { splitPath } from "@fileone/path"
import { useMutation } from "@tanstack/react-query"
import { useParams } from "@tanstack/react-router"
import { useMutation as useConvexMutation } from "convex/react"
import { useSetAtom } from "jotai"
import {
@@ -8,7 +10,7 @@ import {
PlusIcon,
UploadCloudIcon,
} from "lucide-react"
import { type ChangeEvent, useRef } from "react"
import { type ChangeEvent, Fragment, useRef } from "react"
import { toast } from "sonner"
import {
DropdownMenu,
@@ -29,16 +31,23 @@ import { Button } from "../components/ui/button"
import { FileTable } from "./file-table"
import { newItemKindAtom } from "./state"
export function FilesPage() {
export function FilesPage({ path }: { path: string }) {
return (
<>
<header className="flex py-2 shrink-0 items-center gap-2 border-b px-4 w-full">
<header className="flex py-1 shrink-0 items-center gap-2 border-b px-4 w-full">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>All Files</BreadcrumbPage>
</BreadcrumbItem>
{splitPath(path).map((p) => (
<Fragment key={p}>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{p}</BreadcrumbPage>
</BreadcrumbItem>
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex flex-row gap-2">
@@ -47,7 +56,7 @@ export function FilesPage() {
</div>
</header>
<div className="w-full">
<FileTable />
<FileTable path={path} />
</div>
</>
)

View File

@@ -14,7 +14,7 @@ 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 AuthenticatedSidebarLayoutFilesRouteImport } from './routes/_authenticated/_sidebar-layout/files'
import { Route as AuthenticatedSidebarLayoutFilesSplatRouteImport } from './routes/_authenticated/_sidebar-layout/files.$'
const LoginRoute = LoginRouteImport.update({
id: '/login',
@@ -40,10 +40,10 @@ const AuthenticatedSidebarLayoutRoute =
id: '/_sidebar-layout',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedSidebarLayoutFilesRoute =
AuthenticatedSidebarLayoutFilesRouteImport.update({
id: '/files',
path: '/files',
const AuthenticatedSidebarLayoutFilesSplatRoute =
AuthenticatedSidebarLayoutFilesSplatRouteImport.update({
id: '/files/$',
path: '/files/$',
getParentRoute: () => AuthenticatedSidebarLayoutRoute,
} as any)
@@ -51,13 +51,13 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute
'/files': typeof AuthenticatedSidebarLayoutFilesRoute
'/files/$': typeof AuthenticatedSidebarLayoutFilesSplatRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute
'/files': typeof AuthenticatedSidebarLayoutFilesRoute
'/files/$': typeof AuthenticatedSidebarLayoutFilesSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -66,13 +66,13 @@ export interface FileRoutesById {
'/_authenticated/_sidebar-layout': typeof AuthenticatedSidebarLayoutRouteWithChildren
'/login_/callback': typeof LoginCallbackRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/_sidebar-layout/files': typeof AuthenticatedSidebarLayoutFilesRoute
'/_authenticated/_sidebar-layout/files/$': typeof AuthenticatedSidebarLayoutFilesSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/login' | '/login/callback' | '/' | '/files'
fullPaths: '/login' | '/login/callback' | '/' | '/files/$'
fileRoutesByTo: FileRoutesByTo
to: '/login' | '/login/callback' | '/' | '/files'
to: '/login' | '/login/callback' | '/' | '/files/$'
id:
| '__root__'
| '/_authenticated'
@@ -80,7 +80,7 @@ export interface FileRouteTypes {
| '/_authenticated/_sidebar-layout'
| '/login_/callback'
| '/_authenticated/'
| '/_authenticated/_sidebar-layout/files'
| '/_authenticated/_sidebar-layout/files/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -126,23 +126,24 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSidebarLayoutRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/_sidebar-layout/files': {
id: '/_authenticated/_sidebar-layout/files'
path: '/files'
fullPath: '/files'
preLoaderRoute: typeof AuthenticatedSidebarLayoutFilesRouteImport
'/_authenticated/_sidebar-layout/files/$': {
id: '/_authenticated/_sidebar-layout/files/$'
path: '/files/$'
fullPath: '/files/$'
preLoaderRoute: typeof AuthenticatedSidebarLayoutFilesSplatRouteImport
parentRoute: typeof AuthenticatedSidebarLayoutRoute
}
}
}
interface AuthenticatedSidebarLayoutRouteChildren {
AuthenticatedSidebarLayoutFilesRoute: typeof AuthenticatedSidebarLayoutFilesRoute
AuthenticatedSidebarLayoutFilesSplatRoute: typeof AuthenticatedSidebarLayoutFilesSplatRoute
}
const AuthenticatedSidebarLayoutRouteChildren: AuthenticatedSidebarLayoutRouteChildren =
{
AuthenticatedSidebarLayoutFilesRoute: AuthenticatedSidebarLayoutFilesRoute,
AuthenticatedSidebarLayoutFilesSplatRoute:
AuthenticatedSidebarLayoutFilesSplatRoute,
}
const AuthenticatedSidebarLayoutRouteWithChildren =

View File

@@ -0,0 +1,15 @@
import { joinPath, PATH_SEPARATOR } from "@fileone/path"
import { createFileRoute } from "@tanstack/react-router"
import { FilesPage } from "@/files/files-page"
export const Route = createFileRoute("/_authenticated/_sidebar-layout/files/$")(
{
component: RouteComponent,
},
)
function RouteComponent() {
const { _splat } = Route.useParams()
const path = _splat ? joinPath("", _splat) : PATH_SEPARATOR
return <FilesPage path={path} />
}

View File

@@ -1,6 +0,0 @@
import { createFileRoute } from "@tanstack/react-router"
import { FilesPage } from "@/files/files-page"
export const Route = createFileRoute("/_authenticated/_sidebar-layout/files")({
component: FilesPage,
})