Compare commits
22 Commits
4812fcc0a2
...
main
Author | SHA1 | Date | |
---|---|---|---|
82ce2ac90c
|
|||
8062fdb7f3
|
|||
29eab87c71
|
|||
a331276c43
|
|||
6323a3f41c
|
|||
bc1f655aab
|
|||
de8a53d7a8
|
|||
39c0268ded
|
|||
19535396ad
|
|||
7eefe2b96e
|
|||
0f5b1f79ff
|
|||
cd2c10fbed
|
|||
d6b693b54b
|
|||
daae016cf3
|
|||
40b9c84b15
|
|||
367e248062
|
|||
ddd2afb879
|
|||
6310a15c78
|
|||
75ee997d04
|
|||
c10edc0df2
|
|||
4dec5e5bb5
|
|||
8b699a8f51
|
@@ -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
10
.env.sample
Normal 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
27
AGENTS.md
Normal 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.
|
9
bun.lock
9
bun.lock
@@ -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
19
dev/docs/dashboard.md
Normal 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.
|
@@ -16,4 +16,4 @@
|
||||
"@types/bun": "latest",
|
||||
"convex": "^1.27.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
4
packages/convex/_generated/api.d.ts
vendored
4
packages/convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
}>;
|
||||
|
@@ -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()),
|
||||
},
|
||||
|
62
packages/convex/filesystem.ts
Normal file
62
packages/convex/filesystem.ts
Normal 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 }
|
||||
},
|
||||
})
|
@@ -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">,
|
||||
|
@@ -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,
|
||||
})
|
||||
}
|
||||
|
@@ -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 }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
37
packages/convex/model/filesystem.ts
Normal file
37
packages/convex/model/filesystem.ts
Normal 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 }
|
||||
}
|
@@ -46,7 +46,6 @@ export async function register(ctx: AuthenticatedMutationCtx) {
|
||||
}),
|
||||
ctx.db.insert("directories", {
|
||||
name: "",
|
||||
path: "",
|
||||
userId: ctx.user._id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
@@ -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
|
||||
|
@@ -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",
|
||||
|
@@ -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" />
|
||||
|
210
packages/web/src/components/image-preview-dialog.tsx
Normal file
210
packages/web/src/components/image-preview-dialog.tsx
Normal 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})` }}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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,
|
||||
}
|
||||
|
@@ -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[]
|
||||
}
|
||||
|
||||
|
@@ -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>
|
||||
)
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
|
93
packages/web/src/files/use-file-drop.ts
Normal file
93
packages/web/src/files/use-file-drop.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
85
packages/web/src/lib/keyboard.ts
Normal file
85
packages/web/src/lib/keyboard.ts
Normal 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)
|
||||
)
|
||||
}
|
@@ -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 =
|
||||
|
@@ -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
|
||||
|
@@ -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>
|
||||
)
|
||||
|
@@ -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} />
|
||||
}
|
@@ -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>
|
||||
}
|
@@ -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
78
platform.ts
Normal 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
|
Reference in New Issue
Block a user