diff --git a/bun.lock b/bun.lock index c436573..5e10453 100644 --- a/bun.lock +++ b/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=="], diff --git a/packages/convex/files.ts b/packages/convex/files.ts index 18c8022..0f48fe2 100644 --- a/packages/convex/files.ts +++ b/packages/convex/files.ts @@ -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({ @@ -141,3 +142,13 @@ export const moveToTrash = authenticatedMutation({ return itemId }, }) + +export const moveFiles = authenticatedMutation({ + args: { + targetDirectoryId: v.id("directories"), + items: v.array(v.id("files")), + }, + handler: async (ctx, { targetDirectoryId, items }) => { + return await Files.moveFiles(ctx, { targetDirectoryId, items }) + }, +}) diff --git a/packages/convex/model/files.ts b/packages/convex/model/files.ts index aa295f5..1d9f6b0 100644 --- a/packages/convex/model/files.ts +++ b/packages/convex/model/files.ts @@ -34,3 +34,28 @@ export async function renameFile( await ctx.db.patch(itemId, { name: newName }) } + +export async function moveFiles( + ctx: AuthenticatedMutationCtx, + { + targetDirectoryId, + items, + }: { + targetDirectoryId: Id<"directories"> + items: Id<"files">[] + }, +) { + const targetDirectory = await ctx.db.get(targetDirectoryId) + if (!targetDirectory) { + throw Err.create( + Err.Code.DirectoryNotFound, + "Target directory not found", + ) + } + await Promise.all( + items.map((itemId) => + ctx.db.patch(itemId, { directoryId: targetDirectoryId }), + ), + ) + return { items, targetDirectory } +} diff --git a/packages/web/package.json b/packages/web/package.json index 1fa076b..6e94595 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -30,6 +30,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", diff --git a/packages/web/src/components/icons/text-file-icon.tsx b/packages/web/src/components/icons/text-file-icon.tsx index be1fc69..38dfa63 100644 --- a/packages/web/src/components/icons/text-file-icon.tsx +++ b/packages/web/src/components/icons/text-file-icon.tsx @@ -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 ( Text File diff --git a/packages/web/src/directories/directory-page/directory-content-table.tsx b/packages/web/src/directories/directory-page/directory-content-table.tsx index e6554aa..9209be9 100644 --- a/packages/web/src/directories/directory-page/directory-content-table.tsx +++ b/packages/web/src/directories/directory-page/directory-content-table.tsx @@ -1,5 +1,5 @@ import { api } from "@fileone/convex/_generated/api" -import type { Doc } from "@fileone/convex/_generated/dataModel" +import type { Doc, Id } from "@fileone/convex/_generated/dataModel" import type { DirectoryItem } from "@fileone/convex/model/directories" import { useMutation } from "@tanstack/react-query" import { Link } from "@tanstack/react-router" @@ -13,7 +13,7 @@ import { import { useMutation as useContextMutation } from "convex/react" import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai" import { CheckIcon, TextCursorInputIcon, TrashIcon, XIcon } from "lucide-react" -import { useContext, useEffect, useId, useRef } from "react" +import { useContext, useEffect, useId, useRef, useState } from "react" import { toast } from "sonner" import { DirectoryIcon } from "@/components/icons/directory-icon" import { Checkbox } from "@/components/ui/checkbox" @@ -39,6 +39,7 @@ import { cn } from "../../lib/utils" import { DirectoryPageContext } from "./context" import { contextMenuTargeItemAtom, + dragInfoAtom, itemBeingRenamedAtom, newItemKindAtom, openedFileAtom, @@ -255,31 +256,18 @@ export function DirectoryContentTableContent() { {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - { - selectRow(row) - }} - onContextMenu={(e) => { - handleRowContextMenu(row, e) - }} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - )) + table + .getRowModel() + .rows.map((row) => ( + selectRow(row)} + onContextMenu={(e) => + handleRowContextMenu(row, e) + } + /> + )) ) : ( )} @@ -400,6 +388,110 @@ function NewItemRow() { ) } +function FileItemRow({ + row, + onClick, + onContextMenu, +}: { + row: Row + onClick: () => void + onContextMenu: (e: React.MouseEvent) => void +}) { + const [isDraggedOver, setIsDraggedOver] = useState(false) + const ref = useRef(null) + const setDragInfo = useSetAtom(dragInfoAtom) + const store = useStore() + + const { mutate: moveFiles } = useMutation({ + mutationFn: useContextMutation(api.files.moveFiles), + onSuccess: ({ + items, + targetDirectory, + }: { + items: Id<"files">[] + targetDirectory: Doc<"directories"> + }) => { + toast.success( + `${items.length} files moved to ${targetDirectory.name}`, + ) + }, + }) + + function onDragStart(e: React.DragEvent) { + if (row.original.kind === "file") { + e.dataTransfer.setData( + "application/x-internal", + JSON.stringify(row.original), + ) + setDragInfo({ + source: row.original, + items: [row.original.doc._id], + }) + } + } + + function onDrop(_e: React.DragEvent) { + const dragInfo = store.get(dragInfoAtom) + if (dragInfo && row.original.kind === "directory") { + moveFiles({ + targetDirectoryId: row.original.doc._id, + items: dragInfo.items, + }) + } + } + + function onDragOver(e: React.DragEvent) { + const dragInfo = store.get(dragInfoAtom) + if ( + dragInfo && + dragInfo.source !== row.original && + row.original.kind === "directory" + ) { + e.preventDefault() + e.dataTransfer.dropEffect = "move" + setIsDraggedOver(true) + } else { + e.dataTransfer.dropEffect = "none" + } + } + + function onDragLeave() { + setIsDraggedOver(false) + } + + function onDragEnd() { + setIsDraggedOver(false) + setDragInfo(null) + } + + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) +} + function DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) { return (
@@ -416,6 +508,7 @@ function DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) { function FileNameCell({ file }: { file: Doc<"files"> }) { const setOpenedFile = useSetAtom(openedFileAtom) + return (
diff --git a/packages/web/src/directories/directory-page/state.ts b/packages/web/src/directories/directory-page/state.ts index dfde8ed..dd5ac26 100644 --- a/packages/web/src/directories/directory-page/state.ts +++ b/packages/web/src/directories/directory-page/state.ts @@ -22,3 +22,8 @@ export const itemBeingRenamedAtom = atom<{ } | null>(null) export const openedFileAtom = atom | null>(null) + +export const dragInfoAtom = atom<{ + source: DirectoryItem + items: Id<"files">[] +} | null>(null)