Compare commits

...

22 Commits

Author SHA1 Message Date
82ce2ac90c build: add lazygit to devcontainer
Install lazygit from latest GitHub release for improved git workflow
in the development environment.

Co-authored-by: Ona <no-reply@ona.com>
2025-09-21 22:30:10 +00:00
8062fdb7f3 wip: directory/file move name conflict check 2025-09-21 22:24:24 +00:00
29eab87c71 feat: impl multi file/dir moving
Co-authored-by: Ona <no-reply@ona.com>
2025-09-21 17:03:50 +00:00
a331276c43 build: add environment variables template
- Add .env.sample with Convex and WorkOS configuration
- Include both server and client-side environment variables

Co-authored-by: Ona <no-reply@ona.com>
2025-09-21 16:06:59 +00:00
6323a3f41c docs: add project guidelines and commit conventions
- Add AGENTS.md with tech stack, project structure, and code styles
- Add dev/docs/dashboard.md with frontend-specific guidelines
- Include conventional commit types with examples

Co-authored-by: Ona <no-reply@ona.com>
2025-09-21 16:05:39 +00:00
bc1f655aab fix: auto focus new dir name input on create dir
Co-authored-by: Ona <no-reply@ona.com>
2025-09-21 15:45:40 +00:00
de8a53d7a8 feat: double click to open directory in dir table 2025-09-21 15:18:32 +00:00
39c0268ded fix: ctx menu sometimes not opening immediately 2025-09-21 15:16:22 +00:00
19535396ad fix: directory table multi select 2025-09-21 15:12:05 +00:00
7eefe2b96e feat: allow file drop on path breadcrumb 2025-09-20 23:54:27 +00:00
0f5b1f79ff refactor: directory path handling
intsead of storing path as field in directories table, it is derived on demand, because it makes moving directories a heck lot eaiser

Co-authored-by: Ona <no-reply@ona.com>
2025-09-20 23:23:28 +00:00
cd2c10fbed refactor: extract file drop logic into hook
Co-authored-by: Ona <no-reply@ona.com>
2025-09-20 22:43:31 +00:00
d6b693b54b feat: basic file drag and drop 2025-09-20 22:25:01 +00:00
daae016cf3 fix: image preview dialog mobile layout 2025-09-20 20:16:51 +00:00
40b9c84b15 feat: add download btn to img preview dialog 2025-09-20 20:05:50 +00:00
367e248062 feat: impl image preview dialog 2025-09-20 19:55:20 +00:00
ddd2afb879 fix: breadcrumb all files link 2025-09-20 13:29:23 +00:00
6310a15c78 fix: regenerate routes 2025-09-20 13:21:13 +00:00
75ee997d04 refactor: remove file route 2025-09-20 13:20:15 +00:00
c10edc0df2 feat: add placeholder /home page 2025-09-20 13:19:41 +00:00
4dec5e5bb5 feat: make file upload dir id required 2025-09-19 23:17:13 +00:00
8b699a8f51 fix: directory link in dir table
Co-authored-by: Ona <no-reply@ona.com>
2025-09-19 23:16:31 +00:00
32 changed files with 1209 additions and 238 deletions

View File

