* 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>
204 lines
5.0 KiB
TypeScript
Executable File
204 lines
5.0 KiB
TypeScript
Executable File
"use client"
|
|
|
|
import {
|
|
IconDownload,
|
|
IconEdit,
|
|
IconExternalLink,
|
|
IconFolderSymlink,
|
|
IconShare,
|
|
IconStar,
|
|
IconStarFilled,
|
|
IconTrash,
|
|
IconTrashOff,
|
|
} from "@tabler/icons-react"
|
|
|
|
import type { FileItem } from "@/lib/files-data"
|
|
import { useFiles } from "@/hooks/use-files"
|
|
import { isGoogleNativeFile } from "@/lib/google/mapper"
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuTrigger,
|
|
} from "@/components/ui/context-menu"
|
|
import { toast } from "sonner"
|
|
|
|
export function FileContextMenu({
|
|
file,
|
|
children,
|
|
onRename,
|
|
onMove,
|
|
}: {
|
|
file: FileItem
|
|
children: React.ReactNode
|
|
onRename: (file: FileItem) => void
|
|
onMove: (file: FileItem) => void
|
|
}) {
|
|
const {
|
|
starFile,
|
|
trashFile,
|
|
restoreFile,
|
|
state,
|
|
dispatch,
|
|
} = useFiles()
|
|
|
|
const handleStar = async () => {
|
|
if (state.isConnected === true) {
|
|
await starFile(file.id)
|
|
} else {
|
|
dispatch({ type: "OPTIMISTIC_STAR", payload: file.id })
|
|
}
|
|
}
|
|
|
|
const handleTrash = async () => {
|
|
if (state.isConnected === true) {
|
|
const ok = await trashFile(file.id)
|
|
if (ok) {
|
|
toast.success(`"${file.name}" moved to trash`)
|
|
} else {
|
|
toast.error("Failed to delete file")
|
|
}
|
|
} else {
|
|
dispatch({
|
|
type: "OPTIMISTIC_TRASH",
|
|
payload: file.id,
|
|
})
|
|
toast.success(`"${file.name}" moved to trash`)
|
|
}
|
|
}
|
|
|
|
const handleRestore = async () => {
|
|
if (state.isConnected === true) {
|
|
const ok = await restoreFile(file.id)
|
|
if (ok) {
|
|
toast.success(`"${file.name}" restored`)
|
|
} else {
|
|
toast.error("Failed to restore file")
|
|
}
|
|
} else {
|
|
dispatch({
|
|
type: "OPTIMISTIC_RESTORE",
|
|
payload: file.id,
|
|
})
|
|
toast.success(`"${file.name}" restored`)
|
|
}
|
|
}
|
|
|
|
const handleDownload = () => {
|
|
if (state.isConnected === true) {
|
|
window.open(
|
|
`/api/google/download/${file.id}`,
|
|
"_blank"
|
|
)
|
|
} else {
|
|
toast.success("Download started")
|
|
}
|
|
}
|
|
|
|
const handleOpenInDrive = () => {
|
|
if (file.webViewLink) {
|
|
window.open(file.webViewLink, "_blank")
|
|
}
|
|
}
|
|
|
|
return (
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
{children}
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent className="w-48">
|
|
{file.type === "folder" && (
|
|
<ContextMenuItem
|
|
onClick={() =>
|
|
toast.info("Opening folder...")
|
|
}
|
|
>
|
|
<IconFolderSymlink
|
|
size={16}
|
|
className="mr-2"
|
|
/>
|
|
Open
|
|
</ContextMenuItem>
|
|
)}
|
|
<ContextMenuItem
|
|
onClick={() =>
|
|
toast.info("Share dialog coming soon")
|
|
}
|
|
>
|
|
<IconShare size={16} className="mr-2" />
|
|
Share
|
|
</ContextMenuItem>
|
|
{!file.trashed && (
|
|
<>
|
|
{file.webViewLink &&
|
|
file.mimeType &&
|
|
isGoogleNativeFile(file.mimeType) && (
|
|
<ContextMenuItem
|
|
onClick={handleOpenInDrive}
|
|
>
|
|
<IconExternalLink
|
|
size={16}
|
|
className="mr-2"
|
|
/>
|
|
Open in Google Drive
|
|
</ContextMenuItem>
|
|
)}
|
|
{file.type !== "folder" && (
|
|
<ContextMenuItem onClick={handleDownload}>
|
|
<IconDownload
|
|
size={16}
|
|
className="mr-2"
|
|
/>
|
|
Download
|
|
</ContextMenuItem>
|
|
)}
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem onClick={() => onRename(file)}>
|
|
<IconEdit size={16} className="mr-2" />
|
|
Rename
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onClick={() => onMove(file)}>
|
|
<IconFolderSymlink
|
|
size={16}
|
|
className="mr-2"
|
|
/>
|
|
Move to
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onClick={handleStar}>
|
|
{file.starred ? (
|
|
<>
|
|
<IconStarFilled
|
|
size={16}
|
|
className="mr-2 text-amber-400"
|
|
/>
|
|
Unstar
|
|
</>
|
|
) : (
|
|
<>
|
|
<IconStar size={16} className="mr-2" />
|
|
Star
|
|
</>
|
|
)}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
className="text-destructive"
|
|
onClick={handleTrash}
|
|
>
|
|
<IconTrash size={16} className="mr-2" />
|
|
Delete
|
|
</ContextMenuItem>
|
|
</>
|
|
)}
|
|
{file.trashed && (
|
|
<ContextMenuItem onClick={handleRestore}>
|
|
<IconTrashOff size={16} className="mr-2" />
|
|
Restore
|
|
</ContextMenuItem>
|
|
)}
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
)
|
|
}
|