* feat(files): wire file browser to Google Drive API replace mock file data with real Google Drive integration via domain-wide delegation (service account impersonation). - add google drive REST API v3 library (JWT auth, drive client, rate limiting, token cache) - add schema: google_auth, google_starred_files tables, users.googleEmail column - add 16+ server actions for full CRUD (browse, upload, download, create folders, rename, move, trash/restore) - add download proxy route with google-native file export - add folder-by-ID route (/dashboard/files/folder/[id]) - refactor use-files hook to fetch from server actions with mock data fallback when disconnected - update all file browser components (upload, rename, move, context menu, breadcrumb, drag-drop, nav) - add settings UI: connection, shared drive picker, user email mapping - extract shared AES-GCM crypto from netsuite module - dual permission: compass RBAC + google workspace ACLs * docs(files): add Google Drive integration guide covers architecture decisions, permission model, setup instructions, and known limitations. --------- Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
235 lines
6.3 KiB
TypeScript
Executable File
235 lines
6.3 KiB
TypeScript
Executable File
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import {
|
|
IconFolder,
|
|
IconFolderSymlink,
|
|
IconLoader2,
|
|
} from "@tabler/icons-react"
|
|
|
|
import type { FileItem } from "@/lib/files-data"
|
|
import { useFiles } from "@/hooks/use-files"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import { cn } from "@/lib/utils"
|
|
import { toast } from "sonner"
|
|
|
|
type FolderEntry = { id: string; name: string }
|
|
|
|
export function FileMoveDialog({
|
|
open,
|
|
onOpenChange,
|
|
file,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
file: FileItem | null
|
|
}) {
|
|
const {
|
|
moveFile: moveFileFn,
|
|
fetchFolders,
|
|
getFolders,
|
|
state,
|
|
dispatch,
|
|
} = useFiles()
|
|
|
|
const [selectedFolderId, setSelectedFolderId] = useState<
|
|
string | null
|
|
>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [movePending, setMovePending] = useState(false)
|
|
const [driveFolders, setDriveFolders] = useState<
|
|
FolderEntry[]
|
|
>([])
|
|
|
|
// fetch folders when dialog opens
|
|
useEffect(() => {
|
|
if (!open) return
|
|
|
|
if (state.isConnected === true) {
|
|
setLoading(true)
|
|
fetchFolders().then(folders => {
|
|
if (folders) {
|
|
setDriveFolders(
|
|
folders.filter(f => f.id !== file?.id)
|
|
)
|
|
}
|
|
setLoading(false)
|
|
})
|
|
}
|
|
}, [open, state.isConnected, fetchFolders, file?.id])
|
|
|
|
const mockFolders = getFolders().filter(
|
|
f => f.id !== file?.id
|
|
)
|
|
|
|
const handleMove = async () => {
|
|
if (!file) return
|
|
|
|
setMovePending(true)
|
|
try {
|
|
if (state.isConnected === true) {
|
|
if (!selectedFolderId) {
|
|
toast.error("Select a destination folder")
|
|
return
|
|
}
|
|
const oldParentId = file.parentId ?? "root"
|
|
const ok = await moveFileFn(
|
|
file.id,
|
|
selectedFolderId,
|
|
oldParentId
|
|
)
|
|
if (ok) {
|
|
const dest = driveFolders.find(
|
|
f => f.id === selectedFolderId
|
|
)
|
|
toast.success(
|
|
`Moved "${file.name}" to ${dest?.name ?? "folder"}`
|
|
)
|
|
} else {
|
|
toast.error("Failed to move file")
|
|
}
|
|
} else {
|
|
// mock mode
|
|
const targetFolder = mockFolders.find(
|
|
f => f.id === selectedFolderId
|
|
)
|
|
const targetPath = targetFolder
|
|
? [...targetFolder.path, targetFolder.name]
|
|
: []
|
|
|
|
dispatch({
|
|
type: "REMOVE_FILE",
|
|
payload: file.id,
|
|
})
|
|
toast.success(
|
|
`Moved "${file.name}" to ${targetFolder?.name ?? "My Files"}`
|
|
)
|
|
}
|
|
onOpenChange(false)
|
|
} finally {
|
|
setMovePending(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<IconFolderSymlink size={18} />
|
|
Move to
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-10">
|
|
<IconLoader2
|
|
size={24}
|
|
className="animate-spin text-muted-foreground"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<ScrollArea className="h-64 rounded-md border p-2">
|
|
{state.isConnected === true ? (
|
|
<>
|
|
{driveFolders.map(folder => (
|
|
<button
|
|
key={folder.id}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent",
|
|
selectedFolderId === folder.id &&
|
|
"bg-accent"
|
|
)}
|
|
onClick={() =>
|
|
setSelectedFolderId(folder.id)
|
|
}
|
|
>
|
|
<IconFolder
|
|
size={16}
|
|
className="text-amber-500"
|
|
/>
|
|
{folder.name}
|
|
</button>
|
|
))}
|
|
{driveFolders.length === 0 && (
|
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
|
No folders found
|
|
</p>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent",
|
|
selectedFolderId === null && "bg-accent"
|
|
)}
|
|
onClick={() => setSelectedFolderId(null)}
|
|
>
|
|
<IconFolder
|
|
size={16}
|
|
className="text-amber-500"
|
|
/>
|
|
My Files (root)
|
|
</button>
|
|
{mockFolders.map(folder => (
|
|
<button
|
|
key={folder.id}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent",
|
|
selectedFolderId === folder.id &&
|
|
"bg-accent"
|
|
)}
|
|
style={{
|
|
paddingLeft: `${(folder.path.length + 1) * 12 + 12}px`,
|
|
}}
|
|
onClick={() =>
|
|
setSelectedFolderId(folder.id)
|
|
}
|
|
>
|
|
<IconFolder
|
|
size={16}
|
|
className="text-amber-500"
|
|
/>
|
|
{folder.name}
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
</ScrollArea>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={movePending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleMove}
|
|
disabled={movePending}
|
|
>
|
|
{movePending && (
|
|
<IconLoader2
|
|
size={16}
|
|
className="mr-2 animate-spin"
|
|
/>
|
|
)}
|
|
Move here
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|