@@ -9,6 +9,13 @@ RUN apt-get update && apt-get install -y \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Install lazygit
RUN LAZYGIT_VERSION=$(curl -s "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | grep -Po '"tag_name": "v\K[^"]*') \
&& curl -Lo lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" \
&& tar xf lazygit.tar.gz lazygit \
&& install lazygit /usr/local/bin \
&& rm lazygit.tar.gz lazygit
# Install Bun as the node user
USER node
RUN curl -fsSL https://bun.sh/install | bash

10
.env.sample Normal file
View File

@@ -0,0 +1,10 @@
CONVEX_SELF_HOSTED_URL=
CONVEX_SELF_HOSTED_ADMIN_KEY=
CONVEX_URL=
WORKOS_CLIENT_ID=
WORKOS_CLIENT_SECRET=
WORKOS_API_KEY=
BUN_PUBLIC_CONVEX_URL=
BUN_PUBLIC_WORKOS_CLIENT_ID=
BUN_PUBLIC_WORKOS_REDIRECT_URI=

27
AGENTS.md Normal file
View File

@@ -0,0 +1,27 @@
# Project tech stack
frontend: react, shadcn, tailwindcss
backend: convex
# Project structure
This project uses npm workspaces.
- `packages/convex` - convex functions and models
- `packages/web` - frontend dashboard
- `packages/path` - path utils
# General Guidelines
- For frontend dashboard guidelines, refer to dev/docs/dashboard.md
- Always use bun for package management and running npm scripts.
- When commiting, follow conventional commits: `[type]: msg` and clarify in commit body if necessary. keep your commit header to <= 50 characters.
- `feat:` for new features
- `fix:` for bug fixes
- `docs:` for documentation
- `refactor:` for code restructuring
- `style:` for formatting changes
- `test:` for adding tests
- `ci:` for CI/CD pipeline changes
- `build:` for build system or dependency changes
# Code styles
- file names should always be kebab-case except in convex code, in which case try to use single word file names.

View File

@@ -50,6 +50,7 @@
"convex-helpers": "^0.1.104",
"jotai": "^2.14.0",
"lucide-react": "^0.544.0",
"motion": "^12.23.16",
"next-themes": "^0.4.6",
"react": "^19",
"react-dom": "^19",
@@ -524,6 +525,8 @@
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"framer-motion": ["framer-motion@12.23.16", "", { "dependencies": { "motion-dom": "^12.23.12", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N81A8hiHqVsexOzI3wzkibyLURW1nEJsZaRuctPhG4AdbbciYu+bKJq9I2lQFzAO4Bx3h4swI6pBbF/Hu7f7BA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
@@ -572,6 +575,12 @@
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
"motion": ["motion@12.23.16", "", { "dependencies": { "framer-motion": "^12.23.16", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-8vVuxZgcfGZm4kgSqFgGrhQ+6034y4UuEsqCX8s7UYeoQ+NO3R9LV5AyDlVr2Mb7xvS7ZM5s/XkTurWbWQ+UHA=="],
"motion-dom": ["motion-dom@12.23.12", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw=="],
"motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],

19
dev/docs/dashboard.md Normal file
View File

@@ -0,0 +1,19 @@
# Context
- tech stack: react, tailwindcss, shadcn, tanstack router, tanstack table, jotai
- `@/` maps to `src/`
# Key files
- `src/components`: reusable React components
- `src/lib`: reusable util code
- `src/routes`: tanstack file routes
# Guidelines
- ALWAYS use absolute import using `@/`, unless the file is in the same directory.
- ALWAYS use kebab-case for file names
- when importing useQuery/useMutation from convex/react, alias them with useConvexQuery/useConvexMutation
- Do not attempt to create a preview server. There is one running already at port 3001.
- After create a new file route, run `bunx tsr generate`
- No testing is in place right now, so skip that for now.

View File

@@ -16,4 +16,4 @@
"@types/bun": "latest",
"convex": "^1.27.0"
}
}
}

View File

@@ -14,10 +14,12 @@ import type {
FunctionReference,
} from "convex/server";
import type * as files from "../files.js";
import type * as filesystem from "../filesystem.js";
import type * as functions from "../functions.js";
import type * as model_directories from "../model/directories.js";
import type * as model_error from "../model/error.js";
import type * as model_files from "../model/files.js";
import type * as model_filesystem from "../model/filesystem.js";
import type * as model_user from "../model/user.js";
import type * as users from "../users.js";
@@ -31,10 +33,12 @@ import type * as users from "../users.js";
*/
declare const fullApi: ApiFromModules<{
files: typeof files;
filesystem: typeof filesystem;
functions: typeof functions;
"model/directories": typeof model_directories;
"model/error": typeof model_error;
"model/files": typeof model_files;
"model/filesystem": typeof model_filesystem;
"model/user": typeof model_user;
users: typeof users;
}>;

View File

@@ -3,6 +3,7 @@ import { v } from "convex/values"
import { authenticatedMutation, authenticatedQuery } from "./functions"
import type { DirectoryItem } from "./model/directories"
import * as Directories from "./model/directories"
import * as Err from "./model/error"
import * as Files from "./model/files"
export const generateUploadUrl = authenticatedMutation({
@@ -12,6 +13,15 @@ export const generateUploadUrl = authenticatedMutation({
},
})
export const generateFileUrl = authenticatedQuery({
args: {
storageId: v.id("_storage"),
},
handler: async (ctx, { storageId }) => {
return await ctx.storage.getUrl(storageId)
},
})
export const fetchFiles = authenticatedQuery({
args: {
directoryId: v.optional(v.id("directories")),
@@ -44,10 +54,9 @@ export const fetchDirectory = authenticatedQuery({
export const fetchDirectoryContent = authenticatedQuery({
args: {
directoryId: v.optional(v.id("directories")),
path: v.optional(v.string()),
},
handler: async (ctx, { directoryId, path }): Promise<DirectoryItem[]> => {
return await Directories.fetchContent(ctx, { directoryId, path })
handler: async (ctx, { directoryId }): Promise<DirectoryItem[]> => {
return await Directories.fetchContent(ctx, { directoryId })
},
})
@@ -68,7 +77,7 @@ export const saveFile = authenticatedMutation({
args: {
name: v.string(),
size: v.number(),
directoryId: v.optional(v.id("directories")),
directoryId: v.id("directories"),
storageId: v.id("_storage"),
mimeType: v.optional(v.string()),
},

View File

@@ -0,0 +1,62 @@
import { v } from "convex/values"
import { authenticatedMutation } from "./functions"
import * as Directories from "./model/directories"
import * as Err from "./model/error"
import * as Files from "./model/files"
import type { DirectoryHandle, FileHandle } from "./model/filesystem"
const VDirectoryHandle = v.object({
kind: v.literal("directory"),
id: v.id("directories"),
})
const VFileHandle = v.object({
kind: v.literal("file"),
id: v.id("files"),
})
const VFileSystemHandle = v.union(VFileHandle, VDirectoryHandle)
export const moveItems = authenticatedMutation({
args: {
targetDirectory: VDirectoryHandle,
items: v.array(VFileSystemHandle),
},
handler: async (ctx, { targetDirectory: targetDirectoryHandle, items }) => {
const targetDirectory = await Directories.fetchHandle(
ctx,
targetDirectoryHandle,
)
if (!targetDirectory) {
throw Err.create(
Err.Code.DirectoryNotFound,
`Directory ${targetDirectoryHandle.id} not found`,
)
}
const directoryHandles: DirectoryHandle[] = []
const fileHandles: FileHandle[] = []
for (const item of items) {
switch (item.kind) {
case "directory":
directoryHandles.push(item)
break
case "file":
fileHandles.push(item)
break
}
}
await Promise.all([
Files.move(ctx, {
targetDirectory: targetDirectoryHandle,
items: fileHandles,
}),
Directories.move(ctx, {
targetDirectory: targetDirectoryHandle,
sourceDirectories: directoryHandles,
}),
])
return { items, targetDirectory }
},
})

View File

@@ -1,10 +1,11 @@
import type { Doc, Id } from "@fileone/convex/_generated/dataModel"
import { joinPath, PATH_SEPARATOR } from "@fileone/path"
import type {
AuthenticatedMutationCtx,
AuthenticatedQueryCtx,
} from "../functions"
import * as Err from "./error"
import type { DirectoryHandle, FilePath, ReverseFilePath } from "./filesystem"
import { newDirectoryHandle } from "./filesystem"
type Directory = {
kind: "directory"
@@ -19,6 +20,8 @@ type File = {
export type DirectoryItem = Directory | File
export type DirectoryItemKind = DirectoryItem["kind"]
export type DirectoryInfo = Doc<"directories"> & { path: FilePath }
export async function fetchRoot(ctx: AuthenticatedQueryCtx) {
return await ctx.db
.query("directories")
@@ -28,33 +31,61 @@ export async function fetchRoot(ctx: AuthenticatedQueryCtx) {
.first()
}
export async function fetchHandle(
ctx: AuthenticatedQueryCtx,
handle: DirectoryHandle,
): Promise<Doc<"directories">> {
const directory = await ctx.db.get(handle.id)
if (!directory || directory.userId !== ctx.user._id) {
throw Err.create(
Err.Code.DirectoryNotFound,
`Directory ${handle.id} not found`,
)
}
return directory
}
export async function fetch(
ctx: AuthenticatedQueryCtx,
{ directoryId }: { directoryId: Id<"directories"> },
) {
return await ctx.db.get(directoryId)
): Promise<DirectoryInfo> {
const directory = await ctx.db.get(directoryId)
if (!directory) {
throw Err.create(
Err.Code.DirectoryNotFound,
`Directory ${directoryId} not found`,
)
}
const path: ReverseFilePath = [
{
handle: newDirectoryHandle(directoryId),
name: directory.name,
},
]
let parentDirId = directory.parentId
while (parentDirId) {
const parentDir = await ctx.db.get(parentDirId)
if (parentDir) {
path.push({
handle: newDirectoryHandle(parentDir._id),
name: parentDir.name,
})
parentDirId = parentDir.parentId
} else {
throw Err.create(Err.Code.Internal)
}
}
return { ...directory, path: path.reverse() as FilePath }
}
export async function fetchContent(
ctx: AuthenticatedQueryCtx,
{
path,
directoryId,
}: { path?: string; directoryId?: Id<"directories"> } = {},
{ directoryId }: { 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) {
if (directoryId) {
dirId = directoryId
}
@@ -127,10 +158,63 @@ export async function create(
userId: ctx.user._id,
createdAt: now,
updatedAt: now,
path: parentDir ? joinPath(parentDir.path, name) : joinPath("", name),
})
}
export async function move(
ctx: AuthenticatedMutationCtx,
{
targetDirectory,
sourceDirectories,
}: {
targetDirectory: DirectoryHandle
sourceDirectories: DirectoryHandle[]
},
): Promise<void> {
const conflictCheckResults = await Promise.allSettled(
sourceDirectories.map((directory) =>
ctx.db.get(directory.id).then((d) => {
if (!d) {
throw Err.create(
Err.Code.DirectoryNotFound,
`Directory ${directory.id} not found`,
)
}
return ctx.db
.query("directories")
.withIndex("uniqueDirectoryInDirectory", (q) =>
q
.eq("userId", ctx.user._id)
.eq("parentId", targetDirectory.id)
.eq("name", d.name)
.eq("deletedAt", undefined),
)
.first()
}),
),
)
const errors: Err.ApplicationError[] = []
for (const result of conflictCheckResults) {
if (result.status === "fulfilled" && result.value) {
errors.push(
Err.create(
Err.Code.Conflict,
`Directory ${targetDirectory.id} already contains a directory with name ${result.value.name}`,
),
)
} else if (result.status === "rejected") {
errors.push(Err.create(Err.Code.Internal))
}
}
await Promise.all(
sourceDirectories.map((directory) =>
ctx.db.patch(directory.id, { parentId: targetDirectory.id }),
),
)
}
export async function moveToTrashRecursive(
ctx: AuthenticatedMutationCtx,
directoryId: Id<"directories">,

View File

@@ -1,6 +1,7 @@
import { ConvexError } from "convex/values"
export enum Code {
Conflict = "Conflict",
DirectoryExists = "DirectoryExists",
DirectoryNotFound = "DirectoryNotFound",
FileExists = "FileExists",
@@ -8,15 +9,16 @@ export enum Code {
Unauthenticated = "Unauthenticated",
}
export type ApplicationError = ConvexError<{ code: Code; message: string }>
export type ApplicationError = ConvexError<{ code: Code; message?: string }>
export function isApplicationError(error: unknown): error is ApplicationError {
return error instanceof ConvexError && "code" in error.data
}
export function create(code: Code, message?: string) {
export function create(code: Code, message?: string): ApplicationError {
return new ConvexError({
code,
message,
message:
code === Code.Internal ? "Internal application error" : message,
})
}

View File

@@ -1,6 +1,7 @@
import type { Id } from "../_generated/dataModel"
import type { AuthenticatedMutationCtx } from "../functions"
import * as Err from "./error"
import type { DirectoryHandle, FileHandle } from "./filesystem"
export async function renameFile(
ctx: AuthenticatedMutationCtx,
@@ -34,3 +35,20 @@ export async function renameFile(
await ctx.db.patch(itemId, { name: newName })
}
export async function move(
ctx: AuthenticatedMutationCtx,
{
targetDirectory: targetDirectoryHandle,
items,
}: {
targetDirectory: DirectoryHandle
items: FileHandle[]
},
) {
await Promise.all(
items.map((item) =>
ctx.db.patch(item.id, { directoryId: targetDirectoryHandle.id }),
),
)
}

View File

@@ -0,0 +1,37 @@
import type { Id } from "../_generated/dataModel"
export type DirectoryPathComponent = {
handle: DirectoryHandle
name: string
}
export type FilePathComponent = {
handle: FileHandle
name: string
}
export type PathComponent = FilePathComponent | DirectoryPathComponent
export type FilePath = [...DirectoryPathComponent[], PathComponent]
export type ReverseFilePath = [PathComponent, ...DirectoryPathComponent[]]
export type FileHandle = {
kind: "file"
id: Id<"files">
}
export type DirectoryHandle = {
kind: "directory"
id: Id<"directories">
}
export type FileSystemHandle = DirectoryHandle | FileHandle
export function newDirectoryHandle(id: Id<"directories">): DirectoryHandle {
return { kind: "directory", id }
}
export function newFileHandle(id: Id<"files">): FileHandle {
return { kind: "file", id }
}

View File

@@ -46,7 +46,6 @@ export async function register(ctx: AuthenticatedMutationCtx) {
}),
ctx.db.insert("directories", {
name: "",
path: "",
userId: ctx.user._id,
createdAt: now,
updatedAt: now,

View File

@@ -27,7 +27,6 @@ const schema = defineSchema({
]),
directories: defineTable({
name: v.string(),
path: v.string(),
userId: v.id("users"),
parentId: v.optional(v.id("directories")),
createdAt: v.string(),
@@ -41,8 +40,7 @@ const schema = defineSchema({
"parentId",
"name",
"deletedAt",
])
.index("byPath", ["userId", "path", "deletedAt"]),
]),
})
export default schema

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "bun --hot src/server.tsx",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
"start": "NODE_ENV=production bun src/index.tsx"
"start": "NODE_ENV=production bun src/index.tsx",
"format": "biome format --write"
},
"dependencies": {
"@convex-dev/workos": "^0.0.1",
@@ -30,6 +31,7 @@
"convex-helpers": "^0.1.104",
"jotai": "^2.14.0",
"lucide-react": "^0.544.0",
"motion": "^12.23.16",
"next-themes": "^0.4.6",
"react": "^19",
"react-dom": "^19",

View File

@@ -1,6 +1,10 @@
import type React from "react"
import { cn } from "@/lib/utils"
export function TextFileIcon({ className }: { className?: string }) {
export function TextFileIcon({
className,
...props
}: React.ComponentProps<"svg">) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -12,6 +16,7 @@ export function TextFileIcon({ className }: { className?: string }) {
"icon icon-tabler icons-tabler-filled icon-tabler-file-text text-blue-300",
className,
)}
{...props}
>
<title>Text File</title>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />

View File

@@ -0,0 +1,210 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc } from "@fileone/convex/_generated/dataModel"
import { DialogTitle } from "@radix-ui/react-dialog"
import { useQuery as useConvexQuery } from "convex/react"
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
import {
DownloadIcon,
Maximize2Icon,
Minimize2Icon,
XIcon,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react"
import { useEffect, useRef } from "react"
import { Button } from "./ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogOverlay,
} from "./ui/dialog"
import { LoadingSpinner } from "./ui/loading-spinner"
const zoomLevelAtom = atom(
1,
(get, set, update: number | ((current: number) => number)) => {
const current = get(zoomLevelAtom)
console.log("current", current)
const newValue = typeof update === "function" ? update(current) : update
if (newValue >= 0.1) {
set(zoomLevelAtom, newValue)
}
},
)
export function ImagePreviewDialog({
file,
onClose,
}: {
file: Doc<"files">
onClose: () => void
}) {
const fileUrl = useConvexQuery(api.files.generateFileUrl, {
storageId: file.storageId,
})
const setZoomLevel = useSetAtom(zoomLevelAtom)
useEffect(
() => () => {
setZoomLevel(1)
},
[setZoomLevel],
)
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) {
onClose()
}
}}
>
<DialogOverlay className="flex items-center justify-center">
{!fileUrl ? (
<LoadingSpinner className="text-neutral-200 size-10" />
) : null}
</DialogOverlay>
{fileUrl ? <PreviewContent fileUrl={fileUrl} file={file} /> : null}
</Dialog>
)
}
function PreviewContent({
fileUrl,
file,
}: {
fileUrl: string
file: Doc<"files">
}) {
return (
<DialogContent
showCloseButton={false}
className="p-0 lg:min-w-1/3 gap-0"
>
<DialogHeader className="overflow-auto border-b border-b-border p-4 flex flex-row items-center justify-between">
<DialogTitle className="truncate flex-1">
{file.name}
</DialogTitle>
<div className="flex flex-row items-center space-x-2">
<Toolbar fileUrl={fileUrl} file={file} />
<Button variant="ghost" size="icon" asChild>
<DialogClose>
<XIcon />
<span className="sr-only">Close</span>
</DialogClose>
</Button>
</div>
</DialogHeader>
<div className="w-full h-full flex items-center justify-center max-h-[calc(100vh-10rem)] overflow-auto">
<ImagePreview fileUrl={fileUrl} file={file} />
</div>
</DialogContent>
)
}
function Toolbar({ fileUrl, file }: { fileUrl: string; file: Doc<"files"> }) {
const setZoomLevel = useSetAtom(zoomLevelAtom)
const zoomInterval = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(
() => () => {
if (zoomInterval.current) {
clearInterval(zoomInterval.current)
console.log("clearInterval")
zoomInterval.current = null
}
},
[],
)
function startZooming(delta: number) {
setZoomLevel((zoom) => zoom + delta)
zoomInterval.current = setInterval(() => {
setZoomLevel((zoom) => zoom + delta)
}, 100)
}
function stopZooming() {
if (zoomInterval.current) {
clearInterval(zoomInterval.current)
zoomInterval.current = null
}
}
return (
<div className="flex flex-row items-center space-x-2 border-r border-r-border pr-4">
<ResetZoomButton />
<Button
variant="ghost"
onMouseDown={() => {
startZooming(0.1)
}}
onMouseLeave={stopZooming}
onMouseUp={stopZooming}
>
<ZoomInIcon />
</Button>
<Button
variant="ghost"
onMouseDown={() => {
startZooming(-0.1)
}}
onMouseLeave={stopZooming}
onMouseUp={stopZooming}
>
<ZoomOutIcon />
</Button>
<Button asChild>
<a
href={fileUrl}
download={file.name}
target="_blank"
className="flex flex-row items-center"
>
<DownloadIcon />
<span className="sr-only md:not-sr-only">Download</span>
</a>
</Button>
</div>
)
}
function ResetZoomButton() {
const [zoomLevel, setZoomLevel] = useAtom(zoomLevelAtom)
if (zoomLevel === 1) {
return null
}
return (
<Button
variant="ghost"
onClick={() => {
setZoomLevel(1)
}}
>
{zoomLevel > 1 ? <Minimize2Icon /> : <Maximize2Icon />}
</Button>
)
}
function ImagePreview({
fileUrl,
file,
}: {
fileUrl: string
file: Doc<"files">
}) {
const zoomLevel = useAtomValue(zoomLevelAtom)
return (
<img
src={fileUrl}
alt={file.name}
className="object-contain"
style={{ transform: `scale(${zoomLevel})` }}
/>
)
}

View File

@@ -1,141 +1,144 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import type * as React from "react"
import { cn } from "@/lib/utils"
function Dialog({
...props
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-xs",
className,
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
return (
<div
data-slot="dialog-header"
className={cn(
"flex flex-col gap-2 text-center sm:text-left",
className,
)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -1,9 +1,13 @@
import type { Doc } from "@fileone/convex/_generated/dataModel"
import type { DirectoryItem } from "@fileone/convex/model/directories"
import type {
DirectoryInfo,
DirectoryItem,
} from "@fileone/convex/model/directories"
import { createContext } from "react"
type DirectoryPageContextType = {
directory: Doc<"directories">
rootDirectory: Doc<"directories">
directory: DirectoryInfo
directoryContent: DirectoryItem[]
}

View File

@@ -1,13 +1,19 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc } from "@fileone/convex/_generated/dataModel"
import type { DirectoryItem } from "@fileone/convex/model/directories"
import {
type FileSystemHandle,
newDirectoryHandle,
newFileHandle,
} from "@fileone/convex/model/filesystem"
import { useMutation } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { Link, useNavigate } from "@tanstack/react-router"
import {
type ColumnDef,
flexRender,
getCoreRowModel,
type Row,
type Table as TableType,
useReactTable,
} from "@tanstack/react-table"
import { useMutation as useContextMutation } from "convex/react"
@@ -31,16 +37,23 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
isControlOrCommandKeyActive,
keyboardModifierAtom,
} from "@/lib/keyboard"
import { TextFileIcon } from "../../components/icons/text-file-icon"
import { Button } from "../../components/ui/button"
import { LoadingSpinner } from "../../components/ui/loading-spinner"
import { useFileDrop } from "../../files/use-file-drop"
import { withDefaultOnError } from "../../lib/error"
import { cn } from "../../lib/utils"
import { DirectoryPageContext } from "./context"
import {
contextMenuTargeItemAtom,
dragInfoAtom,
itemBeingRenamedAtom,
newItemKindAtom,
openedFileAtom,
optimisticDeletedItemsAtom,
} from "./state"
@@ -60,15 +73,18 @@ const columns: ColumnDef<DirectoryItem>[] = [
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) =>
onCheckedChange={(value) => {
table.toggleAllPageRowsSelected(!!value)
}
}}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => {
e.stopPropagation()
}}
onCheckedChange={row.getToggleSelectedHandler()}
aria-label="Select row"
/>
@@ -83,7 +99,7 @@ const columns: ColumnDef<DirectoryItem>[] = [
cell: ({ row }) => {
switch (row.original.kind) {
case "file":
return <FileNameCell initialName={row.original.doc.name} />
return <FileNameCell file={row.original.doc} />
case "directory":
return <DirectoryNameCell directory={row.original.doc} />
}
@@ -135,6 +151,7 @@ export function DirectoryContentTableContextMenu({
const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom)
const moveToTrashMutation = useContextMutation(api.files.moveToTrash)
const setItemBeingRenamed = useSetAtom(itemBeingRenamedAtom)
const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemAtom)
const { mutate: moveToTrash } = useMutation({
mutationFn: moveToTrashMutation,
onMutate: ({ itemId }) => {
@@ -172,7 +189,13 @@ export function DirectoryContentTableContextMenu({
}
return (
<ContextMenu>
<ContextMenu
onOpenChange={(open) => {
if (!open) {
setContextMenuTargetItem(null)
}
}}
>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
{target && (
<ContextMenuContent>
@@ -195,6 +218,7 @@ export function DirectoryContentTableContent() {
const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom)
const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemAtom)
const store = useStore()
const navigate = useNavigate()
const handleRowContextMenu = (
row: Row<DirectoryItem>,
@@ -222,10 +246,33 @@ export function DirectoryContentTableContent() {
})
const selectRow = (row: Row<DirectoryItem>) => {
console.log("row.getIsSelected()", row.getIsSelected())
if (!row.getIsSelected()) {
table.toggleAllPageRowsSelected(false)
const keyboardModifiers = store.get(keyboardModifierAtom)
const isMultiSelectMode = isControlOrCommandKeyActive(keyboardModifiers)
const isRowSelected = row.getIsSelected()
console.log({ isMultiSelectMode, isRowSelected })
if (isRowSelected && isMultiSelectMode) {
row.toggleSelected(false)
} else if (isRowSelected && !isMultiSelectMode) {
table.setRowSelection({
[row.id]: true,
})
row.toggleSelected(true)
} else if (!isRowSelected) {
if (isMultiSelectMode) {
row.toggleSelected(true)
} else {
table.setRowSelection({
[row.id]: true,
})
}
}
}
const handleRowDoubleClick = (row: Row<DirectoryItem>) => {
if (row.original.kind === "directory") {
navigate({
to: `/directories/${row.original.doc._id}`,
})
}
}
@@ -255,29 +302,18 @@ export function DirectoryContentTableContent() {
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
<FileItemRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={() => {
selectRow(row)
}}
onContextMenu={(e) => {
table={table}
row={row}
onClick={() => selectRow(row)}
onContextMenu={(e) =>
handleRowContextMenu(row, e)
}
onDoubleClick={() => {
handleRowDoubleClick(row)
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell
className="first:pl-4 last:pr-4"
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
/>
))
) : (
<NoResultsRow />
@@ -320,11 +356,17 @@ function NewItemRow() {
}),
})
// Auto-focus the input when newItemKind changes to a truthy value
useEffect(() => {
if (newItemKind) {
setTimeout(() => {
inputRef.current?.focus()
}, 1)
if (newItemKind && inputRef.current) {
// Use requestAnimationFrame to ensure the component is fully rendered
// and the dropdown has completed its close cycle
requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.select() // Also select the default text for better UX
}
})
}
}, [newItemKind])
@@ -356,9 +398,9 @@ function NewItemRow() {
<TableCell className="p-0">
<div className="flex items-center gap-2 px-2 py-1 h-full">
{isPending ? (
<LoadingSpinner className="size-6" />
<LoadingSpinner className="size-4" />
) : (
<DirectoryIcon />
<DirectoryIcon className="size-4" />
)}
<form
className="w-full"
@@ -399,13 +441,95 @@ function NewItemRow() {
)
}
function FileItemRow({
table,
row,
onClick,
onContextMenu,
onDoubleClick,
}: {
table: TableType<DirectoryItem>
row: Row<DirectoryItem>
onClick: () => void
onContextMenu: (e: React.MouseEvent) => void
onDoubleClick: () => void
}) {
const ref = useRef<HTMLTableRowElement>(null)
const setDragInfo = useSetAtom(dragInfoAtom)
const { isDraggedOver, dropHandlers } = useFileDrop({
item:
row.original.kind === "directory"
? newDirectoryHandle(row.original.doc._id)
: null,
dragInfoAtom,
})
const handleDragStart = (e: React.DragEvent) => {
let source: FileSystemHandle
switch (row.original.kind) {
case "file":
source = newFileHandle(row.original.doc._id)
break
case "directory":
source = newDirectoryHandle(row.original.doc._id)
break
}
// biome-ignore lint/suspicious/useIterableCallbackReturn: the switch statement is exhaustive
const draggedItems = table.getSelectedRowModel().rows.map((row) => {
switch (row.original.kind) {
case "file":
return newFileHandle(row.original.doc._id)
case "directory":
return newDirectoryHandle(row.original.doc._id)
}
})
setDragInfo({
source,
items: draggedItems,
})
}
const handleDragEnd = () => {
setDragInfo(null)
}
return (
<TableRow
draggable
ref={ref}
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
{...dropHandlers}
className={cn({ "bg-muted": isDraggedOver })}
>
{row.getVisibleCells().map((cell) => (
<TableCell
className="first:pl-4 last:pr-4"
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
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={`/directories/${directory.path}`}
to={`/directories/${directory._id}`}
>
{directory.name}
</Link>
@@ -413,11 +537,21 @@ function DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) {
)
}
function FileNameCell({ initialName }: { initialName: string }) {
function FileNameCell({ file }: { file: Doc<"files"> }) {
const setOpenedFile = useSetAtom(openedFileAtom)
return (
<div className="flex w-full items-center gap-2">
<TextFileIcon className="size-4" />
{initialName}
<button
type="button"
className="hover:underline cursor-pointer"
onClick={() => {
setOpenedFile(file)
}}
>
{file.name}
</button>
</div>
)
}

View File

@@ -1,17 +1,21 @@
import { api } from "@fileone/convex/_generated/api"
import { baseName, splitPath } from "@fileone/path"
import type {
DirectoryHandle,
PathComponent,
} from "@fileone/convex/model/filesystem"
import { useMutation } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { useMutation as useConvexMutation } from "convex/react"
import { useSetAtom } from "jotai"
import { useAtom, useAtomValue, useSetAtom } from "jotai"
import {
ChevronDownIcon,
Loader2Icon,
PlusIcon,
UploadCloudIcon,
} from "lucide-react"
import { type ChangeEvent, Fragment, useContext, useRef } from "react"
import React, { type ChangeEvent, Fragment, useContext, useRef } from "react"
import { toast } from "sonner"
import { ImagePreviewDialog } from "@/components/image-preview-dialog"
import {
DropdownMenu,
DropdownMenuContent,
@@ -29,17 +33,23 @@ import {
BreadcrumbSeparator,
} from "../../components/ui/breadcrumb"
import { Button } from "../../components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "../../components/ui/tooltip"
import { useFileDrop } from "../../files/use-file-drop"
import { cn } from "../../lib/utils"
import { DirectoryPageContext } from "./context"
import { DirectoryContentTable } from "./directory-content-table"
import { RenameFileDialog } from "./rename-file-dialog"
import { newItemKindAtom } from "./state"
import { dragInfoAtom, newItemKindAtom, openedFileAtom } from "./state"
export function DirectoryPage() {
const { directory } = useContext(DirectoryPageContext)
return (
<>
<header className="flex py-1 shrink-0 items-center gap-2 border-b px-4 w-full">
<FilePathBreadcrumb path={directory.path} />
<FilePathBreadcrumb />
<div className="ml-auto flex flex-row gap-2">
<NewDirectoryItemDropdown />
<UploadFileButton />
@@ -49,42 +59,74 @@ export function DirectoryPage() {
<DirectoryContentTable />
</div>
<RenameFileDialog />
<PreviewDialog />
</>
)
}
function FilePathBreadcrumb({ path }: { path: string }) {
const pathComponents = splitPath(path)
const base = baseName(path)
function FilePathBreadcrumb() {
const { rootDirectory, directory } = useContext(DirectoryPageContext)
const breadcrumbItems: React.ReactNode[] = []
for (let i = 1; i < directory.path.length - 1; i++) {
breadcrumbItems.push(
<Fragment key={directory.path[i]!.handle.id}>
<BreadcrumbSeparator />
<FilePathBreadcrumbItem component={directory.path[i]!} />
</Fragment>,
)
}
return (
<Breadcrumb>
<BreadcrumbList>
{rootDirectory._id === directory._id ? (
<BreadcrumbItem>
<BreadcrumbPage>All Files</BreadcrumbPage>
</BreadcrumbItem>
) : (
<FilePathBreadcrumbItem component={directory.path[0]!} />
)}
{breadcrumbItems}
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/directories">All Files</Link>
</BreadcrumbLink>
<BreadcrumbPage>{directory.name}</BreadcrumbPage>{" "}
</BreadcrumbItem>
{pathComponents.map((p) => (
<Fragment key={p}>
<BreadcrumbSeparator />
{p === base ? (
<BreadcrumbPage>{p}</BreadcrumbPage>
) : (
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={`/directories/${p}`}>{p}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
)}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
)
}
function FilePathBreadcrumbItem({ component }: { component: PathComponent }) {
const { isDraggedOver, dropHandlers } = useFileDrop({
item: component.handle as DirectoryHandle,
dragInfoAtom,
})
const dirName = component.name || "All Files"
return (
<Tooltip open={isDraggedOver}>
<TooltipTrigger asChild>
<BreadcrumbItem
className={cn({ "bg-muted": isDraggedOver })}
{...dropHandlers}
>
<BreadcrumbLink asChild>
<Link to={`/directories/${component.handle.id}`}>
{dirName}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
</TooltipTrigger>
<TooltipContent>Move to {dirName}</TooltipContent>
</Tooltip>
)
}
// tags: upload, uploadfile, uploadfilebutton, fileupload, fileuploadbutton
function UploadFileButton() {
const { directory } = useContext(DirectoryPageContext)
const generateUploadUrl = useConvexMutation(api.files.generateUploadUrl)
const saveFile = useConvexMutation(api.files.saveFile)
const { mutate: uploadFile, isPending: isUploading } = useMutation({
@@ -104,6 +146,7 @@ function UploadFileButton() {
name: file.name,
size: file.size,
mimeType: file.type,
directoryId: directory._id,
})
},
onSuccess: () => {
@@ -152,11 +195,19 @@ function UploadFileButton() {
function NewDirectoryItemDropdown() {
const setNewItemKind = useSetAtom(newItemKindAtom)
const newItemKind = useAtomValue(newItemKindAtom)
const addNewDirectory = () => {
setNewItemKind("directory")
}
const handleCloseAutoFocus = (event: Event) => {
// If we just created a new item, prevent the dropdown from restoring focus to the trigger
if (newItemKind) {
event.preventDefault()
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -166,7 +217,7 @@ function NewDirectoryItemDropdown() {
<ChevronDownIcon className="pl-1 size-4 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuContent onCloseAutoFocus={handleCloseAutoFocus}>
<DropdownMenuItem>
<TextFileIcon />
Text file
@@ -179,3 +230,25 @@ function NewDirectoryItemDropdown() {
</DropdownMenu>
)
}
function PreviewDialog() {
const [openedFile, setOpenedFile] = useAtom(openedFileAtom)
if (!openedFile) return null
console.log("openedFile", openedFile)
switch (openedFile.mimeType) {
case "image/jpeg":
case "image/png":
case "image/gif":
return (
<ImagePreviewDialog
file={openedFile}
onClose={() => setOpenedFile(null)}
/>
)
default:
return null
}
}

View File

@@ -1,10 +1,11 @@
import type { Id } from "@fileone/convex/_generated/dataModel"
import type { Doc, Id } from "@fileone/convex/_generated/dataModel"
import type {
DirectoryItem,
DirectoryItemKind,
} from "@fileone/convex/model/directories"
import type { RowSelectionState } from "@tanstack/react-table"
import { atom } from "jotai"
import type { FileDragInfo } from "../../files/use-file-drop"
export const contextMenuTargeItemAtom = atom<DirectoryItem | null>(null)
export const optimisticDeletedItemsAtom = atom(
@@ -20,3 +21,7 @@ export const itemBeingRenamedAtom = atom<{
originalItem: DirectoryItem
name: string
} | null>(null)
export const openedFileAtom = atom<Doc<"files"> | null>(null)
export const dragInfoAtom = atom<FileDragInfo | null>(null)

View File

@@ -0,0 +1,93 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc, Id } from "@fileone/convex/_generated/dataModel"
import type {
DirectoryHandle,
FileSystemHandle,
} from "@fileone/convex/model/filesystem"
import { useMutation } from "@tanstack/react-query"
import { useMutation as useContextMutation } from "convex/react"
import type { Atom } from "jotai"
import { useStore } from "jotai"
import { useState } from "react"
import { toast } from "sonner"
export interface FileDragInfo {
source: FileSystemHandle
items: FileSystemHandle[]
}
export interface UseFileDropOptions {
item: DirectoryHandle | null
dragInfoAtom: Atom<FileDragInfo | null>
onDropSuccess?: (
items: Id<"files">[],
targetDirectory: Doc<"directories">,
) => void
}
export interface UseFileDropReturn {
isDraggedOver: boolean
dropHandlers: {
onDrop: (e: React.DragEvent) => void
onDragOver: (e: React.DragEvent) => void
onDragLeave: () => void
}
}
export function useFileDrop({
item,
dragInfoAtom,
}: UseFileDropOptions): UseFileDropReturn {
const [isDraggedOver, setIsDraggedOver] = useState(false)
const store = useStore()
const { mutate: moveDroppedItems } = useMutation({
mutationFn: useContextMutation(api.filesystem.moveItems),
onSuccess: ({
items,
targetDirectory,
}: {
items: FileSystemHandle[]
targetDirectory: Doc<"directories">
}) => {
toast.success(
`${items.length} items moved to ${targetDirectory.name}`,
)
},
})
const handleDrop = (_e: React.DragEvent) => {
const dragInfo = store.get(dragInfoAtom)
if (dragInfo && item) {
moveDroppedItems({
targetDirectory: item,
items: dragInfo.items,
})
}
setIsDraggedOver(false)
}
const handleDragOver = (e: React.DragEvent) => {
const dragInfo = store.get(dragInfoAtom)
if (dragInfo && item) {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
setIsDraggedOver(true)
} else {
e.dataTransfer.dropEffect = "none"
}
}
const handleDragLeave = () => {
setIsDraggedOver(false)
}
return {
isDraggedOver,
dropHandlers: {
onDrop: handleDrop,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
},
}
}

View File

@@ -0,0 +1,85 @@
import { atom, useSetAtom } from "jotai"
import { useEffect } from "react"
export enum KeyboardModifier {
Alt = "Alt",
Control = "Control",
Meta = "Meta",
Shift = "Shift",
}
export const keyboardModifierAtom = atom(new Set<KeyboardModifier>())
const addKeyboardModifierAtom = atom(
null,
(get, set, modifier: KeyboardModifier) => {
const activeModifiers = get(keyboardModifierAtom)
const nextActiveModifiers = new Set(activeModifiers)
nextActiveModifiers.add(modifier)
set(keyboardModifierAtom, nextActiveModifiers)
},
)
const removeKeyboardModifierAtom = atom(
null,
(get, set, modifier: KeyboardModifier) => {
const activeModifiers = get(keyboardModifierAtom)
const nextActiveModifiers = new Set(activeModifiers)
nextActiveModifiers.delete(modifier)
set(keyboardModifierAtom, nextActiveModifiers)
},
)
export function useKeyboardModifierListener() {
const addKeyboardModifier = useSetAtom(addKeyboardModifierAtom)
const removeKeyboardModifier = useSetAtom(removeKeyboardModifierAtom)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case "Alt":
addKeyboardModifier(KeyboardModifier.Alt)
break
case "Control":
addKeyboardModifier(KeyboardModifier.Control)
break
case "Meta":
addKeyboardModifier(KeyboardModifier.Meta)
break
case "Shift":
addKeyboardModifier(KeyboardModifier.Shift)
break
}
}
const handleKeyUp = (event: KeyboardEvent) => {
switch (event.key) {
case "Alt":
removeKeyboardModifier(KeyboardModifier.Alt)
break
case "Control":
removeKeyboardModifier(KeyboardModifier.Control)
break
case "Meta":
removeKeyboardModifier(KeyboardModifier.Meta)
break
case "Shift":
removeKeyboardModifier(KeyboardModifier.Shift)
break
}
}
window.addEventListener("keydown", handleKeyDown)
window.addEventListener("keyup", handleKeyUp)
return () => {
window.removeEventListener("keydown", handleKeyDown)
window.removeEventListener("keyup", handleKeyUp)
}
}, [addKeyboardModifier, removeKeyboardModifier])
}
export function isControlOrCommandKeyActive(
keyboardModifiers: Set<KeyboardModifier>,
) {
return (
keyboardModifiers.has(KeyboardModifier.Control) ||
keyboardModifiers.has(KeyboardModifier.Meta)
)
}

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 AuthenticatedSidebarLayoutFilesSplatRouteImport } from './routes/_authenticated/_sidebar-layout/files.$'
import { Route as AuthenticatedSidebarLayoutHomeRouteImport } from './routes/_authenticated/_sidebar-layout/home'
import { Route as AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/directories.$directoryId'
const LoginRoute = LoginRouteImport.update({
@@ -41,10 +41,10 @@ const AuthenticatedSidebarLayoutRoute =
id: '/_sidebar-layout',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedSidebarLayoutFilesSplatRoute =
AuthenticatedSidebarLayoutFilesSplatRouteImport.update({
id: '/files/$',
path: '/files/$',
const AuthenticatedSidebarLayoutHomeRoute =
AuthenticatedSidebarLayoutHomeRouteImport.update({
id: '/home',
path: '/home',
getParentRoute: () => AuthenticatedSidebarLayoutRoute,
} as any)
const AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute =
@@ -58,15 +58,15 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/files/$': typeof AuthenticatedSidebarLayoutFilesSplatRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/files/$': typeof AuthenticatedSidebarLayoutFilesSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -75,8 +75,8 @@ export interface FileRoutesById {
'/_authenticated/_sidebar-layout': typeof AuthenticatedSidebarLayoutRouteWithChildren
'/login_/callback': typeof LoginCallbackRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/_sidebar-layout/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/_authenticated/_sidebar-layout/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/_authenticated/_sidebar-layout/files/$': typeof AuthenticatedSidebarLayoutFilesSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -84,15 +84,10 @@ export interface FileRouteTypes {
| '/login'
| '/login/callback'
| '/'
| '/home'
| '/directories/$directoryId'
| '/files/$'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
| '/login/callback'
| '/'
| '/directories/$directoryId'
| '/files/$'
to: '/login' | '/login/callback' | '/' | '/home' | '/directories/$directoryId'
id:
| '__root__'
| '/_authenticated'
@@ -100,8 +95,8 @@ export interface FileRouteTypes {
| '/_authenticated/_sidebar-layout'
| '/login_/callback'
| '/_authenticated/'
| '/_authenticated/_sidebar-layout/home'
| '/_authenticated/_sidebar-layout/directories/$directoryId'
| '/_authenticated/_sidebar-layout/files/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -147,11 +142,11 @@ 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 AuthenticatedSidebarLayoutFilesSplatRouteImport
'/_authenticated/_sidebar-layout/home': {
id: '/_authenticated/_sidebar-layout/home'
path: '/home'
fullPath: '/home'
preLoaderRoute: typeof AuthenticatedSidebarLayoutHomeRouteImport
parentRoute: typeof AuthenticatedSidebarLayoutRoute
}
'/_authenticated/_sidebar-layout/directories/$directoryId': {
@@ -165,16 +160,15 @@ declare module '@tanstack/react-router' {
}
interface AuthenticatedSidebarLayoutRouteChildren {
AuthenticatedSidebarLayoutHomeRoute: typeof AuthenticatedSidebarLayoutHomeRoute
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
AuthenticatedSidebarLayoutFilesSplatRoute: typeof AuthenticatedSidebarLayoutFilesSplatRoute
}
const AuthenticatedSidebarLayoutRouteChildren: AuthenticatedSidebarLayoutRouteChildren =
{
AuthenticatedSidebarLayoutHomeRoute: AuthenticatedSidebarLayoutHomeRoute,
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute:
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute,
AuthenticatedSidebarLayoutFilesSplatRoute:
AuthenticatedSidebarLayoutFilesSplatRoute,
}
const AuthenticatedSidebarLayoutRouteWithChildren =

View File

@@ -6,6 +6,7 @@ import { AuthKitProvider, useAuth } from "@workos-inc/authkit-react"
import { ConvexReactClient } from "convex/react"
import { toast } from "sonner"
import { formatError } from "@/lib/error"
import { useKeyboardModifierListener } from "@/lib/keyboard"
export const Route = createRootRoute({
component: RootLayout,
@@ -25,6 +26,8 @@ const queryClient = new QueryClient({
})
function RootLayout() {
useKeyboardModifierListener()
return (
<QueryClientProvider client={queryClient}>
<AuthKitProvider

View File

@@ -13,6 +13,7 @@ export const Route = createFileRoute(
function RouteComponent() {
const { directoryId } = Route.useParams()
const rootDirectory = useConvexQuery(api.files.fetchRootDirectory)
const directory = useConvexQuery(api.files.fetchDirectory, {
directoryId,
})
@@ -20,12 +21,14 @@ function RouteComponent() {
directoryId,
})
if (!directory || !directoryContent) {
if (!directory || !directoryContent || !rootDirectory) {
return <DirectoryPageSkeleton />
}
return (
<DirectoryPageContext value={{ directory, directoryContent }}>
<DirectoryPageContext
value={{ rootDirectory, directory, directoryContent }}
>
<DirectoryPage />
</DirectoryPageContext>
)

View File

@@ -1,15 +0,0 @@
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

@@ -0,0 +1,9 @@
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

@@ -5,5 +5,5 @@ export const Route = createFileRoute("/_authenticated/")({
})
function RouteComponent() {
return <Navigate replace to="/files" />
return <Navigate replace to="/home" />
}

78
platform.ts Normal file
View File

@@ -0,0 +1,78 @@
export enum Platform {
Windows = "windows",
MacOS = "macos",
Linux = "linux",
Android = "android",
iOS = "ios",
}
// Internal global variables (computed once at module load)
let _isWindows = false
let _isMacOS = false
let _isLinux = false
let _isAndroid = false
let _isIOS = false
let _isMobile = false
let _userAgent: string | undefined
let _platform: Platform = Platform.Linux // Default fallback
interface INavigator {
userAgent: string
maxTouchPoints?: number
}
declare const navigator: INavigator
// Platform detection logic (runs once at module load)
if (typeof navigator === "object") {
_userAgent = navigator.userAgent
// iOS detection (must come before macOS since iOS contains "Mac")
if (
/iPad|iPhone|iPod/.test(_userAgent) ||
(/Macintosh/.test(_userAgent) &&
navigator.maxTouchPoints &&
navigator.maxTouchPoints > 0)
) {
_isIOS = true
_platform = Platform.iOS
_isMobile = /iPhone|iPod/.test(_userAgent) || /Mobi/.test(_userAgent)
}
// Android detection
else if (/Android/.test(_userAgent)) {
_isAndroid = true
_platform = Platform.Android
_isMobile = true
}
// Windows detection
else if (/Windows/.test(_userAgent)) {
_isWindows = true
_platform = Platform.Windows
_isMobile = /Mobi/.test(_userAgent)
}
// macOS detection
else if (/Macintosh|Mac OS X/.test(_userAgent)) {
_isMacOS = true
_platform = Platform.MacOS
_isMobile = false
}
// Linux detection
else if (/Linux/.test(_userAgent)) {
_isLinux = true
_platform = Platform.Linux
_isMobile = /Mobi/.test(_userAgent)
}
// Fallback - check for mobile
else {
_isMobile = /Mobi/.test(_userAgent)
}
}
// Exported constants (computed once)
export const isWindows = _isWindows
export const isMacOS = _isMacOS
export const isLinux = _isLinux
export const isAndroid = _isAndroid
export const isIOS = _isIOS
export const isMobile = _isMobile
export const platform = _platform
export const userAgent = _userAgent