feat(files): Google Drive integration (#49)
* 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>
This commit is contained in:
parent
e3b708317c
commit
017b0797c7
203
docs/google-drive/GOOGLE-DRIVE-INTEGRATION.md
Executable file
203
docs/google-drive/GOOGLE-DRIVE-INTEGRATION.md
Executable file
@ -0,0 +1,203 @@
|
||||
Google Drive Integration
|
||||
===
|
||||
|
||||
Compass connects to a Google Workspace via the Drive API v3, replacing the mock file data with real cloud storage. The integration uses domain-wide delegation, which means a single service account impersonates each Compass user by their Google Workspace email. This is worth understanding because it determines how permissions work, how authentication flows, and why the setup requires Google Workspace admin access.
|
||||
|
||||
|
||||
Why domain-wide delegation
|
||||
---
|
||||
|
||||
There were two realistic options for connecting Compass to Google Drive: OAuth per-user or domain-wide delegation via a service account.
|
||||
|
||||
Per-user OAuth would require every Compass user to individually authorize the app through a Google consent screen, creating friction on a construction site where field workers need immediate file access. It also requires managing refresh tokens per user and handling re-authorization when tokens expire.
|
||||
|
||||
Domain-wide delegation avoids this entirely. A Google Workspace admin grants the service account access to Drive scopes once, and from that point, every Workspace user is accessible without individual consent flows. The service account impersonates each user by setting the `sub` claim in its JWT, so every API call runs as that specific user with their own Drive permissions. This means Google's native sharing model is preserved - if a user can't access a file in Google Workspace, the API returns 403 to Compass as well.
|
||||
|
||||
The tradeoff is that setup requires access to both the Google Cloud Console (to create the service account and enable the Drive API) and the Google Workspace Admin Console (to grant domain-wide delegation scopes). This is a one-time administrative action, but it does require someone with admin access to both systems.
|
||||
|
||||
|
||||
How the two permission layers work
|
||||
---
|
||||
|
||||
Every file operation passes through two gates, and this is the most important architectural decision in the integration.
|
||||
|
||||
**Compass RBAC** runs first. The role-based permissions defined in `src/lib/permissions.ts` determine what *type* of operation a user can attempt. Admin users get full CRUD plus approve. Office staff get create, read, update. Field workers get create and read. Client users get read only. This check happens before any Google API call is made, so a field worker can never trigger a delete operation regardless of their Google permissions.
|
||||
|
||||
**Google Workspace permissions** run second, implicitly. Because the API call is made as the user (via impersonation), Google enforces whatever sharing and access rules exist in the Workspace. If a file is in a Shared Drive that the user doesn't have access to, Google returns 404. If the user has view-only access to a file, Google rejects a PATCH request. No mapping logic is needed - the impersonation itself is the enforcement mechanism.
|
||||
|
||||
This means Compass can restrict operations *beyond* what Google allows (a field worker with Google editor access still can't delete through Compass), but it cannot grant access *beyond* what Google allows (an admin who doesn't have a Google Workspace account can't browse files).
|
||||
|
||||
|
||||
User-to-Google email mapping
|
||||
---
|
||||
|
||||
Most Compass users will have the same email in both systems. For cases where they don't (someone whose Compass email is `nicholai@biohazardvfx.com` but whose Workspace email is different), the `users` table has a `googleEmail` column that overrides the default.
|
||||
|
||||
The resolution logic is straightforward: use `googleEmail` if set, otherwise fall back to `email`. If the resolved email doesn't exist in the Workspace, the Google API call fails and Compass returns a user-facing error. An admin can set override emails through the settings UI.
|
||||
|
||||
|
||||
Architecture
|
||||
---
|
||||
|
||||
### Library structure
|
||||
|
||||
```
|
||||
src/lib/google/
|
||||
config.ts - config types, API URLs, scopes, crypto salt
|
||||
auth/
|
||||
service-account.ts - JWT creation and token exchange (RS256 via Web Crypto)
|
||||
token-cache.ts - per-request in-memory cache (see note below)
|
||||
client/
|
||||
drive-client.ts - REST wrapper with retry, rate limiting, impersonation
|
||||
types.ts - Google Drive API v3 response types
|
||||
mapper.ts - DriveFile -> FileItem type mapping
|
||||
```
|
||||
|
||||
The library uses the Web Crypto API exclusively for JWT signing, which is the only option on Cloudflare Workers (no Node.js crypto module). The service account's private key (RSA, PEM-encoded) is imported as a CryptoKey and used for RS256 signatures. This works identically in Workers, Node, and Deno.
|
||||
|
||||
### Token caching
|
||||
|
||||
A note on the token cache: in Cloudflare Workers, each request runs in its own isolate, so an in-memory `Map` resets on every request. The cache is effectively a no-op in production. Each request generates a fresh JWT and exchanges it for an access token. This adds roughly 100ms of latency per request but avoids the complexity of KV-backed caching. If this becomes a measurable problem, the cache can be swapped to Workers KV without changing any calling code.
|
||||
|
||||
### Rate limiting and retry
|
||||
|
||||
The `DriveClient` uses the same `ConcurrencyLimiter` from the NetSuite integration, defaulting to 10 concurrent requests. On 429 responses, it reduces concurrency automatically. On 401 responses, it clears the cached token and retries (the service account token may have expired). On 5xx responses, it retries with exponential backoff up to 3 attempts.
|
||||
|
||||
### Encryption at rest
|
||||
|
||||
The service account JSON key is encrypted with AES-256-GCM before storage in D1. The encryption module in `src/lib/crypto.ts` is shared between the Google and NetSuite integrations, parameterized by a salt string so the same encryption key can be used for both without deriving identical keys.
|
||||
|
||||
The encryption key itself lives in the Cloudflare Workers environment (`GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY`), not in D1. This means a database export alone doesn't expose the service account credentials.
|
||||
|
||||
|
||||
Database schema
|
||||
---
|
||||
|
||||
Migration `0015_busy_photon.sql` adds two tables and one column:
|
||||
|
||||
**google_auth** stores one record per organization containing the encrypted service account key, workspace domain, and optional shared drive selection. The `connectedBy` column tracks which admin set up the connection.
|
||||
|
||||
**google_starred_files** stores per-user file stars. These are local to Compass rather than using Google's native starring, because the service account impersonation means each user's stars would actually be the service account's stars. This table solves that by keeping star state in D1.
|
||||
|
||||
**users.google_email** is a nullable text column for overriding the default email used for impersonation.
|
||||
|
||||
|
||||
Server actions
|
||||
---
|
||||
|
||||
All 16+ server actions live in `src/app/actions/google-drive.ts` and follow the standard Compass pattern: `"use server"`, authenticate with `requireAuth()`, check permissions with `requirePermission()`, return a discriminated union.
|
||||
|
||||
### Connection management (admin only)
|
||||
|
||||
- `connectGoogleDrive(keyJson, domain)` - encrypts and stores the service account key, validates by making a test API call
|
||||
- `disconnectGoogleDrive()` - deletes the google_auth record
|
||||
- `listAvailableSharedDrives()` - lists drives visible to the service account
|
||||
- `selectSharedDrive(id, name)` - sets which shared drive to browse by default
|
||||
|
||||
### File operations (RBAC-gated)
|
||||
|
||||
- `listDriveFiles(folderId?, pageToken?)` - browse a folder
|
||||
- `listDriveFilesForView(view, pageToken?)` - shared, recent, starred, trash views
|
||||
- `searchDriveFiles(query)` - fulltext search via Google's `fullText contains` query
|
||||
- `createDriveFolder(name, parentId?)` - requires `document:create`
|
||||
- `renameDriveFile(fileId, newName)` - requires `document:update`
|
||||
- `moveDriveFile(fileId, newParentId, oldParentId)` - requires `document:update`
|
||||
- `trashDriveFile(fileId)` - requires `document:delete`
|
||||
- `restoreDriveFile(fileId)` - requires `document:update`
|
||||
- `getUploadSessionUrl(fileName, mimeType, parentId?)` - initiates a resumable upload session; returns the session URL for client-side upload directly to Google
|
||||
- `getDriveStorageQuota()` - returns used and total bytes
|
||||
- `getDriveFileInfo(fileId)` - single file metadata
|
||||
- `listDriveFolders(parentId?)` - folders only, for the move dialog
|
||||
|
||||
### Local operations
|
||||
|
||||
- `toggleStarFile(googleFileId)` - D1 operation, no Google API call
|
||||
- `getStarredFileIds()` - returns starred IDs for current user
|
||||
- `updateUserGoogleEmail(userId, email)` - admin sets override email
|
||||
|
||||
|
||||
Upload flow
|
||||
---
|
||||
|
||||
Uploads use Google's resumable upload protocol. The server creates an upload session (which returns a time-limited, single-use URL), and the client uploads directly to Google via XHR with progress tracking. This avoids proxying file bytes through the Cloudflare Worker, which has request body size limits and would double the bandwidth cost. The upload URL is scoped to a single file and expires, so exposure is limited.
|
||||
|
||||
|
||||
Download flow
|
||||
---
|
||||
|
||||
Downloads are proxied through the worker at `/api/google/download/[fileId]`. The route authenticates the user, checks permissions, and streams the file content from Google through the response. For Google-native files (Docs, Sheets, Slides), it exports them as PDF or xlsx before streaming. This proxy is necessary because the Google API requires authentication that the browser doesn't have.
|
||||
|
||||
|
||||
File type mapping
|
||||
---
|
||||
|
||||
The mapper in `src/lib/google/mapper.ts` converts Google Drive's MIME types to Compass's `FileType` union. Google-native apps types (`application/vnd.google-apps.*`) get special treatment:
|
||||
|
||||
- `apps.folder` -> `"folder"`
|
||||
- `apps.document` -> `"document"` (opens in Google Docs via `webViewLink`)
|
||||
- `apps.spreadsheet` -> `"spreadsheet"` (opens in Google Sheets)
|
||||
- `apps.presentation` -> `"document"` (opens in Google Slides)
|
||||
|
||||
Standard MIME types are mapped by prefix (`image/*`, `video/*`, `audio/*`) or by keyword matching for archives, spreadsheets, documents, and code files. Anything unrecognized becomes `"unknown"`.
|
||||
|
||||
|
||||
UI changes
|
||||
---
|
||||
|
||||
### File browser
|
||||
|
||||
The `use-files.tsx` hook was rewritten from a pure client-side reducer with mock data to a server-action-backed fetcher. Local state (view mode, sort order, selection, search query) still lives in a reducer. File data comes from async server action calls. When Google Drive isn't connected, the hook falls back to mock data and the browser shows a "Demo Mode" banner prompting the admin to connect.
|
||||
|
||||
### Components
|
||||
|
||||
Every file operation component (upload, rename, move, new folder, context menu) was updated to call server actions when connected and fall back to the mock dispatch when not. The breadcrumb resolves folder ancestry by walking up the `parents` chain via `getDriveFileInfo()` calls.
|
||||
|
||||
### Routes
|
||||
|
||||
- `/dashboard/files` - root file browser (shows drive root or shared drive root)
|
||||
- `/dashboard/files?view=shared|recent|starred|trash` - view filters
|
||||
- `/dashboard/files/folder/[folderId]` - browse a specific folder by Google Drive ID
|
||||
|
||||
### Settings
|
||||
|
||||
The settings modal's Integrations tab now includes a Google Drive section with connection status, service account upload dialog, shared drive picker, and user email mapping.
|
||||
|
||||
|
||||
Setup
|
||||
---
|
||||
|
||||
### Google Cloud Console
|
||||
|
||||
1. Create a project (or use an existing one)
|
||||
2. Enable the Google Drive API
|
||||
3. Create a service account
|
||||
4. Enable domain-wide delegation on the service account
|
||||
5. Download the service account JSON key
|
||||
|
||||
### Google Workspace Admin Console
|
||||
|
||||
1. Go to Security -> API Controls -> Domain-wide Delegation
|
||||
2. Add the service account's client ID
|
||||
3. Grant scope: `https://www.googleapis.com/auth/drive`
|
||||
|
||||
### Compass
|
||||
|
||||
1. Set `GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY` in `.dev.vars` (local) or via `wrangler secret put` (production). This can be any random string - it's used to derive an AES-256 key for encrypting the service account JSON at rest.
|
||||
2. Run `bun run db:migrate:local` (or `bun run db:migrate:prod` for production)
|
||||
3. Start the app, go to Settings -> Integrations -> Google Drive
|
||||
4. Upload the service account JSON key and enter the workspace domain
|
||||
5. Optionally select a shared drive to scope the file browser
|
||||
|
||||
|
||||
Known limitations
|
||||
---
|
||||
|
||||
**Token caching is per-request.** Each request generates a new service account JWT and exchanges it for an access token. For typical usage this adds negligible latency, but high-traffic deployments might want KV-backed caching.
|
||||
|
||||
**Breadcrumb resolution is sequential.** Resolving a folder's ancestry requires one API call per parent level. Most folders are 2-4 levels deep, so this is 200-400ms of async resolution. The UI renders immediately and fills in breadcrumb segments as they resolve.
|
||||
|
||||
**Stars are local.** Because impersonation shares a single service account identity, Google's native star feature can't be used per-user. Stars are stored in D1 instead, which means they don't appear in the Google Drive web UI.
|
||||
|
||||
**No real-time sync.** Files are fetched on navigation. If someone adds a file through the Google Drive web UI, it appears in Compass on the next folder load. There's no push notification or polling.
|
||||
|
||||
**Single-organization model.** The `getOrgGoogleAuth()` helper grabs the first google_auth record without filtering by organization ID. This is correct for the current single-tenant-per-D1-instance architecture but would need a WHERE clause if multi-tenancy is added.
|
||||
@ -7,6 +7,7 @@ export default defineConfig({
|
||||
"./src/db/schema-plugins.ts",
|
||||
"./src/db/schema-agent.ts",
|
||||
"./src/db/schema-ai-config.ts",
|
||||
"./src/db/schema-google.ts",
|
||||
],
|
||||
out: "./drizzle",
|
||||
dialect: "sqlite",
|
||||
|
||||
23
drizzle/0015_busy_photon.sql
Executable file
23
drizzle/0015_busy_photon.sql
Executable file
@ -0,0 +1,23 @@
|
||||
CREATE TABLE `google_auth` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`service_account_key_encrypted` text NOT NULL,
|
||||
`workspace_domain` text NOT NULL,
|
||||
`shared_drive_id` text,
|
||||
`shared_drive_name` text,
|
||||
`connected_by` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`connected_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `google_starred_files` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`google_file_id` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `google_email` text;
|
||||
3331
drizzle/meta/0015_snapshot.json
Executable file
3331
drizzle/meta/0015_snapshot.json
Executable file
File diff suppressed because it is too large
Load Diff
@ -106,6 +106,13 @@
|
||||
"when": 1770431392946,
|
||||
"tag": "0014_new_giant_girl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1770439304946,
|
||||
"tag": "0015_busy_photon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
1029
src/app/actions/google-drive.ts
Executable file
1029
src/app/actions/google-drive.ts
Executable file
File diff suppressed because it is too large
Load Diff
106
src/app/api/google/download/[fileId]/route.ts
Executable file
106
src/app/api/google/download/[fileId]/route.ts
Executable file
@ -0,0 +1,106 @@
|
||||
import { NextRequest } from "next/server"
|
||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||
import { requireAuth } from "@/lib/auth"
|
||||
import { requirePermission } from "@/lib/permissions"
|
||||
import { getDb } from "@/db"
|
||||
import { googleAuth } from "@/db/schema-google"
|
||||
import { decrypt } from "@/lib/crypto"
|
||||
import {
|
||||
getGoogleConfig,
|
||||
getGoogleCryptoSalt,
|
||||
parseServiceAccountKey,
|
||||
} from "@/lib/google/config"
|
||||
import { DriveClient } from "@/lib/google/client/drive-client"
|
||||
import {
|
||||
isGoogleNativeFile,
|
||||
getExportMimeType,
|
||||
getExportExtension,
|
||||
} from "@/lib/google/mapper"
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ fileId: string }> }
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const user = await requireAuth()
|
||||
requirePermission(user, "document", "read")
|
||||
|
||||
const googleEmail = user.googleEmail ?? user.email
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
const envRecord = env as unknown as Record<string, string>
|
||||
const config = getGoogleConfig(envRecord)
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const auth = await db
|
||||
.select()
|
||||
.from(googleAuth)
|
||||
.limit(1)
|
||||
.then(rows => rows[0] ?? null)
|
||||
if (!auth) {
|
||||
return new Response("Google Drive not connected", {
|
||||
status: 404,
|
||||
})
|
||||
}
|
||||
|
||||
const keyJson = await decrypt(
|
||||
auth.serviceAccountKeyEncrypted,
|
||||
config.encryptionKey,
|
||||
getGoogleCryptoSalt()
|
||||
)
|
||||
const serviceAccountKey = parseServiceAccountKey(keyJson)
|
||||
const client = new DriveClient({ serviceAccountKey })
|
||||
|
||||
const { fileId } = await params
|
||||
|
||||
// get file metadata to determine type
|
||||
const fileMeta = await client.getFile(
|
||||
googleEmail,
|
||||
fileId
|
||||
)
|
||||
|
||||
let response: Response
|
||||
let fileName = fileMeta.name
|
||||
let contentType: string
|
||||
|
||||
if (isGoogleNativeFile(fileMeta.mimeType)) {
|
||||
const exportMime = getExportMimeType(fileMeta.mimeType)
|
||||
if (!exportMime) {
|
||||
return new Response("Cannot export this file type", {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
const ext = getExportExtension(fileMeta.mimeType)
|
||||
fileName = `${fileMeta.name}${ext}`
|
||||
contentType = exportMime
|
||||
response = await client.exportFile(
|
||||
googleEmail,
|
||||
fileId,
|
||||
exportMime
|
||||
)
|
||||
} else {
|
||||
contentType = fileMeta.mimeType
|
||||
response = await client.downloadFile(
|
||||
googleEmail,
|
||||
fileId
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response("Failed to download file", {
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||
"Cache-Control": "private, max-age=300",
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Download error:", err)
|
||||
return new Response("Download failed", { status: 500 })
|
||||
}
|
||||
}
|
||||
16
src/app/dashboard/files/folder/[folderId]/page.tsx
Executable file
16
src/app/dashboard/files/folder/[folderId]/page.tsx
Executable file
@ -0,0 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { FileBrowser } from "@/components/files/file-browser"
|
||||
|
||||
export default function FilesFolderPage() {
|
||||
const params = useParams()
|
||||
const folderId = params.folderId as string
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<FileBrowser folderId={folderId} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@ -6,7 +6,7 @@ import { FileBrowser } from "@/components/files/file-browser"
|
||||
export default function FilesPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<FileBrowser path={[]} />
|
||||
<FileBrowser />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { IconChevronRight } from "@tabler/icons-react"
|
||||
|
||||
import { useFiles } from "@/hooks/use-files"
|
||||
import { getDriveFileInfo } from "@/app/actions/google-drive"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@ -12,12 +15,112 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
|
||||
export function FileBreadcrumb({ path }: { path: string[] }) {
|
||||
type BreadcrumbSegment = {
|
||||
name: string
|
||||
folderId: string | null
|
||||
}
|
||||
|
||||
export function FileBreadcrumb({
|
||||
path,
|
||||
folderId,
|
||||
}: {
|
||||
path?: string[]
|
||||
folderId?: string
|
||||
}) {
|
||||
const { state } = useFiles()
|
||||
const [segments, setSegments] = useState<
|
||||
BreadcrumbSegment[]
|
||||
>([])
|
||||
|
||||
// for google drive mode: resolve folder ancestry
|
||||
useEffect(() => {
|
||||
if (state.isConnected !== true || !folderId) {
|
||||
setSegments([])
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function resolve() {
|
||||
const trail: BreadcrumbSegment[] = []
|
||||
let currentId: string | null = folderId ?? null
|
||||
|
||||
// walk up the parents chain (max 10 deep to prevent infinite loops)
|
||||
for (let depth = 0; depth < 10 && currentId; depth++) {
|
||||
try {
|
||||
const result = await getDriveFileInfo(currentId)
|
||||
if (!result.success || cancelled) break
|
||||
trail.unshift({
|
||||
name: result.file.name,
|
||||
folderId: currentId,
|
||||
})
|
||||
currentId = result.file.parentId
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setSegments(trail)
|
||||
}
|
||||
}
|
||||
|
||||
resolve()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [folderId, state.isConnected])
|
||||
|
||||
// mock data mode: use path array
|
||||
if (state.isConnected !== true) {
|
||||
const effectivePath = path ?? []
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
{effectivePath.length === 0 ? (
|
||||
<BreadcrumbPage>My Files</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/dashboard/files">
|
||||
My Files
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{effectivePath.map((segment, i) => {
|
||||
const isLast = i === effectivePath.length - 1
|
||||
const href = `/dashboard/files/${effectivePath.slice(0, i + 1).join("/")}`
|
||||
return (
|
||||
<span key={segment} className="contents">
|
||||
<BreadcrumbSeparator>
|
||||
<IconChevronRight size={14} />
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>
|
||||
{segment}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={href}>{segment}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
|
||||
// google drive mode: use resolved segments
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
{path.length === 0 ? (
|
||||
{segments.length === 0 && !folderId ? (
|
||||
<BreadcrumbPage>My Files</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
@ -25,20 +128,26 @@ export function FileBreadcrumb({ path }: { path: string[] }) {
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{path.map((segment, i) => {
|
||||
const isLast = i === path.length - 1
|
||||
const href = `/dashboard/files/${path.slice(0, i + 1).join("/")}`
|
||||
{segments.map((seg, i) => {
|
||||
const isLast = i === segments.length - 1
|
||||
return (
|
||||
<span key={segment} className="contents">
|
||||
<span
|
||||
key={seg.folderId ?? i}
|
||||
className="contents"
|
||||
>
|
||||
<BreadcrumbSeparator>
|
||||
<IconChevronRight size={14} />
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>{segment}</BreadcrumbPage>
|
||||
<BreadcrumbPage>{seg.name}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={href}>{segment}</Link>
|
||||
<Link
|
||||
href={`/dashboard/files/folder/${seg.folderId}`}
|
||||
>
|
||||
{seg.name}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
IconCloudOff,
|
||||
IconAlertTriangle,
|
||||
IconRefresh,
|
||||
IconLoader2,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import type { FileItem } from "@/lib/files-data"
|
||||
import { useFiles, type FileView } from "@/hooks/use-files"
|
||||
@ -17,23 +23,54 @@ import { FileRenameDialog } from "./file-rename-dialog"
|
||||
import { FileMoveDialog } from "./file-move-dialog"
|
||||
import { FileDropZone } from "./file-drop-zone"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function FileBrowser({ path }: { path: string[] }) {
|
||||
export function FileBrowser({
|
||||
path,
|
||||
folderId,
|
||||
}: {
|
||||
path?: string[]
|
||||
folderId?: string
|
||||
}) {
|
||||
const searchParams = useSearchParams()
|
||||
const viewParam = searchParams.get("view") as FileView | null
|
||||
const currentView = viewParam ?? "my-files"
|
||||
|
||||
const { state, dispatch, getFilesForView } = useFiles()
|
||||
const files = getFilesForView(currentView, path)
|
||||
const {
|
||||
state,
|
||||
dispatch,
|
||||
getFilesForView,
|
||||
fetchFiles,
|
||||
loadMore,
|
||||
createFolder,
|
||||
starFile,
|
||||
} = useFiles()
|
||||
|
||||
const effectivePath = path ?? []
|
||||
const files = getFilesForView(currentView, effectivePath)
|
||||
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [uploadFiles, setUploadFiles] = useState<File[]>([])
|
||||
const [newFolderOpen, setNewFolderOpen] = useState(false)
|
||||
const [renameFile, setRenameFile] = useState<FileItem | null>(null)
|
||||
const [renameFile, setRenameFile] = useState<FileItem | null>(
|
||||
null
|
||||
)
|
||||
const [moveFile, setMoveFile] = useState<FileItem | null>(null)
|
||||
|
||||
const { handleClick } = useFileSelection(files, state.selectedIds, {
|
||||
select: (ids) => dispatch({ type: "SET_SELECTED", payload: ids }),
|
||||
})
|
||||
const { handleClick } = useFileSelection(
|
||||
files,
|
||||
state.selectedIds,
|
||||
{
|
||||
select: ids =>
|
||||
dispatch({ type: "SET_SELECTED", payload: ids }),
|
||||
}
|
||||
)
|
||||
|
||||
// fetch files when connected and folder/view changes
|
||||
useEffect(() => {
|
||||
if (state.isConnected !== true) return
|
||||
fetchFiles(folderId, currentView)
|
||||
}, [state.isConnected, folderId, currentView, fetchFiles])
|
||||
|
||||
const handleBackgroundClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@ -44,45 +81,40 @@ export function FileBrowser({ path }: { path: string[] }) {
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
// for mock data mode, resolve parentId from path
|
||||
const parentId = (() => {
|
||||
if (path.length === 0) return null
|
||||
if (folderId) return folderId
|
||||
if (effectivePath.length === 0) return null
|
||||
const folder = state.files.find(
|
||||
(f) =>
|
||||
f =>
|
||||
f.type === "folder" &&
|
||||
f.name === path[path.length - 1] &&
|
||||
JSON.stringify(f.path) === JSON.stringify(path.slice(0, -1))
|
||||
f.name === effectivePath[effectivePath.length - 1] &&
|
||||
JSON.stringify(f.path) ===
|
||||
JSON.stringify(effectivePath.slice(0, -1))
|
||||
)
|
||||
return folder?.id ?? null
|
||||
})()
|
||||
|
||||
const handleNew = useCallback(
|
||||
(type: NewFileType) => {
|
||||
async (type: NewFileType) => {
|
||||
if (type === "folder") {
|
||||
setNewFolderOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
const names: Record<string, string> = {
|
||||
document: "Untitled Document",
|
||||
spreadsheet: "Untitled Spreadsheet",
|
||||
presentation: "Untitled Presentation",
|
||||
}
|
||||
const fileType = type === "presentation" ? "document" : type
|
||||
|
||||
dispatch({
|
||||
type: "CREATE_FILE",
|
||||
payload: {
|
||||
name: names[type],
|
||||
fileType: fileType as FileItem["type"],
|
||||
parentId,
|
||||
path,
|
||||
},
|
||||
})
|
||||
toast.success(`${names[type]} created`)
|
||||
// for google-native doc creation, these would
|
||||
// be created as google docs/sheets/slides
|
||||
toast.info(
|
||||
"Creating Google Workspace files coming soon"
|
||||
)
|
||||
},
|
||||
[dispatch, parentId, path]
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback((droppedFiles: File[]) => {
|
||||
setUploadFiles(droppedFiles)
|
||||
setUploadOpen(true)
|
||||
}, [])
|
||||
|
||||
const viewTitle: Record<FileView, string> = {
|
||||
"my-files": "",
|
||||
shared: "Shared with me",
|
||||
@ -91,19 +123,97 @@ export function FileBrowser({ path }: { path: string[] }) {
|
||||
trash: "Trash",
|
||||
}
|
||||
|
||||
// loading state (only for initial load)
|
||||
if (state.isConnected === null) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<IconLoader2
|
||||
size={32}
|
||||
className="animate-spin"
|
||||
/>
|
||||
<p className="text-sm">
|
||||
Checking connection...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// error state
|
||||
if (state.error && state.files.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<IconAlertTriangle
|
||||
size={32}
|
||||
className="text-destructive"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{state.error}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
fetchFiles(folderId, currentView)
|
||||
}
|
||||
>
|
||||
<IconRefresh size={16} className="mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
{currentView !== "my-files" && (
|
||||
<h1 className="text-lg font-semibold">{viewTitle[currentView]}</h1>
|
||||
{/* not connected banner (demo mode) */}
|
||||
{state.isConnected === false && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed border-muted-foreground/30 bg-muted/50 px-4 py-3">
|
||||
<IconCloudOff
|
||||
size={18}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing demo files. Connect Google Drive in
|
||||
Settings to see your real files.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentView !== "my-files" && (
|
||||
<h1 className="text-lg font-semibold">
|
||||
{viewTitle[currentView]}
|
||||
</h1>
|
||||
)}
|
||||
{currentView === "my-files" && (
|
||||
<FileBreadcrumb
|
||||
path={effectivePath}
|
||||
folderId={folderId}
|
||||
/>
|
||||
)}
|
||||
{currentView === "my-files" && <FileBreadcrumb path={path} />}
|
||||
<FileToolbar
|
||||
onNew={handleNew}
|
||||
onUpload={() => setUploadOpen(true)}
|
||||
onUpload={() => {
|
||||
setUploadFiles([])
|
||||
setUploadOpen(true)
|
||||
}}
|
||||
/>
|
||||
<FileDropZone onDrop={() => setUploadOpen(true)}>
|
||||
<ScrollArea className="flex-1" onClick={handleBackgroundClick}>
|
||||
{state.viewMode === "grid" ? (
|
||||
<FileDropZone onDrop={handleDrop}>
|
||||
<ScrollArea
|
||||
className="flex-1"
|
||||
onClick={handleBackgroundClick}
|
||||
>
|
||||
{state.isLoading && state.files.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<IconLoader2
|
||||
size={24}
|
||||
className="animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
) : state.viewMode === "grid" ? (
|
||||
<FileGrid
|
||||
files={files}
|
||||
selectedIds={state.selectedIds}
|
||||
@ -120,27 +230,50 @@ export function FileBrowser({ path }: { path: string[] }) {
|
||||
onMove={setMoveFile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.nextPageToken && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadMore}
|
||||
disabled={state.isLoading}
|
||||
>
|
||||
{state.isLoading ? (
|
||||
<IconLoader2
|
||||
size={16}
|
||||
className="mr-2 animate-spin"
|
||||
/>
|
||||
) : null}
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</FileDropZone>
|
||||
|
||||
<FileUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
<FileUploadDialog
|
||||
open={uploadOpen}
|
||||
onOpenChange={setUploadOpen}
|
||||
files={uploadFiles}
|
||||
parentId={parentId ?? folderId}
|
||||
/>
|
||||
<FileNewFolderDialog
|
||||
open={newFolderOpen}
|
||||
onOpenChange={setNewFolderOpen}
|
||||
currentPath={path}
|
||||
parentId={parentId}
|
||||
currentPath={effectivePath}
|
||||
parentId={parentId ?? folderId ?? null}
|
||||
/>
|
||||
<FileRenameDialog
|
||||
open={!!renameFile}
|
||||
onOpenChange={(open) => !open && setRenameFile(null)}
|
||||
onOpenChange={open => !open && setRenameFile(null)}
|
||||
file={renameFile}
|
||||
/>
|
||||
<FileMoveDialog
|
||||
open={!!moveFile}
|
||||
onOpenChange={(open) => !open && setMoveFile(null)}
|
||||
onOpenChange={open => !open && setMoveFile(null)}
|
||||
file={moveFile}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import {
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconFolderSymlink,
|
||||
IconShare,
|
||||
IconStar,
|
||||
@ -13,6 +14,7 @@ import {
|
||||
|
||||
import type { FileItem } from "@/lib/files-data"
|
||||
import { useFiles } from "@/hooks/use-files"
|
||||
import { isGoogleNativeFile } from "@/lib/google/mapper"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@ -33,43 +35,143 @@ export function FileContextMenu({
|
||||
onRename: (file: FileItem) => void
|
||||
onMove: (file: FileItem) => void
|
||||
}) {
|
||||
const { dispatch } = useFiles()
|
||||
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>
|
||||
<ContextMenuTrigger asChild>
|
||||
{children}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
{file.type === "folder" && (
|
||||
<ContextMenuItem onClick={() => toast.info("Opening folder...")}>
|
||||
<IconFolderSymlink size={16} className="mr-2" />
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Opening folder...")
|
||||
}
|
||||
>
|
||||
<IconFolderSymlink
|
||||
size={16}
|
||||
className="mr-2"
|
||||
/>
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => toast.info("Share dialog coming soon")}>
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Share dialog coming soon")
|
||||
}
|
||||
>
|
||||
<IconShare size={16} className="mr-2" />
|
||||
Share
|
||||
</ContextMenuItem>
|
||||
{!file.trashed && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => toast.success("Download started")}>
|
||||
<IconDownload size={16} className="mr-2" />
|
||||
Download
|
||||
</ContextMenuItem>
|
||||
{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" />
|
||||
<IconFolderSymlink
|
||||
size={16}
|
||||
className="mr-2"
|
||||
/>
|
||||
Move to
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => dispatch({ type: "STAR_FILE", payload: file.id })}
|
||||
>
|
||||
<ContextMenuItem onClick={handleStar}>
|
||||
{file.starred ? (
|
||||
<>
|
||||
<IconStarFilled size={16} className="mr-2 text-amber-400" />
|
||||
<IconStarFilled
|
||||
size={16}
|
||||
className="mr-2 text-amber-400"
|
||||
/>
|
||||
Unstar
|
||||
</>
|
||||
) : (
|
||||
@ -82,10 +184,7 @@ export function FileContextMenu({
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
dispatch({ type: "TRASH_FILE", payload: file.id })
|
||||
toast.success(`"${file.name}" moved to trash`)
|
||||
}}
|
||||
onClick={handleTrash}
|
||||
>
|
||||
<IconTrash size={16} className="mr-2" />
|
||||
Delete
|
||||
@ -93,12 +192,7 @@ export function FileContextMenu({
|
||||
</>
|
||||
)}
|
||||
{file.trashed && (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
dispatch({ type: "RESTORE_FILE", payload: file.id })
|
||||
toast.success(`"${file.name}" restored`)
|
||||
}}
|
||||
>
|
||||
<ContextMenuItem onClick={handleRestore}>
|
||||
<IconTrashOff size={16} className="mr-2" />
|
||||
Restore
|
||||
</ContextMenuItem>
|
||||
|
||||
@ -9,7 +9,7 @@ export function FileDropZone({
|
||||
onDrop,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onDrop: () => void
|
||||
onDrop: (files: File[]) => void
|
||||
}) {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [dragCounter, setDragCounter] = useState(0)
|
||||
@ -17,7 +17,7 @@ export function FileDropZone({
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragCounter((c) => c + 1)
|
||||
setDragCounter(c => c + 1)
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
setDragging(true)
|
||||
}
|
||||
@ -28,7 +28,7 @@ export function FileDropZone({
|
||||
const handleDragLeave = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragCounter((c) => {
|
||||
setDragCounter(c => {
|
||||
const next = c - 1
|
||||
if (next <= 0) setDragging(false)
|
||||
return Math.max(0, next)
|
||||
@ -37,9 +37,12 @@ export function FileDropZone({
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
@ -47,7 +50,7 @@ export function FileDropZone({
|
||||
setDragging(false)
|
||||
setDragCounter(0)
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
onDrop()
|
||||
onDrop(Array.from(e.dataTransfer.files))
|
||||
}
|
||||
},
|
||||
[onDrop]
|
||||
@ -73,7 +76,9 @@ export function FileDropZone({
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 text-primary">
|
||||
<IconCloudUpload size={48} strokeWidth={1.5} />
|
||||
<p className="text-sm font-medium">Drop files to upload</p>
|
||||
<p className="text-sm font-medium">
|
||||
Drop files to upload
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { forwardRef } from "react"
|
||||
import { IconStar, IconStarFilled, IconUsers, IconDots } from "@tabler/icons-react"
|
||||
import {
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import type { FileItem as FileItemType } from "@/lib/files-data"
|
||||
@ -29,13 +33,32 @@ export const FolderCard = forwardRef<
|
||||
selected: boolean
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}
|
||||
>(function FolderCard({ file, selected, onClick, ...props }, ref) {
|
||||
>(function FolderCard(
|
||||
{ file, selected, onClick, ...props },
|
||||
ref
|
||||
) {
|
||||
const router = useRouter()
|
||||
const { dispatch } = useFiles()
|
||||
const { starFile, state, dispatch } = useFiles()
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
const folderPath = [...file.path, file.name].join("/")
|
||||
router.push(`/dashboard/files/${folderPath}`)
|
||||
if (state.isConnected === true) {
|
||||
router.push(`/dashboard/files/folder/${file.id}`)
|
||||
} else {
|
||||
const folderPath = [...file.path, file.name].join("/")
|
||||
router.push(`/dashboard/files/${folderPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStar = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (state.isConnected === true) {
|
||||
await starFile(file.id)
|
||||
} else {
|
||||
dispatch({
|
||||
type: "OPTIMISTIC_STAR",
|
||||
payload: file.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -50,25 +73,37 @@ export const FolderCard = forwardRef<
|
||||
onDoubleClick={handleDoubleClick}
|
||||
{...props}
|
||||
>
|
||||
<FileIcon type="folder" size={22} className="shrink-0" />
|
||||
<span className="text-sm font-medium line-clamp-2 flex-1 break-words">{file.name}</span>
|
||||
<FileIcon
|
||||
type="folder"
|
||||
size={22}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="text-sm font-medium line-clamp-2 flex-1 break-words">
|
||||
{file.name}
|
||||
</span>
|
||||
{file.shared && (
|
||||
<IconUsers size={14} className="text-muted-foreground shrink-0" />
|
||||
<IconUsers
|
||||
size={14}
|
||||
className="text-muted-foreground shrink-0"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className={cn(
|
||||
"shrink-0 opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
file.starred && "opacity-100"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
dispatch({ type: "STAR_FILE", payload: file.id })
|
||||
}}
|
||||
onClick={handleStar}
|
||||
>
|
||||
{file.starred ? (
|
||||
<IconStarFilled size={14} className="text-amber-400" />
|
||||
<IconStarFilled
|
||||
size={14}
|
||||
className="text-amber-400"
|
||||
/>
|
||||
) : (
|
||||
<IconStar size={14} className="text-muted-foreground hover:text-amber-400" />
|
||||
<IconStar
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-amber-400"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@ -82,8 +117,23 @@ export const FileCard = forwardRef<
|
||||
selected: boolean
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}
|
||||
>(function FileCard({ file, selected, onClick, ...props }, ref) {
|
||||
const { dispatch } = useFiles()
|
||||
>(function FileCard(
|
||||
{ file, selected, onClick, ...props },
|
||||
ref
|
||||
) {
|
||||
const { starFile, state, dispatch } = useFiles()
|
||||
|
||||
const handleStar = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (state.isConnected === true) {
|
||||
await starFile(file.id)
|
||||
} else {
|
||||
dispatch({
|
||||
type: "OPTIMISTIC_STAR",
|
||||
payload: file.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -99,13 +149,20 @@ export const FileCard = forwardRef<
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center h-20 sm:h-24",
|
||||
fileTypeColors[file.type] ?? fileTypeColors.unknown
|
||||
fileTypeColors[file.type] ??
|
||||
fileTypeColors.unknown
|
||||
)}
|
||||
>
|
||||
<FileIcon type={file.type} size={32} className="opacity-70 sm:size-10" />
|
||||
<FileIcon
|
||||
type={file.type}
|
||||
size={32}
|
||||
className="opacity-70 sm:size-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-2.5 py-2.5 border-t">
|
||||
<p className="text-sm font-medium line-clamp-2 break-words leading-snug">{file.name}</p>
|
||||
<p className="text-sm font-medium line-clamp-2 break-words leading-snug">
|
||||
{file.name}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{formatRelativeDate(file.modifiedAt)}
|
||||
@ -116,15 +173,18 @@ export const FileCard = forwardRef<
|
||||
"opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0",
|
||||
file.starred && "opacity-100"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
dispatch({ type: "STAR_FILE", payload: file.id })
|
||||
}}
|
||||
onClick={handleStar}
|
||||
>
|
||||
{file.starred ? (
|
||||
<IconStarFilled size={14} className="text-amber-400" />
|
||||
<IconStarFilled
|
||||
size={14}
|
||||
className="text-amber-400"
|
||||
/>
|
||||
) : (
|
||||
<IconStar size={14} className="text-muted-foreground hover:text-amber-400" />
|
||||
<IconStar
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-amber-400"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { IconFolder, IconFolderSymlink } from "@tabler/icons-react"
|
||||
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"
|
||||
@ -17,6 +21,8 @@ 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,
|
||||
@ -26,31 +32,91 @@ export function FileMoveDialog({
|
||||
onOpenChange: (open: boolean) => void
|
||||
file: FileItem | null
|
||||
}) {
|
||||
const { dispatch, getFolders } = useFiles()
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||
const {
|
||||
moveFile: moveFileFn,
|
||||
fetchFolders,
|
||||
getFolders,
|
||||
state,
|
||||
dispatch,
|
||||
} = useFiles()
|
||||
|
||||
const folders = getFolders().filter((f) => f.id !== file?.id)
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [movePending, setMovePending] = useState(false)
|
||||
const [driveFolders, setDriveFolders] = useState<
|
||||
FolderEntry[]
|
||||
>([])
|
||||
|
||||
const handleMove = () => {
|
||||
// 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
|
||||
|
||||
const targetFolder = folders.find((f) => f.id === selectedFolderId)
|
||||
const targetPath = targetFolder
|
||||
? [...targetFolder.path, targetFolder.name]
|
||||
: []
|
||||
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: "MOVE_FILE",
|
||||
payload: {
|
||||
id: file.id,
|
||||
targetFolderId: selectedFolderId,
|
||||
targetPath,
|
||||
},
|
||||
})
|
||||
toast.success(
|
||||
`Moved "${file.name}" to ${targetFolder?.name ?? "My Files"}`
|
||||
)
|
||||
onOpenChange(false)
|
||||
dispatch({
|
||||
type: "REMOVE_FILE",
|
||||
payload: file.id,
|
||||
})
|
||||
toast.success(
|
||||
`Moved "${file.name}" to ${targetFolder?.name ?? "My Files"}`
|
||||
)
|
||||
}
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setMovePending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -62,37 +128,105 @@ export function FileMoveDialog({
|
||||
Move to
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-64 rounded-md border p-2">
|
||||
<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"
|
||||
|
||||
{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>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
onClick={() => setSelectedFolderId(null)}
|
||||
>
|
||||
<IconFolder size={16} className="text-amber-500" />
|
||||
My Files (root)
|
||||
</button>
|
||||
{folders.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>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={movePending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleMove}>Move here</Button>
|
||||
<Button
|
||||
onClick={handleMove}
|
||||
disabled={movePending}
|
||||
>
|
||||
{movePending && (
|
||||
<IconLoader2
|
||||
size={16}
|
||||
className="mr-2 animate-spin"
|
||||
/>
|
||||
)}
|
||||
Move here
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { IconFolderPlus } from "@tabler/icons-react"
|
||||
import { IconFolderPlus, IconLoader2 } from "@tabler/icons-react"
|
||||
|
||||
import { useFiles } from "@/hooks/use-files"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@ -27,19 +27,52 @@ export function FileNewFolderDialog({
|
||||
parentId: string | null
|
||||
}) {
|
||||
const [name, setName] = useState("")
|
||||
const { dispatch } = useFiles()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { createFolder, state, dispatch } = useFiles()
|
||||
|
||||
const handleCreate = () => {
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
dispatch({
|
||||
type: "CREATE_FOLDER",
|
||||
payload: { name: trimmed, parentId, path: currentPath },
|
||||
})
|
||||
toast.success(`Folder "${trimmed}" created`)
|
||||
setName("")
|
||||
onOpenChange(false)
|
||||
setLoading(true)
|
||||
try {
|
||||
if (state.isConnected === true) {
|
||||
const ok = await createFolder(
|
||||
trimmed,
|
||||
parentId ?? undefined
|
||||
)
|
||||
if (ok) {
|
||||
toast.success(`Folder "${trimmed}" created`)
|
||||
} else {
|
||||
toast.error("Failed to create folder")
|
||||
}
|
||||
} else {
|
||||
// mock mode: local dispatch
|
||||
dispatch({
|
||||
type: "OPTIMISTIC_ADD_FOLDER",
|
||||
payload: {
|
||||
id: `folder-${Date.now()}`,
|
||||
name: trimmed,
|
||||
type: "folder",
|
||||
size: 0,
|
||||
path: currentPath,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString(),
|
||||
owner: { name: "You" },
|
||||
starred: false,
|
||||
shared: false,
|
||||
trashed: false,
|
||||
parentId,
|
||||
},
|
||||
})
|
||||
toast.success(`Folder "${trimmed}" created`)
|
||||
}
|
||||
|
||||
setName("")
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -55,16 +88,32 @@ export function FileNewFolderDialog({
|
||||
<Input
|
||||
placeholder="Folder name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e =>
|
||||
e.key === "Enter" && handleCreate()
|
||||
}
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim()}>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || loading}
|
||||
>
|
||||
{loading && (
|
||||
<IconLoader2
|
||||
size={16}
|
||||
className="mr-2 animate-spin"
|
||||
/>
|
||||
)}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { IconEdit } from "@tabler/icons-react"
|
||||
import { IconEdit, IconLoader2 } from "@tabler/icons-react"
|
||||
|
||||
import type { FileItem } from "@/lib/files-data"
|
||||
import { useFiles } from "@/hooks/use-files"
|
||||
@ -26,20 +26,42 @@ export function FileRenameDialog({
|
||||
file: FileItem | null
|
||||
}) {
|
||||
const [name, setName] = useState("")
|
||||
const { dispatch } = useFiles()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const {
|
||||
renameFile: renameFileFn,
|
||||
state,
|
||||
dispatch,
|
||||
} = useFiles()
|
||||
|
||||
useEffect(() => {
|
||||
if (file) setName(file.name)
|
||||
}, [file])
|
||||
|
||||
const handleRename = () => {
|
||||
const handleRename = async () => {
|
||||
if (!file) return
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || trimmed === file.name) return
|
||||
|
||||
dispatch({ type: "RENAME_FILE", payload: { id: file.id, name: trimmed } })
|
||||
toast.success(`Renamed to "${trimmed}"`)
|
||||
onOpenChange(false)
|
||||
setLoading(true)
|
||||
try {
|
||||
if (state.isConnected === true) {
|
||||
const ok = await renameFileFn(file.id, trimmed)
|
||||
if (ok) {
|
||||
toast.success(`Renamed to "${trimmed}"`)
|
||||
} else {
|
||||
toast.error("Failed to rename")
|
||||
}
|
||||
} else {
|
||||
dispatch({
|
||||
type: "OPTIMISTIC_RENAME",
|
||||
payload: { id: file.id, name: trimmed },
|
||||
})
|
||||
toast.success(`Renamed to "${trimmed}"`)
|
||||
}
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -54,19 +76,36 @@ export function FileRenameDialog({
|
||||
<div className="py-2">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRename()}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e =>
|
||||
e.key === "Enter" && handleRename()
|
||||
}
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRename}
|
||||
disabled={!name.trim() || name.trim() === file?.name}
|
||||
disabled={
|
||||
!name.trim() ||
|
||||
name.trim() === file?.name ||
|
||||
loading
|
||||
}
|
||||
>
|
||||
{loading && (
|
||||
<IconLoader2
|
||||
size={16}
|
||||
className="mr-2 animate-spin"
|
||||
/>
|
||||
)}
|
||||
Rename
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@ -2,10 +2,17 @@
|
||||
|
||||
import { forwardRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { IconStar, IconStarFilled, IconUsers } from "@tabler/icons-react"
|
||||
import {
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import type { FileItem } from "@/lib/files-data"
|
||||
import { formatFileSize, formatRelativeDate } from "@/lib/file-utils"
|
||||
import {
|
||||
formatFileSize,
|
||||
formatRelativeDate,
|
||||
} from "@/lib/file-utils"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { useFiles } from "@/hooks/use-files"
|
||||
import { TableCell, TableRow } from "@/components/ui/table"
|
||||
@ -18,14 +25,35 @@ export const FileRow = forwardRef<
|
||||
selected: boolean
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}
|
||||
>(function FileRow({ file, selected, onClick, ...props }, ref) {
|
||||
>(function FileRow(
|
||||
{ file, selected, onClick, ...props },
|
||||
ref
|
||||
) {
|
||||
const router = useRouter()
|
||||
const { dispatch } = useFiles()
|
||||
const { starFile, state, dispatch } = useFiles()
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
if (file.type === "folder") {
|
||||
const folderPath = [...file.path, file.name].join("/")
|
||||
router.push(`/dashboard/files/${folderPath}`)
|
||||
if (state.isConnected === true) {
|
||||
router.push(`/dashboard/files/folder/${file.id}`)
|
||||
} else {
|
||||
const folderPath = [...file.path, file.name].join(
|
||||
"/"
|
||||
)
|
||||
router.push(`/dashboard/files/${folderPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleStar = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (state.isConnected === true) {
|
||||
await starFile(file.id)
|
||||
} else {
|
||||
dispatch({
|
||||
type: "OPTIMISTIC_STAR",
|
||||
payload: file.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,8 +71,15 @@ export const FileRow = forwardRef<
|
||||
<TableCell className="w-[40%]">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<FileIcon type={file.type} size={18} />
|
||||
<span className="truncate text-sm font-medium">{file.name}</span>
|
||||
{file.shared && <IconUsers size={13} className="text-muted-foreground shrink-0" />}
|
||||
<span className="truncate text-sm font-medium">
|
||||
{file.name}
|
||||
</span>
|
||||
{file.shared && (
|
||||
<IconUsers
|
||||
size={13}
|
||||
className="text-muted-foreground shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
@ -54,7 +89,9 @@ export const FileRow = forwardRef<
|
||||
{file.owner.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{file.type === "folder" ? "—" : formatFileSize(file.size)}
|
||||
{file.type === "folder"
|
||||
? "—"
|
||||
: formatFileSize(file.size)}
|
||||
</TableCell>
|
||||
<TableCell className="w-8">
|
||||
<button
|
||||
@ -62,15 +99,18 @@ export const FileRow = forwardRef<
|
||||
"opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
file.starred && "opacity-100"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
dispatch({ type: "STAR_FILE", payload: file.id })
|
||||
}}
|
||||
onClick={handleStar}
|
||||
>
|
||||
{file.starred ? (
|
||||
<IconStarFilled size={14} className="text-amber-400" />
|
||||
<IconStarFilled
|
||||
size={14}
|
||||
className="text-amber-400"
|
||||
/>
|
||||
) : (
|
||||
<IconStar size={14} className="text-muted-foreground hover:text-amber-400" />
|
||||
<IconStar
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-amber-400"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</TableCell>
|
||||
|
||||
@ -1,51 +1,229 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { IconUpload } from "@tabler/icons-react"
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { IconUpload, IconFile, IconCheck, IconX } from "@tabler/icons-react"
|
||||
|
||||
import { useFiles } from "@/hooks/use-files"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
import { formatFileSize } from "@/lib/file-utils"
|
||||
|
||||
type UploadItem = {
|
||||
file: File
|
||||
progress: number
|
||||
status: "pending" | "uploading" | "done" | "error"
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function FileUploadDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
files: initialFiles,
|
||||
parentId,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
files?: File[]
|
||||
parentId?: string | null
|
||||
}) {
|
||||
const [progress, setProgress] = useState(0)
|
||||
const { getUploadUrl, state, fetchFiles } = useFiles()
|
||||
const [uploads, setUploads] = useState<UploadItem[]>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setProgress(0)
|
||||
setUploads([])
|
||||
setUploading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
const interval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(interval)
|
||||
setTimeout(() => {
|
||||
onOpenChange(false)
|
||||
toast.success("File uploaded successfully")
|
||||
}, 300)
|
||||
return 100
|
||||
}
|
||||
return prev + Math.random() * 15
|
||||
})
|
||||
}, 200)
|
||||
if (initialFiles && initialFiles.length > 0) {
|
||||
setUploads(
|
||||
initialFiles.map(f => ({
|
||||
file: f,
|
||||
progress: 0,
|
||||
status: "pending" as const,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}, [open, initialFiles])
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [open, onOpenChange])
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files
|
||||
if (!selected) return
|
||||
const newUploads: UploadItem[] = Array.from(
|
||||
selected
|
||||
).map(f => ({
|
||||
file: f,
|
||||
progress: 0,
|
||||
status: "pending" as const,
|
||||
}))
|
||||
setUploads(prev => [...prev, ...newUploads])
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const uploadSingleFile = useCallback(
|
||||
async (item: UploadItem, index: number) => {
|
||||
setUploads(prev =>
|
||||
prev.map((u, i) =>
|
||||
i === index ? { ...u, status: "uploading" } : u
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
if (state.isConnected !== true) {
|
||||
// mock mode: fake progress
|
||||
for (let p = 0; p <= 100; p += 20) {
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
setUploads(prev =>
|
||||
prev.map((u, i) =>
|
||||
i === index
|
||||
? { ...u, progress: Math.min(p, 100) }
|
||||
: u
|
||||
)
|
||||
)
|
||||
}
|
||||
setUploads(prev =>
|
||||
prev.map((u, i) =>
|
||||
i === index
|
||||
? { ...u, status: "done", progress: 100 }
|
||||
: u
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const uploadUrl = await getUploadUrl(
|
||||
item.file.name,
|
||||
item.file.type || "application/octet-stream",
|
||||
parentId ?? undefined
|
||||
)
|
||||
|
||||
if (!uploadUrl) {
|
||||
throw new Error("Failed to get upload URL")
|
||||
}
|
||||
|
||||
// upload directly to google using XHR for progress
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open("PUT", uploadUrl)
|
||||
xhr.setRequestHeader(
|
||||
"Content-Type",
|
||||
item.file.type || "application/octet-stream"
|
||||
)
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round(
|
||||
(e.loaded / e.total) * 100
|
||||
)
|
||||
setUploads(prev =>
|
||||
prev.map((u, i) =>
|
||||
i === index
|
||||
? { ...u, progress: pct }
|
||||
: u
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`Upload failed: ${xhr.status}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onerror = () =>
|
||||
reject(new Error("Upload failed"))
|
||||
xhr.send(item.file)
|
||||
})
|
||||
|
||||
setUploads(prev =>
|
||||
prev.map((u, i) =>
|
||||
i === index
|
||||
? { ...u, status: "done", progress: 100 }
|
||||
: u
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setUploads(prev =>
|
||||
prev.map((u, i) =>
|
||||
i === index
|
||||
? {
|
||||
...u,
|
||||
status: "error",
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Upload failed",
|
||||
}
|
||||
: u
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
[getUploadUrl, parentId, state.isConnected]
|
||||
)
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (uploads.length === 0) return
|
||||
setUploading(true)
|
||||
|
||||
for (let i = 0; i < uploads.length; i++) {
|
||||
if (uploads[i].status === "pending") {
|
||||
await uploadSingleFile(uploads[i], i)
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false)
|
||||
|
||||
const allDone = uploads.every(
|
||||
u => u.status === "done" || u.status === "error"
|
||||
)
|
||||
if (allDone) {
|
||||
const successCount = uploads.filter(
|
||||
u => u.status === "done"
|
||||
).length
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
`${successCount} file${successCount > 1 ? "s" : ""} uploaded`
|
||||
)
|
||||
// refresh file list
|
||||
if (state.isConnected === true) {
|
||||
await fetchFiles(parentId ?? undefined)
|
||||
}
|
||||
}
|
||||
setTimeout(() => onOpenChange(false), 500)
|
||||
}
|
||||
}, [
|
||||
uploads,
|
||||
uploadSingleFile,
|
||||
onOpenChange,
|
||||
state.isConnected,
|
||||
fetchFiles,
|
||||
parentId,
|
||||
])
|
||||
|
||||
const removeUpload = useCallback((index: number) => {
|
||||
setUploads(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@ -53,23 +231,117 @@ export function FileUploadDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<IconUpload size={18} />
|
||||
Uploading file
|
||||
Upload files
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">example-file.pdf</span>
|
||||
<span className="text-muted-foreground">
|
||||
{Math.min(100, Math.round(progress))}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={Math.min(100, progress)} className="h-2" />
|
||||
{uploading && progress < 100 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uploading to cloud storage...
|
||||
</p>
|
||||
{uploads.length === 0 && (
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 rounded-lg border-2 border-dashed p-8 cursor-pointer hover:border-primary/50"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<IconUpload
|
||||
size={32}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click to select files or drag and drop
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploads.map((item, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{item.status === "done" ? (
|
||||
<IconCheck
|
||||
size={14}
|
||||
className="shrink-0 text-green-500"
|
||||
/>
|
||||
) : item.status === "error" ? (
|
||||
<IconX
|
||||
size={14}
|
||||
className="shrink-0 text-destructive"
|
||||
/>
|
||||
) : (
|
||||
<IconFile
|
||||
size={14}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{item.file.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(item.file.size)}
|
||||
</span>
|
||||
{item.status === "pending" &&
|
||||
!uploading && (
|
||||
<button
|
||||
onClick={() => removeUpload(i)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(item.status === "uploading" ||
|
||||
item.status === "done") && (
|
||||
<Progress
|
||||
value={item.progress}
|
||||
className="h-1.5"
|
||||
/>
|
||||
)}
|
||||
{item.error && (
|
||||
<p className="text-xs text-destructive">
|
||||
{item.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
{uploads.length > 0 && !uploading && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Add more
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={uploading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={
|
||||
uploads.length === 0 ||
|
||||
uploading ||
|
||||
uploads.every(u => u.status === "done")
|
||||
}
|
||||
>
|
||||
{uploading ? "Uploading..." : "Upload"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
180
src/components/google/connect-dialog.tsx
Executable file
180
src/components/google/connect-dialog.tsx
Executable file
@ -0,0 +1,180 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import {
|
||||
IconBrandGoogleDrive,
|
||||
IconUpload,
|
||||
IconLoader2,
|
||||
IconCheck,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import { connectGoogleDrive } from "@/app/actions/google-drive"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function GoogleConnectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const [keyFile, setKeyFile] = useState<string | null>(null)
|
||||
const [keyFileName, setKeyFileName] = useState("")
|
||||
const [domain, setDomain] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFileSelect = (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setKeyFileName(file.name)
|
||||
const reader = new FileReader()
|
||||
reader.onload = ev => {
|
||||
const content = ev.target?.result
|
||||
if (typeof content === "string") {
|
||||
setKeyFile(content)
|
||||
// try to extract domain hint from client_email
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (parsed.client_email) {
|
||||
// not setting domain automatically - admin needs to enter workspace domain
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!keyFile || !domain.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
const result = await connectGoogleDrive(
|
||||
keyFile,
|
||||
domain.trim()
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Google Drive connected")
|
||||
setKeyFile(null)
|
||||
setKeyFileName("")
|
||||
setDomain("")
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<IconBrandGoogleDrive size={20} />
|
||||
Connect Google Drive
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload your Google service account JSON key and
|
||||
enter your workspace domain.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">
|
||||
Service Account Key (JSON)
|
||||
</Label>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed p-3 cursor-pointer hover:border-primary/50"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{keyFile ? (
|
||||
<>
|
||||
<IconCheck
|
||||
size={16}
|
||||
className="text-green-500 shrink-0"
|
||||
/>
|
||||
<span className="text-sm truncate">
|
||||
{keyFileName}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconUpload
|
||||
size={16}
|
||||
className="text-muted-foreground shrink-0"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Click to select JSON key file
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">
|
||||
Workspace Domain
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="e.g. openrangeconstruction.ltd"
|
||||
value={domain}
|
||||
onChange={e => setDomain(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The Google Workspace domain your organization
|
||||
uses.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={!keyFile || !domain.trim() || loading}
|
||||
>
|
||||
{loading && (
|
||||
<IconLoader2
|
||||
size={16}
|
||||
className="mr-2 animate-spin"
|
||||
/>
|
||||
)}
|
||||
Connect
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
155
src/components/google/connection-status.tsx
Executable file
155
src/components/google/connection-status.tsx
Executable file
@ -0,0 +1,155 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import {
|
||||
IconBrandGoogleDrive,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconLoader2,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import {
|
||||
getGoogleDriveConnectionStatus,
|
||||
disconnectGoogleDrive,
|
||||
} from "@/app/actions/google-drive"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { toast } from "sonner"
|
||||
import { GoogleConnectDialog } from "./connect-dialog"
|
||||
import { SharedDrivePicker } from "./shared-drive-picker"
|
||||
|
||||
export function GoogleDriveConnectionStatus() {
|
||||
const [status, setStatus] = useState<{
|
||||
connected: boolean
|
||||
workspaceDomain: string | null
|
||||
sharedDriveName: string | null
|
||||
} | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [connectOpen, setConnectOpen] = useState(false)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [disconnecting, setDisconnecting] = useState(false)
|
||||
|
||||
const fetchStatus = async () => {
|
||||
setLoading(true)
|
||||
const result = await getGoogleDriveConnectionStatus()
|
||||
setStatus(result)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [])
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
setDisconnecting(true)
|
||||
const result = await disconnectGoogleDrive()
|
||||
if (result.success) {
|
||||
toast.success("Google Drive disconnected")
|
||||
await fetchStatus()
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
setDisconnecting(false)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border p-4">
|
||||
<IconLoader2
|
||||
size={20}
|
||||
className="animate-spin text-muted-foreground"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Checking Google Drive connection...
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<IconBrandGoogleDrive size={20} />
|
||||
<span className="text-sm font-medium">
|
||||
Google Drive
|
||||
</span>
|
||||
{status?.connected ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 text-green-600 border-green-200"
|
||||
>
|
||||
<IconCheck size={12} />
|
||||
Connected
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 text-muted-foreground"
|
||||
>
|
||||
<IconX size={12} />
|
||||
Not connected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status?.connected && (
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>Domain: {status.workspaceDomain}</p>
|
||||
{status.sharedDriveName && (
|
||||
<p>Shared drive: {status.sharedDriveName}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{status?.connected ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
Change shared drive
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnecting}
|
||||
>
|
||||
{disconnecting
|
||||
? "Disconnecting..."
|
||||
: "Disconnect"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setConnectOpen(true)}
|
||||
>
|
||||
Connect Google Drive
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GoogleConnectDialog
|
||||
open={connectOpen}
|
||||
onOpenChange={open => {
|
||||
setConnectOpen(open)
|
||||
if (!open) fetchStatus()
|
||||
}}
|
||||
/>
|
||||
<SharedDrivePicker
|
||||
open={pickerOpen}
|
||||
onOpenChange={open => {
|
||||
setPickerOpen(open)
|
||||
if (!open) fetchStatus()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
161
src/components/google/shared-drive-picker.tsx
Executable file
161
src/components/google/shared-drive-picker.tsx
Executable file
@ -0,0 +1,161 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import {
|
||||
IconFolder,
|
||||
IconLoader2,
|
||||
IconFolderShare,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import {
|
||||
listAvailableSharedDrives,
|
||||
selectSharedDrive,
|
||||
} from "@/app/actions/google-drive"
|
||||
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"
|
||||
|
||||
export function SharedDrivePicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const [drives, setDrives] = useState<
|
||||
ReadonlyArray<{ id: string; name: string }>
|
||||
>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
setLoading(true)
|
||||
listAvailableSharedDrives().then(result => {
|
||||
if (result.success) {
|
||||
setDrives(result.drives)
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
}, [open])
|
||||
|
||||
const handleSelect = async () => {
|
||||
setSaving(true)
|
||||
const driveName =
|
||||
selectedId === null
|
||||
? null
|
||||
: drives.find(d => d.id === selectedId)?.name ??
|
||||
null
|
||||
|
||||
const result = await selectSharedDrive(
|
||||
selectedId,
|
||||
driveName
|
||||
)
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
selectedId
|
||||
? `Using shared drive "${driveName}"`
|
||||
: "Using root drive"
|
||||
)
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<IconFolderShare size={18} />
|
||||
Select Shared Drive
|
||||
</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">
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent",
|
||||
selectedId === null && "bg-accent"
|
||||
)}
|
||||
onClick={() => setSelectedId(null)}
|
||||
>
|
||||
<IconFolder
|
||||
size={16}
|
||||
className="text-amber-500"
|
||||
/>
|
||||
My Drive (root)
|
||||
</button>
|
||||
{drives.map(drive => (
|
||||
<button
|
||||
key={drive.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent",
|
||||
selectedId === drive.id && "bg-accent"
|
||||
)}
|
||||
onClick={() => setSelectedId(drive.id)}
|
||||
>
|
||||
<IconFolderShare
|
||||
size={16}
|
||||
className="text-blue-500"
|
||||
/>
|
||||
{drive.name}
|
||||
</button>
|
||||
))}
|
||||
{drives.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
No shared drives found.
|
||||
</p>
|
||||
)}
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSelect}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
{saving && (
|
||||
<IconLoader2
|
||||
size={16}
|
||||
className="mr-2 animate-spin"
|
||||
/>
|
||||
)}
|
||||
Select
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
77
src/components/google/user-email-mapping.tsx
Executable file
77
src/components/google/user-email-mapping.tsx
Executable file
@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { IconLoader2, IconMail } from "@tabler/icons-react"
|
||||
|
||||
import { updateUserGoogleEmail } from "@/app/actions/google-drive"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function UserEmailMapping({
|
||||
userId,
|
||||
currentEmail,
|
||||
googleEmail,
|
||||
}: {
|
||||
userId: string
|
||||
currentEmail: string
|
||||
googleEmail: string | null
|
||||
}) {
|
||||
const [email, setEmail] = useState(googleEmail ?? "")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true)
|
||||
const value = email.trim() || null
|
||||
const result = await updateUserGoogleEmail(userId, value)
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
value
|
||||
? `Google email set to ${value}`
|
||||
: "Google email override removed"
|
||||
)
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconMail size={16} className="text-muted-foreground" />
|
||||
<Label className="text-xs font-medium">
|
||||
Google Workspace Email Override
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default: {currentEmail}. Set a different email if
|
||||
your Google Workspace account uses a different
|
||||
address.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Leave empty to use default email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && (
|
||||
<IconLoader2
|
||||
size={14}
|
||||
className="mr-1 animate-spin"
|
||||
/>
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -11,7 +11,7 @@ import {
|
||||
import Link from "next/link"
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
|
||||
import { mockStorageUsage } from "@/lib/files-data"
|
||||
import { useFiles } from "@/hooks/use-files"
|
||||
import { StorageIndicator } from "@/components/files/storage-indicator"
|
||||
import {
|
||||
SidebarGroup,
|
||||
@ -22,11 +22,24 @@ import {
|
||||
} from "@/components/ui/sidebar"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type FileView = "my-files" | "shared" | "recent" | "starred" | "trash"
|
||||
type FileView =
|
||||
| "my-files"
|
||||
| "shared"
|
||||
| "recent"
|
||||
| "starred"
|
||||
| "trash"
|
||||
|
||||
const fileNavItems: { title: string; view: FileView; icon: typeof IconFiles }[] = [
|
||||
const fileNavItems: {
|
||||
title: string
|
||||
view: FileView
|
||||
icon: typeof IconFiles
|
||||
}[] = [
|
||||
{ title: "My Files", view: "my-files", icon: IconFiles },
|
||||
{ title: "Shared with me", view: "shared", icon: IconUsers },
|
||||
{
|
||||
title: "Shared with me",
|
||||
view: "shared",
|
||||
icon: IconUsers,
|
||||
},
|
||||
{ title: "Recent", view: "recent", icon: IconClock },
|
||||
{ title: "Starred", view: "starred", icon: IconStar },
|
||||
{ title: "Trash", view: "trash", icon: IconTrash },
|
||||
@ -36,6 +49,7 @@ export function NavFiles() {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const activeView = searchParams.get("view") ?? "my-files"
|
||||
const { storageUsage } = useFiles()
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -43,7 +57,10 @@ export function NavFiles() {
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="Back to Dashboard">
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip="Back to Dashboard"
|
||||
>
|
||||
<Link href="/dashboard">
|
||||
<IconArrowLeft />
|
||||
<span>Back</span>
|
||||
@ -56,14 +73,16 @@ export function NavFiles() {
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{fileNavItems.map((item) => (
|
||||
{fileNavItems.map(item => (
|
||||
<SidebarMenuItem key={item.view}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
className={cn(
|
||||
activeView === item.view &&
|
||||
pathname?.startsWith("/dashboard/files") &&
|
||||
pathname?.startsWith(
|
||||
"/dashboard/files"
|
||||
) &&
|
||||
"bg-sidebar-foreground/10 font-medium"
|
||||
)}
|
||||
>
|
||||
@ -84,7 +103,7 @@ export function NavFiles() {
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<div className="mt-auto px-3 pb-3">
|
||||
<StorageIndicator usage={mockStorageUsage} />
|
||||
<StorageIndicator usage={storageUsage} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status"
|
||||
import { SyncControls } from "@/components/netsuite/sync-controls"
|
||||
import { GoogleDriveConnectionStatus } from "@/components/google/connection-status"
|
||||
import { MemoriesTable } from "@/components/agent/memories-table"
|
||||
import { SkillsTab } from "@/components/settings/skills-tab"
|
||||
import { AIModelTab } from "@/components/settings/ai-model-tab"
|
||||
@ -150,6 +151,8 @@ export function SettingsModal({
|
||||
|
||||
const integrationsPage = (
|
||||
<>
|
||||
<GoogleDriveConnectionStatus />
|
||||
<Separator />
|
||||
<NetSuiteConnectionStatus />
|
||||
<SyncControls />
|
||||
</>
|
||||
@ -309,6 +312,8 @@ export function SettingsModal({
|
||||
value="integrations"
|
||||
className="space-y-3 pt-3"
|
||||
>
|
||||
<GoogleDriveConnectionStatus />
|
||||
<Separator />
|
||||
<NetSuiteConnectionStatus />
|
||||
<SyncControls />
|
||||
</TabsContent>
|
||||
|
||||
@ -4,6 +4,7 @@ import * as netsuiteSchema from "./schema-netsuite"
|
||||
import * as pluginSchema from "./schema-plugins"
|
||||
import * as agentSchema from "./schema-agent"
|
||||
import * as aiConfigSchema from "./schema-ai-config"
|
||||
import * as googleSchema from "./schema-google"
|
||||
|
||||
const allSchemas = {
|
||||
...schema,
|
||||
@ -11,6 +12,7 @@ const allSchemas = {
|
||||
...pluginSchema,
|
||||
...agentSchema,
|
||||
...aiConfigSchema,
|
||||
...googleSchema,
|
||||
}
|
||||
|
||||
export function getDb(d1: D1Database) {
|
||||
|
||||
34
src/db/schema-google.ts
Executable file
34
src/db/schema-google.ts
Executable file
@ -0,0 +1,34 @@
|
||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { users, organizations } from "./schema"
|
||||
|
||||
export const googleAuth = sqliteTable("google_auth", {
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organizations.id, { onDelete: "cascade" }),
|
||||
serviceAccountKeyEncrypted: text(
|
||||
"service_account_key_encrypted"
|
||||
).notNull(),
|
||||
workspaceDomain: text("workspace_domain").notNull(),
|
||||
sharedDriveId: text("shared_drive_id"),
|
||||
sharedDriveName: text("shared_drive_name"),
|
||||
connectedBy: text("connected_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
})
|
||||
|
||||
export const googleStarredFiles = sqliteTable("google_starred_files", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
googleFileId: text("google_file_id").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
})
|
||||
|
||||
export type GoogleAuth = typeof googleAuth.$inferSelect
|
||||
export type NewGoogleAuth = typeof googleAuth.$inferInsert
|
||||
export type GoogleStarredFile = typeof googleStarredFiles.$inferSelect
|
||||
export type NewGoogleStarredFile = typeof googleStarredFiles.$inferInsert
|
||||
@ -14,6 +14,7 @@ export const users = sqliteTable("users", {
|
||||
displayName: text("display_name"),
|
||||
avatarUrl: text("avatar_url"),
|
||||
role: text("role").notNull().default("office"), // admin, office, field, client
|
||||
googleEmail: text("google_email"), // override for google workspace impersonation
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
lastLoginAt: text("last_login_at"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
|
||||
@ -5,9 +5,31 @@ import {
|
||||
useContext,
|
||||
useReducer,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { mockFiles, mockStorageUsage, type FileItem } from "@/lib/files-data"
|
||||
import {
|
||||
mockFiles,
|
||||
mockStorageUsage,
|
||||
type FileItem,
|
||||
type StorageUsage,
|
||||
} from "@/lib/files-data"
|
||||
import {
|
||||
getGoogleDriveConnectionStatus,
|
||||
listDriveFiles,
|
||||
listDriveFilesForView,
|
||||
searchDriveFiles,
|
||||
createDriveFolder,
|
||||
renameDriveFile,
|
||||
moveDriveFile,
|
||||
trashDriveFile,
|
||||
restoreDriveFile,
|
||||
toggleStarFile,
|
||||
getDriveStorageQuota,
|
||||
getUploadSessionUrl,
|
||||
listDriveFolders,
|
||||
} from "@/app/actions/google-drive"
|
||||
|
||||
export type FileView =
|
||||
| "my-files"
|
||||
@ -28,6 +50,11 @@ type FilesState = {
|
||||
sortDirection: SortDirection
|
||||
searchQuery: string
|
||||
files: FileItem[]
|
||||
isConnected: boolean | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
storageQuota: StorageUsage
|
||||
nextPageToken: string | null
|
||||
}
|
||||
|
||||
type FilesAction =
|
||||
@ -36,22 +63,41 @@ type FilesAction =
|
||||
| { type: "SET_SELECTED"; payload: Set<string> }
|
||||
| { type: "TOGGLE_SELECTED"; payload: string }
|
||||
| { type: "CLEAR_SELECTION" }
|
||||
| { type: "SET_SORT"; payload: { field: SortField; direction: SortDirection } }
|
||||
| {
|
||||
type: "SET_SORT"
|
||||
payload: { field: SortField; direction: SortDirection }
|
||||
}
|
||||
| { type: "SET_SEARCH"; payload: string }
|
||||
| { type: "STAR_FILE"; payload: string }
|
||||
| { type: "TRASH_FILE"; payload: string }
|
||||
| { type: "RESTORE_FILE"; payload: string }
|
||||
| { type: "RENAME_FILE"; payload: { id: string; name: string } }
|
||||
| { type: "CREATE_FOLDER"; payload: { name: string; parentId: string | null; path: string[] } }
|
||||
| { type: "CREATE_FILE"; payload: { name: string; fileType: FileItem["type"]; parentId: string | null; path: string[] } }
|
||||
| { type: "MOVE_FILE"; payload: { id: string; targetFolderId: string | null; targetPath: string[] } }
|
||||
| { type: "SET_FILES"; payload: FileItem[] }
|
||||
| { type: "APPEND_FILES"; payload: FileItem[] }
|
||||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_ERROR"; payload: string | null }
|
||||
| { type: "SET_CONNECTED"; payload: boolean }
|
||||
| { type: "SET_STORAGE_QUOTA"; payload: StorageUsage }
|
||||
| { type: "SET_NEXT_PAGE_TOKEN"; payload: string | null }
|
||||
| { type: "OPTIMISTIC_STAR"; payload: string }
|
||||
| { type: "OPTIMISTIC_TRASH"; payload: string }
|
||||
| { type: "OPTIMISTIC_RESTORE"; payload: string }
|
||||
| {
|
||||
type: "OPTIMISTIC_RENAME"
|
||||
payload: { id: string; name: string }
|
||||
}
|
||||
| { type: "OPTIMISTIC_ADD_FOLDER"; payload: FileItem }
|
||||
| { type: "REMOVE_FILE"; payload: string }
|
||||
|
||||
function filesReducer(state: FilesState, action: FilesAction): FilesState {
|
||||
function filesReducer(
|
||||
state: FilesState,
|
||||
action: FilesAction
|
||||
): FilesState {
|
||||
switch (action.type) {
|
||||
case "SET_VIEW_MODE":
|
||||
return { ...state, viewMode: action.payload }
|
||||
case "SET_CURRENT_VIEW":
|
||||
return { ...state, currentView: action.payload, selectedIds: new Set() }
|
||||
return {
|
||||
...state,
|
||||
currentView: action.payload,
|
||||
selectedIds: new Set(),
|
||||
}
|
||||
case "SET_SELECTED":
|
||||
return { ...state, selectedIds: action.payload }
|
||||
case "TOGGLE_SELECTED": {
|
||||
@ -69,86 +115,77 @@ function filesReducer(state: FilesState, action: FilesAction): FilesState {
|
||||
sortDirection: action.payload.direction,
|
||||
}
|
||||
case "SET_SEARCH":
|
||||
return { ...state, searchQuery: action.payload, selectedIds: new Set() }
|
||||
case "STAR_FILE":
|
||||
return {
|
||||
...state,
|
||||
files: state.files.map((f) =>
|
||||
f.id === action.payload ? { ...f, starred: !f.starred } : f
|
||||
searchQuery: action.payload,
|
||||
selectedIds: new Set(),
|
||||
}
|
||||
case "SET_FILES":
|
||||
return { ...state, files: action.payload }
|
||||
case "APPEND_FILES":
|
||||
return {
|
||||
...state,
|
||||
files: [...state.files, ...action.payload],
|
||||
}
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.payload }
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.payload }
|
||||
case "SET_CONNECTED":
|
||||
return { ...state, isConnected: action.payload }
|
||||
case "SET_STORAGE_QUOTA":
|
||||
return { ...state, storageQuota: action.payload }
|
||||
case "SET_NEXT_PAGE_TOKEN":
|
||||
return { ...state, nextPageToken: action.payload }
|
||||
case "OPTIMISTIC_STAR":
|
||||
return {
|
||||
...state,
|
||||
files: state.files.map(f =>
|
||||
f.id === action.payload
|
||||
? { ...f, starred: !f.starred }
|
||||
: f
|
||||
),
|
||||
}
|
||||
case "TRASH_FILE":
|
||||
case "OPTIMISTIC_TRASH":
|
||||
return {
|
||||
...state,
|
||||
files: state.files.map((f) =>
|
||||
f.id === action.payload ? { ...f, trashed: true } : f
|
||||
files: state.files.filter(
|
||||
f => f.id !== action.payload
|
||||
),
|
||||
selectedIds: new Set(),
|
||||
}
|
||||
case "RESTORE_FILE":
|
||||
case "OPTIMISTIC_RESTORE":
|
||||
return {
|
||||
...state,
|
||||
files: state.files.map((f) =>
|
||||
f.id === action.payload ? { ...f, trashed: false } : f
|
||||
files: state.files.filter(
|
||||
f => f.id !== action.payload
|
||||
),
|
||||
}
|
||||
case "RENAME_FILE":
|
||||
case "OPTIMISTIC_RENAME":
|
||||
return {
|
||||
...state,
|
||||
files: state.files.map((f) =>
|
||||
f.id === action.payload.id
|
||||
? { ...f, name: action.payload.name, modifiedAt: new Date().toISOString() }
|
||||
: f
|
||||
),
|
||||
}
|
||||
case "CREATE_FOLDER": {
|
||||
const newFolder: FileItem = {
|
||||
id: `folder-${Date.now()}`,
|
||||
name: action.payload.name,
|
||||
type: "folder",
|
||||
size: 0,
|
||||
path: action.payload.path,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString(),
|
||||
owner: { name: "Martine Vogel" },
|
||||
starred: false,
|
||||
shared: false,
|
||||
trashed: false,
|
||||
parentId: action.payload.parentId,
|
||||
}
|
||||
return { ...state, files: [...state.files, newFolder] }
|
||||
}
|
||||
case "CREATE_FILE": {
|
||||
const newFile: FileItem = {
|
||||
id: `file-${Date.now()}`,
|
||||
name: action.payload.name,
|
||||
type: action.payload.fileType,
|
||||
size: 0,
|
||||
path: action.payload.path,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString(),
|
||||
owner: { name: "Martine Vogel" },
|
||||
starred: false,
|
||||
shared: false,
|
||||
trashed: false,
|
||||
parentId: action.payload.parentId,
|
||||
}
|
||||
return { ...state, files: [...state.files, newFile] }
|
||||
}
|
||||
case "MOVE_FILE":
|
||||
return {
|
||||
...state,
|
||||
files: state.files.map((f) =>
|
||||
files: state.files.map(f =>
|
||||
f.id === action.payload.id
|
||||
? {
|
||||
...f,
|
||||
parentId: action.payload.targetFolderId,
|
||||
path: action.payload.targetPath,
|
||||
name: action.payload.name,
|
||||
modifiedAt: new Date().toISOString(),
|
||||
}
|
||||
: f
|
||||
),
|
||||
}
|
||||
case "OPTIMISTIC_ADD_FOLDER":
|
||||
return {
|
||||
...state,
|
||||
files: [action.payload, ...state.files],
|
||||
}
|
||||
case "REMOVE_FILE":
|
||||
return {
|
||||
...state,
|
||||
files: state.files.filter(
|
||||
f => f.id !== action.payload
|
||||
),
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
@ -161,42 +198,440 @@ const initialState: FilesState = {
|
||||
sortBy: "name",
|
||||
sortDirection: "asc",
|
||||
searchQuery: "",
|
||||
files: mockFiles,
|
||||
files: [],
|
||||
isConnected: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
storageQuota: mockStorageUsage,
|
||||
nextPageToken: null,
|
||||
}
|
||||
|
||||
type FilesContextValue = {
|
||||
state: FilesState
|
||||
dispatch: React.Dispatch<FilesAction>
|
||||
// fetching
|
||||
fetchFiles: (
|
||||
folderId?: string,
|
||||
view?: FileView
|
||||
) => Promise<void>
|
||||
loadMore: () => Promise<void>
|
||||
// mutations
|
||||
createFolder: (
|
||||
name: string,
|
||||
parentId?: string
|
||||
) => Promise<boolean>
|
||||
renameFile: (
|
||||
fileId: string,
|
||||
newName: string
|
||||
) => Promise<boolean>
|
||||
moveFile: (
|
||||
fileId: string,
|
||||
newParentId: string,
|
||||
oldParentId: string
|
||||
) => Promise<boolean>
|
||||
trashFile: (fileId: string) => Promise<boolean>
|
||||
restoreFile: (fileId: string) => Promise<boolean>
|
||||
starFile: (fileId: string) => Promise<boolean>
|
||||
getUploadUrl: (
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
parentId?: string
|
||||
) => Promise<string | null>
|
||||
fetchFolders: (
|
||||
parentId?: string
|
||||
) => Promise<
|
||||
ReadonlyArray<{ id: string; name: string }> | null
|
||||
>
|
||||
// backward compat for mock data mode
|
||||
getFilesForPath: (path: string[]) => FileItem[]
|
||||
getFilesForView: (view: FileView, path: string[]) => FileItem[]
|
||||
storageUsage: typeof mockStorageUsage
|
||||
storageUsage: StorageUsage
|
||||
getFolders: () => FileItem[]
|
||||
}
|
||||
|
||||
const FilesContext = createContext<FilesContextValue | null>(null)
|
||||
|
||||
export function FilesProvider({ children }: { children: ReactNode }) {
|
||||
export function FilesProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(filesReducer, initialState)
|
||||
const currentFolderRef = useRef<string | undefined>(undefined)
|
||||
const currentViewRef = useRef<FileView>("my-files")
|
||||
|
||||
// check connection on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function check() {
|
||||
try {
|
||||
const status =
|
||||
await getGoogleDriveConnectionStatus()
|
||||
if (cancelled) return
|
||||
dispatch({
|
||||
type: "SET_CONNECTED",
|
||||
payload: status.connected,
|
||||
})
|
||||
|
||||
if (!status.connected) {
|
||||
// fall back to mock data
|
||||
dispatch({ type: "SET_FILES", payload: mockFiles })
|
||||
dispatch({ type: "SET_LOADING", payload: false })
|
||||
}
|
||||
} catch {
|
||||
if (cancelled) return
|
||||
dispatch({ type: "SET_CONNECTED", payload: false })
|
||||
dispatch({ type: "SET_FILES", payload: mockFiles })
|
||||
dispatch({ type: "SET_LOADING", payload: false })
|
||||
}
|
||||
}
|
||||
check()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// fetch storage quota when connected
|
||||
useEffect(() => {
|
||||
if (state.isConnected !== true) return
|
||||
let cancelled = false
|
||||
async function fetchQuota() {
|
||||
const result = await getDriveStorageQuota()
|
||||
if (cancelled) return
|
||||
if (result.success) {
|
||||
dispatch({
|
||||
type: "SET_STORAGE_QUOTA",
|
||||
payload: {
|
||||
used: result.used,
|
||||
total: result.total,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
fetchQuota()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [state.isConnected])
|
||||
|
||||
const fetchFiles = useCallback(
|
||||
async (folderId?: string, view?: FileView) => {
|
||||
if (state.isConnected !== true) return
|
||||
|
||||
currentFolderRef.current = folderId
|
||||
currentViewRef.current = view ?? "my-files"
|
||||
dispatch({ type: "SET_LOADING", payload: true })
|
||||
dispatch({ type: "SET_ERROR", payload: null })
|
||||
|
||||
try {
|
||||
let result
|
||||
|
||||
if (view && view !== "my-files") {
|
||||
result = await listDriveFilesForView(view)
|
||||
} else if (state.searchQuery) {
|
||||
const searchResult = await searchDriveFiles(
|
||||
state.searchQuery
|
||||
)
|
||||
if (searchResult.success) {
|
||||
dispatch({
|
||||
type: "SET_FILES",
|
||||
payload: searchResult.files,
|
||||
})
|
||||
dispatch({
|
||||
type: "SET_NEXT_PAGE_TOKEN",
|
||||
payload: null,
|
||||
})
|
||||
} else {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: searchResult.error,
|
||||
})
|
||||
}
|
||||
dispatch({ type: "SET_LOADING", payload: false })
|
||||
return
|
||||
} else {
|
||||
result = await listDriveFiles(folderId)
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
dispatch({
|
||||
type: "SET_FILES",
|
||||
payload: sortFiles(
|
||||
result.files,
|
||||
state.sortBy,
|
||||
state.sortDirection
|
||||
),
|
||||
})
|
||||
dispatch({
|
||||
type: "SET_NEXT_PAGE_TOKEN",
|
||||
payload: result.nextPageToken,
|
||||
})
|
||||
} else {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: result.error,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to load files",
|
||||
})
|
||||
} finally {
|
||||
dispatch({ type: "SET_LOADING", payload: false })
|
||||
}
|
||||
},
|
||||
[
|
||||
state.isConnected,
|
||||
state.searchQuery,
|
||||
state.sortBy,
|
||||
state.sortDirection,
|
||||
]
|
||||
)
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!state.nextPageToken || state.isConnected !== true)
|
||||
return
|
||||
|
||||
dispatch({ type: "SET_LOADING", payload: true })
|
||||
try {
|
||||
const view = currentViewRef.current
|
||||
let result
|
||||
|
||||
if (view !== "my-files") {
|
||||
result = await listDriveFilesForView(
|
||||
view,
|
||||
state.nextPageToken
|
||||
)
|
||||
} else {
|
||||
result = await listDriveFiles(
|
||||
currentFolderRef.current,
|
||||
state.nextPageToken
|
||||
)
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
dispatch({ type: "APPEND_FILES", payload: result.files })
|
||||
dispatch({
|
||||
type: "SET_NEXT_PAGE_TOKEN",
|
||||
payload: result.nextPageToken,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
dispatch({ type: "SET_LOADING", payload: false })
|
||||
}
|
||||
}, [state.nextPageToken, state.isConnected])
|
||||
|
||||
const createFolder = useCallback(
|
||||
async (
|
||||
name: string,
|
||||
parentId?: string
|
||||
): Promise<boolean> => {
|
||||
if (state.isConnected !== true) return false
|
||||
|
||||
const result = await createDriveFolder(name, parentId)
|
||||
if (result.success) {
|
||||
dispatch({
|
||||
type: "OPTIMISTIC_ADD_FOLDER",
|
||||
payload: result.folder,
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[state.isConnected]
|
||||
)
|
||||
|
||||
const renameFile = useCallback(
|
||||
async (
|
||||
fileId: string,
|
||||
newName: string
|
||||
): Promise<boolean> => {
|
||||
if (state.isConnected !== true) return false
|
||||
|
||||
dispatch({
|
||||
type: "OPTIMISTIC_RENAME",
|
||||
payload: { id: fileId, name: newName },
|
||||
})
|
||||
const result = await renameDriveFile(fileId, newName)
|
||||
if (!result.success) {
|
||||
// revert will happen on next fetch
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
[state.isConnected]
|
||||
)
|
||||
|
||||
const moveFile = useCallback(
|
||||
async (
|
||||
fileId: string,
|
||||
newParentId: string,
|
||||
oldParentId: string
|
||||
): Promise<boolean> => {
|
||||
if (state.isConnected !== true) return false
|
||||
|
||||
dispatch({ type: "REMOVE_FILE", payload: fileId })
|
||||
const result = await moveDriveFile(
|
||||
fileId,
|
||||
newParentId,
|
||||
oldParentId
|
||||
)
|
||||
if (!result.success) {
|
||||
// re-fetch to recover
|
||||
await fetchFiles(
|
||||
currentFolderRef.current,
|
||||
currentViewRef.current
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
[state.isConnected, fetchFiles]
|
||||
)
|
||||
|
||||
const trashFile = useCallback(
|
||||
async (fileId: string): Promise<boolean> => {
|
||||
if (state.isConnected !== true) return false
|
||||
|
||||
dispatch({ type: "OPTIMISTIC_TRASH", payload: fileId })
|
||||
const result = await trashDriveFile(fileId)
|
||||
if (!result.success) {
|
||||
await fetchFiles(
|
||||
currentFolderRef.current,
|
||||
currentViewRef.current
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
[state.isConnected, fetchFiles]
|
||||
)
|
||||
|
||||
const restoreFile = useCallback(
|
||||
async (fileId: string): Promise<boolean> => {
|
||||
if (state.isConnected !== true) return false
|
||||
|
||||
dispatch({
|
||||
type: "OPTIMISTIC_RESTORE",
|
||||
payload: fileId,
|
||||
})
|
||||
const result = await restoreDriveFile(fileId)
|
||||
if (!result.success) {
|
||||
await fetchFiles(
|
||||
currentFolderRef.current,
|
||||
currentViewRef.current
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
[state.isConnected, fetchFiles]
|
||||
)
|
||||
|
||||
const starFile = useCallback(
|
||||
async (fileId: string): Promise<boolean> => {
|
||||
if (state.isConnected !== true) {
|
||||
// mock mode: just toggle locally
|
||||
dispatch({ type: "OPTIMISTIC_STAR", payload: fileId })
|
||||
return true
|
||||
}
|
||||
|
||||
dispatch({ type: "OPTIMISTIC_STAR", payload: fileId })
|
||||
const result = await toggleStarFile(fileId)
|
||||
if (!result.success) {
|
||||
dispatch({ type: "OPTIMISTIC_STAR", payload: fileId })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
[state.isConnected]
|
||||
)
|
||||
|
||||
const getUploadUrl = useCallback(
|
||||
async (
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
parentId?: string
|
||||
): Promise<string | null> => {
|
||||
if (state.isConnected !== true) return null
|
||||
|
||||
const result = await getUploadSessionUrl(
|
||||
fileName,
|
||||
mimeType,
|
||||
parentId
|
||||
)
|
||||
if (result.success) return result.uploadUrl
|
||||
return null
|
||||
},
|
||||
[state.isConnected]
|
||||
)
|
||||
|
||||
const fetchFolders = useCallback(
|
||||
async (
|
||||
parentId?: string
|
||||
): Promise<
|
||||
ReadonlyArray<{ id: string; name: string }> | null
|
||||
> => {
|
||||
if (state.isConnected !== true) return null
|
||||
|
||||
const result = await listDriveFolders(parentId)
|
||||
if (result.success) return result.folders
|
||||
return null
|
||||
},
|
||||
[state.isConnected]
|
||||
)
|
||||
|
||||
// backward compat: mock data selectors
|
||||
const getFilesForPath = useCallback(
|
||||
(path: string[]) => {
|
||||
return state.files.filter((f) => {
|
||||
if (state.isConnected === true) return state.files
|
||||
|
||||
const allFiles =
|
||||
state.files.length > 0 ? state.files : mockFiles
|
||||
return allFiles.filter(f => {
|
||||
if (f.trashed) return false
|
||||
if (path.length === 0) return f.parentId === null
|
||||
const parentFolder = state.files.find(
|
||||
(folder) =>
|
||||
const parentFolder = allFiles.find(
|
||||
folder =>
|
||||
folder.type === "folder" &&
|
||||
folder.name === path[path.length - 1] &&
|
||||
JSON.stringify(folder.path) === JSON.stringify(path.slice(0, -1))
|
||||
JSON.stringify(folder.path) ===
|
||||
JSON.stringify(path.slice(0, -1))
|
||||
)
|
||||
return parentFolder && f.parentId === parentFolder.id
|
||||
})
|
||||
},
|
||||
[state.files]
|
||||
[state.files, state.isConnected]
|
||||
)
|
||||
|
||||
const getFilesForView = useCallback(
|
||||
(view: FileView, path: string[]) => {
|
||||
// when connected, files are already fetched for
|
||||
// the right view
|
||||
if (state.isConnected === true) {
|
||||
let files = state.files
|
||||
|
||||
if (state.searchQuery) {
|
||||
const q = state.searchQuery.toLowerCase()
|
||||
files = files.filter(f =>
|
||||
f.name.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
return sortFiles(
|
||||
files,
|
||||
state.sortBy,
|
||||
state.sortDirection
|
||||
)
|
||||
}
|
||||
|
||||
// mock data mode
|
||||
const allFiles =
|
||||
state.files.length > 0 ? state.files : mockFiles
|
||||
let files: FileItem[]
|
||||
|
||||
switch (view) {
|
||||
@ -204,21 +639,33 @@ export function FilesProvider({ children }: { children: ReactNode }) {
|
||||
files = getFilesForPath(path)
|
||||
break
|
||||
case "shared":
|
||||
files = state.files.filter((f) => !f.trashed && f.shared)
|
||||
files = allFiles.filter(
|
||||
f => !f.trashed && f.shared
|
||||
)
|
||||
break
|
||||
case "recent": {
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() - 30)
|
||||
files = state.files
|
||||
.filter((f) => !f.trashed && new Date(f.modifiedAt) > cutoff)
|
||||
.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime())
|
||||
files = allFiles
|
||||
.filter(
|
||||
f =>
|
||||
!f.trashed &&
|
||||
new Date(f.modifiedAt) > cutoff
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.modifiedAt).getTime() -
|
||||
new Date(a.modifiedAt).getTime()
|
||||
)
|
||||
break
|
||||
}
|
||||
case "starred":
|
||||
files = state.files.filter((f) => !f.trashed && f.starred)
|
||||
files = allFiles.filter(
|
||||
f => !f.trashed && f.starred
|
||||
)
|
||||
break
|
||||
case "trash":
|
||||
files = state.files.filter((f) => f.trashed)
|
||||
files = allFiles.filter(f => f.trashed)
|
||||
break
|
||||
default:
|
||||
files = []
|
||||
@ -226,16 +673,29 @@ export function FilesProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
if (state.searchQuery) {
|
||||
const q = state.searchQuery.toLowerCase()
|
||||
files = files.filter((f) => f.name.toLowerCase().includes(q))
|
||||
files = files.filter(f =>
|
||||
f.name.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
return sortFiles(files, state.sortBy, state.sortDirection)
|
||||
},
|
||||
[state.files, state.searchQuery, state.sortBy, state.sortDirection, getFilesForPath]
|
||||
[
|
||||
state.files,
|
||||
state.searchQuery,
|
||||
state.sortBy,
|
||||
state.sortDirection,
|
||||
state.isConnected,
|
||||
getFilesForPath,
|
||||
]
|
||||
)
|
||||
|
||||
const getFolders = useCallback(() => {
|
||||
return state.files.filter((f) => f.type === "folder" && !f.trashed)
|
||||
const allFiles =
|
||||
state.files.length > 0 ? state.files : mockFiles
|
||||
return allFiles.filter(
|
||||
f => f.type === "folder" && !f.trashed
|
||||
)
|
||||
}, [state.files])
|
||||
|
||||
return (
|
||||
@ -243,9 +703,19 @@ export function FilesProvider({ children }: { children: ReactNode }) {
|
||||
value={{
|
||||
state,
|
||||
dispatch,
|
||||
fetchFiles,
|
||||
loadMore,
|
||||
createFolder,
|
||||
renameFile,
|
||||
moveFile,
|
||||
trashFile,
|
||||
restoreFile,
|
||||
starFile,
|
||||
getUploadUrl,
|
||||
fetchFolders,
|
||||
getFilesForPath,
|
||||
getFilesForView,
|
||||
storageUsage: mockStorageUsage,
|
||||
storageUsage: state.storageQuota,
|
||||
getFolders,
|
||||
}}
|
||||
>
|
||||
@ -256,7 +726,10 @@ export function FilesProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
export function useFiles() {
|
||||
const ctx = useContext(FilesContext)
|
||||
if (!ctx) throw new Error("useFiles must be used within FilesProvider")
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"useFiles must be used within FilesProvider"
|
||||
)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@ -266,7 +739,6 @@ function sortFiles(
|
||||
direction: SortDirection
|
||||
): FileItem[] {
|
||||
const sorted = [...files].sort((a, b) => {
|
||||
// folders always first
|
||||
if (a.type === "folder" && b.type !== "folder") return -1
|
||||
if (a.type !== "folder" && b.type === "folder") return 1
|
||||
|
||||
@ -276,7 +748,9 @@ function sortFiles(
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
break
|
||||
case "modified":
|
||||
cmp = new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime()
|
||||
cmp =
|
||||
new Date(a.modifiedAt).getTime() -
|
||||
new Date(b.modifiedAt).getTime()
|
||||
break
|
||||
case "size":
|
||||
cmp = a.size - b.size
|
||||
|
||||
@ -13,6 +13,7 @@ export type AuthUser = {
|
||||
readonly displayName: string | null
|
||||
readonly avatarUrl: string | null
|
||||
readonly role: string
|
||||
readonly googleEmail: string | null
|
||||
readonly isActive: boolean
|
||||
readonly lastLoginAt: string | null
|
||||
readonly createdAt: string
|
||||
@ -63,6 +64,7 @@ export async function getCurrentUser(): Promise<AuthUser | null> {
|
||||
displayName: "Dev User",
|
||||
avatarUrl: null,
|
||||
role: "admin",
|
||||
googleEmail: null,
|
||||
isActive: true,
|
||||
lastLoginAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
@ -108,6 +110,7 @@ export async function getCurrentUser(): Promise<AuthUser | null> {
|
||||
displayName: dbUser.displayName,
|
||||
avatarUrl: dbUser.avatarUrl,
|
||||
role: dbUser.role,
|
||||
googleEmail: dbUser.googleEmail ?? null,
|
||||
isActive: dbUser.isActive,
|
||||
lastLoginAt: now,
|
||||
createdAt: dbUser.createdAt,
|
||||
|
||||
76
src/lib/crypto.ts
Executable file
76
src/lib/crypto.ts
Executable file
@ -0,0 +1,76 @@
|
||||
// shared AES-256-GCM encryption for secrets at rest in D1.
|
||||
// uses Web Crypto API (available in Cloudflare Workers).
|
||||
|
||||
const ALGORITHM = "AES-GCM"
|
||||
const KEY_LENGTH = 256
|
||||
const IV_LENGTH = 12
|
||||
const TAG_LENGTH = 128
|
||||
|
||||
async function deriveKey(
|
||||
secret: string,
|
||||
salt: string
|
||||
): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder()
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "PBKDF2" },
|
||||
false,
|
||||
["deriveKey"]
|
||||
)
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: encoder.encode(salt),
|
||||
iterations: 100000,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: ALGORITHM, length: KEY_LENGTH },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
}
|
||||
|
||||
export async function encrypt(
|
||||
plaintext: string,
|
||||
secret: string,
|
||||
salt: string
|
||||
): Promise<string> {
|
||||
const key = await deriveKey(secret, salt)
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
|
||||
key,
|
||||
encoder.encode(plaintext)
|
||||
)
|
||||
|
||||
const packed = new Uint8Array(iv.length + ciphertext.byteLength)
|
||||
packed.set(iv)
|
||||
packed.set(new Uint8Array(ciphertext), iv.length)
|
||||
|
||||
return btoa(String.fromCharCode(...packed))
|
||||
}
|
||||
|
||||
export async function decrypt(
|
||||
encoded: string,
|
||||
secret: string,
|
||||
salt: string
|
||||
): Promise<string> {
|
||||
const key = await deriveKey(secret, salt)
|
||||
|
||||
const packed = Uint8Array.from(atob(encoded), c => c.charCodeAt(0))
|
||||
const iv = packed.slice(0, IV_LENGTH)
|
||||
const ciphertext = packed.slice(IV_LENGTH)
|
||||
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
|
||||
key,
|
||||
ciphertext
|
||||
)
|
||||
|
||||
return new TextDecoder().decode(plaintext)
|
||||
}
|
||||
@ -38,6 +38,7 @@ export type FileItem = {
|
||||
sharedWith?: SharedUser[]
|
||||
trashed: boolean
|
||||
parentId: string | null
|
||||
webViewLink?: string
|
||||
}
|
||||
|
||||
export type StorageUsage = {
|
||||
|
||||
126
src/lib/google/auth/service-account.ts
Executable file
126
src/lib/google/auth/service-account.ts
Executable file
@ -0,0 +1,126 @@
|
||||
// JWT-based auth for google service accounts with
|
||||
// domain-wide delegation (impersonating workspace users).
|
||||
// uses Web Crypto API for RS256 signing (cloudflare workers compatible).
|
||||
|
||||
import {
|
||||
GOOGLE_DRIVE_SCOPES,
|
||||
GOOGLE_TOKEN_URL,
|
||||
type ServiceAccountKey,
|
||||
} from "../config"
|
||||
|
||||
function base64url(data: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...data))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "")
|
||||
}
|
||||
|
||||
function base64urlEncode(str: string): string {
|
||||
return base64url(new TextEncoder().encode(str))
|
||||
}
|
||||
|
||||
// convert PEM private key to CryptoKey for RS256 signing
|
||||
async function importPrivateKey(
|
||||
pem: string
|
||||
): Promise<CryptoKey> {
|
||||
const pemBody = pem
|
||||
.replace(/-----BEGIN PRIVATE KEY-----/g, "")
|
||||
.replace(/-----END PRIVATE KEY-----/g, "")
|
||||
.replace(/\s/g, "")
|
||||
|
||||
const binaryString = atob(pemBody)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
|
||||
return crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
bytes.buffer,
|
||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
)
|
||||
}
|
||||
|
||||
export async function createServiceAccountJWT(
|
||||
serviceAccountKey: ServiceAccountKey,
|
||||
userEmail: string,
|
||||
scopes: ReadonlyArray<string> = GOOGLE_DRIVE_SCOPES
|
||||
): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
const header = {
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
kid: serviceAccountKey.private_key_id,
|
||||
}
|
||||
|
||||
const payload = {
|
||||
iss: serviceAccountKey.client_email,
|
||||
sub: userEmail,
|
||||
scope: scopes.join(" "),
|
||||
aud: GOOGLE_TOKEN_URL,
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
}
|
||||
|
||||
const headerB64 = base64urlEncode(JSON.stringify(header))
|
||||
const payloadB64 = base64urlEncode(JSON.stringify(payload))
|
||||
const signingInput = `${headerB64}.${payloadB64}`
|
||||
|
||||
const key = await importPrivateKey(
|
||||
serviceAccountKey.private_key
|
||||
)
|
||||
const signature = await crypto.subtle.sign(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
key,
|
||||
new TextEncoder().encode(signingInput)
|
||||
)
|
||||
|
||||
const signatureB64 = base64url(new Uint8Array(signature))
|
||||
|
||||
return `${signingInput}.${signatureB64}`
|
||||
}
|
||||
|
||||
export type AccessTokenResponse = {
|
||||
readonly access_token: string
|
||||
readonly token_type: string
|
||||
readonly expires_in: number
|
||||
}
|
||||
|
||||
export async function exchangeJWTForAccessToken(
|
||||
jwt: string
|
||||
): Promise<AccessTokenResponse> {
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion: jwt,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
throw new Error(
|
||||
`Token exchange failed (${response.status}): ${body}`
|
||||
)
|
||||
}
|
||||
|
||||
return response.json() as Promise<AccessTokenResponse>
|
||||
}
|
||||
|
||||
export async function getAccessToken(
|
||||
serviceAccountKey: ServiceAccountKey,
|
||||
userEmail: string
|
||||
): Promise<string> {
|
||||
const jwt = await createServiceAccountJWT(
|
||||
serviceAccountKey,
|
||||
userEmail
|
||||
)
|
||||
const tokenResponse = await exchangeJWTForAccessToken(jwt)
|
||||
return tokenResponse.access_token
|
||||
}
|
||||
42
src/lib/google/auth/token-cache.ts
Executable file
42
src/lib/google/auth/token-cache.ts
Executable file
@ -0,0 +1,42 @@
|
||||
// in-memory token cache keyed by user email.
|
||||
// NOTE: in cloudflare workers, this resets per request since
|
||||
// each request runs in its own isolate. for now we just
|
||||
// generate tokens per-request (they're fast ~100ms).
|
||||
// if perf becomes an issue, swap to KV-backed cache.
|
||||
|
||||
type CachedToken = {
|
||||
readonly accessToken: string
|
||||
readonly expiresAt: number
|
||||
}
|
||||
|
||||
const TOKEN_BUFFER_MS = 5 * 60 * 1000 // refresh 5 min early
|
||||
|
||||
const cache = new Map<string, CachedToken>()
|
||||
|
||||
export function getCachedToken(
|
||||
userEmail: string
|
||||
): string | null {
|
||||
const entry = cache.get(userEmail)
|
||||
if (!entry) return null
|
||||
if (Date.now() >= entry.expiresAt) {
|
||||
cache.delete(userEmail)
|
||||
return null
|
||||
}
|
||||
return entry.accessToken
|
||||
}
|
||||
|
||||
export function setCachedToken(
|
||||
userEmail: string,
|
||||
accessToken: string,
|
||||
expiresInSeconds: number
|
||||
): void {
|
||||
cache.set(userEmail, {
|
||||
accessToken,
|
||||
expiresAt:
|
||||
Date.now() + expiresInSeconds * 1000 - TOKEN_BUFFER_MS,
|
||||
})
|
||||
}
|
||||
|
||||
export function clearCachedToken(userEmail: string): void {
|
||||
cache.delete(userEmail)
|
||||
}
|
||||
427
src/lib/google/client/drive-client.ts
Executable file
427
src/lib/google/client/drive-client.ts
Executable file
@ -0,0 +1,427 @@
|
||||
// google drive REST API v3 wrapper.
|
||||
// each method accepts userEmail for domain-wide delegation impersonation.
|
||||
|
||||
import {
|
||||
GOOGLE_DRIVE_API,
|
||||
GOOGLE_UPLOAD_API,
|
||||
type ServiceAccountKey,
|
||||
} from "../config"
|
||||
import {
|
||||
getAccessToken,
|
||||
} from "../auth/service-account"
|
||||
import {
|
||||
getCachedToken,
|
||||
setCachedToken,
|
||||
clearCachedToken,
|
||||
} from "../auth/token-cache"
|
||||
import { ConcurrencyLimiter } from "@/lib/netsuite/rate-limiter/concurrency-limiter"
|
||||
import {
|
||||
DRIVE_FILE_FIELDS,
|
||||
DRIVE_LIST_FIELDS,
|
||||
type DriveFile,
|
||||
type DriveFileList,
|
||||
type DriveAbout,
|
||||
type DriveSharedDriveList,
|
||||
type ListFilesOptions,
|
||||
type UploadOptions,
|
||||
} from "./types"
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const INITIAL_BACKOFF_MS = 1000
|
||||
|
||||
type DriveClientConfig = {
|
||||
readonly serviceAccountKey: ServiceAccountKey
|
||||
readonly limiter?: ConcurrencyLimiter
|
||||
}
|
||||
|
||||
export class DriveClient {
|
||||
private serviceAccountKey: ServiceAccountKey
|
||||
private limiter: ConcurrencyLimiter
|
||||
|
||||
constructor(config: DriveClientConfig) {
|
||||
this.serviceAccountKey = config.serviceAccountKey
|
||||
this.limiter = config.limiter ?? new ConcurrencyLimiter(10)
|
||||
}
|
||||
|
||||
private async getToken(userEmail: string): Promise<string> {
|
||||
const cached = getCachedToken(userEmail)
|
||||
if (cached) return cached
|
||||
|
||||
const token = await getAccessToken(
|
||||
this.serviceAccountKey,
|
||||
userEmail
|
||||
)
|
||||
setCachedToken(userEmail, token, 3600)
|
||||
return token
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
userEmail: string,
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
isUpload = false
|
||||
): Promise<T> {
|
||||
return this.limiter.execute(async () => {
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
const token = await this.getToken(userEmail)
|
||||
const baseUrl = isUpload
|
||||
? GOOGLE_UPLOAD_API
|
||||
: GOOGLE_DRIVE_API
|
||||
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
// refresh token on 401
|
||||
if (response.status === 401) {
|
||||
clearCachedToken(userEmail)
|
||||
continue
|
||||
}
|
||||
|
||||
// retry on rate limit or server error
|
||||
if (
|
||||
response.status === 429 ||
|
||||
response.status >= 500
|
||||
) {
|
||||
if (response.status === 429) {
|
||||
this.limiter.reduceConcurrency()
|
||||
}
|
||||
const backoff =
|
||||
INITIAL_BACKOFF_MS * Math.pow(2, attempt)
|
||||
await new Promise(r => setTimeout(r, backoff))
|
||||
lastError = new Error(
|
||||
`Google API ${response.status}: ${await response.text()}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// non-retryable error
|
||||
const body = await response.text()
|
||||
throw new Error(
|
||||
`Google Drive API error (${response.status}): ${body}`
|
||||
)
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("Max retries exceeded")
|
||||
})
|
||||
}
|
||||
|
||||
async listFiles(
|
||||
userEmail: string,
|
||||
options: ListFilesOptions = {}
|
||||
): Promise<DriveFileList> {
|
||||
const params = new URLSearchParams({
|
||||
fields: DRIVE_LIST_FIELDS,
|
||||
pageSize: String(options.pageSize ?? 100),
|
||||
})
|
||||
|
||||
const queryParts: string[] = []
|
||||
|
||||
if (options.folderId) {
|
||||
queryParts.push(`'${options.folderId}' in parents`)
|
||||
}
|
||||
|
||||
if (options.trashed !== undefined) {
|
||||
queryParts.push(`trashed = ${options.trashed}`)
|
||||
} else {
|
||||
queryParts.push("trashed = false")
|
||||
}
|
||||
|
||||
if (options.sharedWithMe) {
|
||||
queryParts.push("sharedWithMe = true")
|
||||
}
|
||||
|
||||
if (options.query) {
|
||||
queryParts.push(options.query)
|
||||
}
|
||||
|
||||
if (queryParts.length > 0) {
|
||||
params.set("q", queryParts.join(" and "))
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
params.set("orderBy", options.orderBy)
|
||||
}
|
||||
|
||||
if (options.pageToken) {
|
||||
params.set("pageToken", options.pageToken)
|
||||
}
|
||||
|
||||
if (options.driveId) {
|
||||
params.set("driveId", options.driveId)
|
||||
params.set("corpora", "drive")
|
||||
params.set("includeItemsFromAllDrives", "true")
|
||||
params.set("supportsAllDrives", "true")
|
||||
}
|
||||
|
||||
return this.request<DriveFileList>(
|
||||
userEmail,
|
||||
`/files?${params.toString()}`
|
||||
)
|
||||
}
|
||||
|
||||
async getFile(
|
||||
userEmail: string,
|
||||
fileId: string
|
||||
): Promise<DriveFile> {
|
||||
const params = new URLSearchParams({
|
||||
fields: DRIVE_FILE_FIELDS,
|
||||
supportsAllDrives: "true",
|
||||
})
|
||||
return this.request<DriveFile>(
|
||||
userEmail,
|
||||
`/files/${fileId}?${params.toString()}`
|
||||
)
|
||||
}
|
||||
|
||||
async createFolder(
|
||||
userEmail: string,
|
||||
options: {
|
||||
readonly name: string
|
||||
readonly parentId?: string
|
||||
readonly driveId?: string
|
||||
}
|
||||
): Promise<DriveFile> {
|
||||
const metadata: Record<string, unknown> = {
|
||||
name: options.name,
|
||||
mimeType: "application/vnd.google-apps.folder",
|
||||
}
|
||||
|
||||
if (options.parentId) {
|
||||
metadata.parents = [options.parentId]
|
||||
} else if (options.driveId) {
|
||||
metadata.parents = [options.driveId]
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
fields: DRIVE_FILE_FIELDS,
|
||||
supportsAllDrives: "true",
|
||||
})
|
||||
|
||||
return this.request<DriveFile>(
|
||||
userEmail,
|
||||
`/files?${params.toString()}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(metadata),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async initiateResumableUpload(
|
||||
userEmail: string,
|
||||
options: UploadOptions
|
||||
): Promise<string> {
|
||||
// returns the resumable upload session URI
|
||||
return this.limiter.execute(async () => {
|
||||
const token = await this.getToken(userEmail)
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
name: options.name,
|
||||
mimeType: options.mimeType,
|
||||
}
|
||||
if (options.parentId) {
|
||||
metadata.parents = [options.parentId]
|
||||
} else if (options.driveId) {
|
||||
metadata.parents = [options.driveId]
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
uploadType: "resumable",
|
||||
supportsAllDrives: "true",
|
||||
fields: DRIVE_FILE_FIELDS,
|
||||
})
|
||||
|
||||
const response = await fetch(
|
||||
`${GOOGLE_UPLOAD_API}/files?${params.toString()}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Upload-Content-Type": options.mimeType,
|
||||
},
|
||||
body: JSON.stringify(metadata),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
throw new Error(
|
||||
`Failed to initiate upload (${response.status}): ${body}`
|
||||
)
|
||||
}
|
||||
|
||||
const location = response.headers.get("Location")
|
||||
if (!location) {
|
||||
throw new Error("No upload URI in response")
|
||||
}
|
||||
|
||||
return location
|
||||
})
|
||||
}
|
||||
|
||||
async downloadFile(
|
||||
userEmail: string,
|
||||
fileId: string
|
||||
): Promise<Response> {
|
||||
const token = await this.getToken(userEmail)
|
||||
const params = new URLSearchParams({
|
||||
alt: "media",
|
||||
supportsAllDrives: "true",
|
||||
})
|
||||
|
||||
return fetch(
|
||||
`${GOOGLE_DRIVE_API}/files/${fileId}?${params.toString()}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async exportFile(
|
||||
userEmail: string,
|
||||
fileId: string,
|
||||
exportMimeType: string
|
||||
): Promise<Response> {
|
||||
const token = await this.getToken(userEmail)
|
||||
const params = new URLSearchParams({
|
||||
mimeType: exportMimeType,
|
||||
})
|
||||
|
||||
return fetch(
|
||||
`${GOOGLE_DRIVE_API}/files/${fileId}/export?${params.toString()}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async renameFile(
|
||||
userEmail: string,
|
||||
fileId: string,
|
||||
newName: string
|
||||
): Promise<DriveFile> {
|
||||
const params = new URLSearchParams({
|
||||
fields: DRIVE_FILE_FIELDS,
|
||||
supportsAllDrives: "true",
|
||||
})
|
||||
|
||||
return this.request<DriveFile>(
|
||||
userEmail,
|
||||
`/files/${fileId}?${params.toString()}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName }),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async moveFile(
|
||||
userEmail: string,
|
||||
fileId: string,
|
||||
newParentId: string,
|
||||
oldParentId: string
|
||||
): Promise<DriveFile> {
|
||||
const params = new URLSearchParams({
|
||||
addParents: newParentId,
|
||||
removeParents: oldParentId,
|
||||
fields: DRIVE_FILE_FIELDS,
|
||||
supportsAllDrives: "true",
|
||||
})
|
||||
|
||||
return this.request<DriveFile>(
|
||||
userEmail,
|
||||
`/files/${fileId}?${params.toString()}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async trashFile(
|
||||
userEmail: string,
|
||||
fileId: string
|
||||
): Promise<DriveFile> {
|
||||
const params = new URLSearchParams({
|
||||
fields: DRIVE_FILE_FIELDS,
|
||||
supportsAllDrives: "true",
|
||||
})
|
||||
|
||||
return this.request<DriveFile>(
|
||||
userEmail,
|
||||
`/files/${fileId}?${params.toString()}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trashed: true }),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async restoreFile(
|
||||
userEmail: string,
|
||||
fileId: string
|
||||
): Promise<DriveFile> {
|
||||
const params = new URLSearchParams({
|
||||
fields: DRIVE_FILE_FIELDS,
|
||||
supportsAllDrives: "true",
|
||||
})
|
||||
|
||||
return this.request<DriveFile>(
|
||||
userEmail,
|
||||
`/files/${fileId}?${params.toString()}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trashed: false }),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async getStorageQuota(
|
||||
userEmail: string
|
||||
): Promise<DriveAbout> {
|
||||
return this.request<DriveAbout>(
|
||||
userEmail,
|
||||
"/about?fields=storageQuota,user"
|
||||
)
|
||||
}
|
||||
|
||||
async searchFiles(
|
||||
userEmail: string,
|
||||
searchQuery: string,
|
||||
pageSize = 50,
|
||||
driveId?: string
|
||||
): Promise<DriveFileList> {
|
||||
return this.listFiles(userEmail, {
|
||||
query: `fullText contains '${searchQuery.replace(/'/g, "\\'")}'`,
|
||||
pageSize,
|
||||
driveId,
|
||||
})
|
||||
}
|
||||
|
||||
async listSharedDrives(
|
||||
userEmail: string
|
||||
): Promise<DriveSharedDriveList> {
|
||||
return this.request<DriveSharedDriveList>(
|
||||
userEmail,
|
||||
"/drives?pageSize=100"
|
||||
)
|
||||
}
|
||||
}
|
||||
89
src/lib/google/client/types.ts
Executable file
89
src/lib/google/client/types.ts
Executable file
@ -0,0 +1,89 @@
|
||||
// google drive API v3 response types
|
||||
|
||||
export type DriveUser = {
|
||||
readonly displayName: string
|
||||
readonly photoLink?: string
|
||||
readonly emailAddress?: string
|
||||
}
|
||||
|
||||
export type DrivePermission = {
|
||||
readonly id: string
|
||||
readonly type: string
|
||||
readonly role: string
|
||||
readonly emailAddress?: string
|
||||
readonly displayName?: string
|
||||
readonly photoLink?: string
|
||||
}
|
||||
|
||||
export type DriveFile = {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly mimeType: string
|
||||
readonly size?: string
|
||||
readonly createdTime?: string
|
||||
readonly modifiedTime?: string
|
||||
readonly owners?: ReadonlyArray<DriveUser>
|
||||
readonly parents?: ReadonlyArray<string>
|
||||
readonly permissions?: ReadonlyArray<DrivePermission>
|
||||
readonly shared?: boolean
|
||||
readonly trashed?: boolean
|
||||
readonly webViewLink?: string
|
||||
readonly webContentLink?: string
|
||||
readonly iconLink?: string
|
||||
readonly thumbnailLink?: string
|
||||
readonly driveId?: string
|
||||
}
|
||||
|
||||
export type DriveFileList = {
|
||||
readonly files: ReadonlyArray<DriveFile>
|
||||
readonly nextPageToken?: string
|
||||
readonly incompleteSearch?: boolean
|
||||
}
|
||||
|
||||
export type DriveAbout = {
|
||||
readonly storageQuota: {
|
||||
readonly limit?: string
|
||||
readonly usage: string
|
||||
readonly usageInDrive: string
|
||||
readonly usageInDriveTrash: string
|
||||
}
|
||||
readonly user: DriveUser
|
||||
}
|
||||
|
||||
export type DriveSharedDrive = {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly createdTime?: string
|
||||
}
|
||||
|
||||
export type DriveSharedDriveList = {
|
||||
readonly drives: ReadonlyArray<DriveSharedDrive>
|
||||
readonly nextPageToken?: string
|
||||
}
|
||||
|
||||
export type ListFilesOptions = {
|
||||
readonly folderId?: string
|
||||
readonly query?: string
|
||||
readonly pageSize?: number
|
||||
readonly pageToken?: string
|
||||
readonly orderBy?: string
|
||||
readonly driveId?: string
|
||||
readonly trashed?: boolean
|
||||
readonly sharedWithMe?: boolean
|
||||
}
|
||||
|
||||
export type UploadOptions = {
|
||||
readonly name: string
|
||||
readonly parentId?: string
|
||||
readonly mimeType: string
|
||||
readonly driveId?: string
|
||||
}
|
||||
|
||||
// fields we always request from the API
|
||||
export const DRIVE_FILE_FIELDS =
|
||||
"id,name,mimeType,size,createdTime,modifiedTime,owners," +
|
||||
"parents,permissions,shared,trashed,webViewLink," +
|
||||
"webContentLink,iconLink,thumbnailLink,driveId"
|
||||
|
||||
export const DRIVE_LIST_FIELDS =
|
||||
`nextPageToken,files(${DRIVE_FILE_FIELDS})`
|
||||
65
src/lib/google/config.ts
Executable file
65
src/lib/google/config.ts
Executable file
@ -0,0 +1,65 @@
|
||||
export type GoogleConfig = {
|
||||
readonly encryptionKey: string
|
||||
}
|
||||
|
||||
export function getGoogleConfig(
|
||||
env: Record<string, string | undefined>
|
||||
): GoogleConfig {
|
||||
const encryptionKey = env.GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY
|
||||
if (!encryptionKey) {
|
||||
throw new Error(
|
||||
"GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY not configured"
|
||||
)
|
||||
}
|
||||
|
||||
return { encryptionKey }
|
||||
}
|
||||
|
||||
export type ServiceAccountKey = {
|
||||
readonly type: string
|
||||
readonly project_id: string
|
||||
readonly private_key_id: string
|
||||
readonly private_key: string
|
||||
readonly client_email: string
|
||||
readonly client_id: string
|
||||
readonly auth_uri: string
|
||||
readonly token_uri: string
|
||||
readonly auth_provider_x509_cert_url: string
|
||||
readonly client_x509_cert_url: string
|
||||
readonly universe_domain: string
|
||||
}
|
||||
|
||||
export function parseServiceAccountKey(
|
||||
json: string
|
||||
): ServiceAccountKey {
|
||||
const parsed: unknown = JSON.parse(json)
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
!("type" in parsed) ||
|
||||
!("private_key" in parsed) ||
|
||||
!("client_email" in parsed)
|
||||
) {
|
||||
throw new Error("Invalid service account key JSON")
|
||||
}
|
||||
return parsed as ServiceAccountKey
|
||||
}
|
||||
|
||||
export const GOOGLE_DRIVE_SCOPES = [
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
] as const
|
||||
|
||||
export const GOOGLE_TOKEN_URL =
|
||||
"https://oauth2.googleapis.com/token"
|
||||
|
||||
export const GOOGLE_DRIVE_API =
|
||||
"https://www.googleapis.com/drive/v3"
|
||||
|
||||
export const GOOGLE_UPLOAD_API =
|
||||
"https://www.googleapis.com/upload/drive/v3"
|
||||
|
||||
const GOOGLE_CRYPTO_SALT = "compass-google-service-account"
|
||||
|
||||
export function getGoogleCryptoSalt(): string {
|
||||
return GOOGLE_CRYPTO_SALT
|
||||
}
|
||||
135
src/lib/google/mapper.ts
Executable file
135
src/lib/google/mapper.ts
Executable file
@ -0,0 +1,135 @@
|
||||
// maps google drive API responses to our FileItem type
|
||||
|
||||
import type { FileItem, FileType, SharedUser, SharedRole } from "@/lib/files-data"
|
||||
import type { DriveFile } from "./client/types"
|
||||
|
||||
const GOOGLE_APPS_FOLDER = "application/vnd.google-apps.folder"
|
||||
const GOOGLE_APPS_DOCUMENT = "application/vnd.google-apps.document"
|
||||
const GOOGLE_APPS_SPREADSHEET =
|
||||
"application/vnd.google-apps.spreadsheet"
|
||||
const GOOGLE_APPS_PRESENTATION =
|
||||
"application/vnd.google-apps.presentation"
|
||||
|
||||
function mimeTypeToFileType(mimeType: string): FileType {
|
||||
if (mimeType === GOOGLE_APPS_FOLDER) return "folder"
|
||||
if (mimeType === GOOGLE_APPS_DOCUMENT) return "document"
|
||||
if (mimeType === GOOGLE_APPS_SPREADSHEET)
|
||||
return "spreadsheet"
|
||||
if (mimeType === GOOGLE_APPS_PRESENTATION)
|
||||
return "document"
|
||||
if (mimeType === "application/pdf") return "pdf"
|
||||
if (mimeType.startsWith("image/")) return "image"
|
||||
if (mimeType.startsWith("video/")) return "video"
|
||||
if (mimeType.startsWith("audio/")) return "audio"
|
||||
if (
|
||||
mimeType.includes("zip") ||
|
||||
mimeType.includes("compressed") ||
|
||||
mimeType.includes("archive") ||
|
||||
mimeType.includes("tar") ||
|
||||
mimeType.includes("gzip")
|
||||
)
|
||||
return "archive"
|
||||
if (
|
||||
mimeType.includes("spreadsheet") ||
|
||||
mimeType.includes("excel") ||
|
||||
mimeType === "text/csv"
|
||||
)
|
||||
return "spreadsheet"
|
||||
if (
|
||||
mimeType.includes("document") ||
|
||||
mimeType.includes("word") ||
|
||||
mimeType === "text/plain" ||
|
||||
mimeType === "text/rtf"
|
||||
)
|
||||
return "document"
|
||||
if (
|
||||
mimeType.includes("javascript") ||
|
||||
mimeType.includes("json") ||
|
||||
mimeType.includes("xml") ||
|
||||
mimeType.includes("html") ||
|
||||
mimeType.includes("css") ||
|
||||
mimeType.includes("typescript")
|
||||
)
|
||||
return "code"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function mapPermissionRole(role: string): SharedRole {
|
||||
if (role === "writer" || role === "owner") return "editor"
|
||||
return "viewer"
|
||||
}
|
||||
|
||||
export function mapDriveFileToFileItem(
|
||||
driveFile: DriveFile,
|
||||
starredIds: ReadonlySet<string>,
|
||||
parentId: string | null = null
|
||||
): FileItem {
|
||||
const owner = driveFile.owners?.[0]
|
||||
const sharedWith: SharedUser[] = (
|
||||
driveFile.permissions ?? []
|
||||
)
|
||||
.filter(p => p.role !== "owner" && p.type === "user")
|
||||
.map(p => ({
|
||||
name: p.displayName ?? p.emailAddress ?? "Unknown",
|
||||
avatar: p.photoLink,
|
||||
role: mapPermissionRole(p.role),
|
||||
}))
|
||||
|
||||
return {
|
||||
id: driveFile.id,
|
||||
name: driveFile.name,
|
||||
type: mimeTypeToFileType(driveFile.mimeType),
|
||||
mimeType: driveFile.mimeType,
|
||||
size: driveFile.size ? Number(driveFile.size) : 0,
|
||||
path: [],
|
||||
createdAt: driveFile.createdTime ?? new Date().toISOString(),
|
||||
modifiedAt:
|
||||
driveFile.modifiedTime ?? new Date().toISOString(),
|
||||
owner: {
|
||||
name: owner?.displayName ?? "Unknown",
|
||||
avatar: owner?.photoLink,
|
||||
},
|
||||
starred: starredIds.has(driveFile.id),
|
||||
shared: sharedWith.length > 0 || driveFile.shared === true,
|
||||
sharedWith:
|
||||
sharedWith.length > 0 ? sharedWith : undefined,
|
||||
trashed: driveFile.trashed ?? false,
|
||||
parentId,
|
||||
webViewLink: driveFile.webViewLink,
|
||||
}
|
||||
}
|
||||
|
||||
// export types for google-native files
|
||||
export function isGoogleNativeFile(mimeType: string): boolean {
|
||||
return mimeType.startsWith("application/vnd.google-apps.")
|
||||
}
|
||||
|
||||
export function getExportMimeType(
|
||||
googleMimeType: string
|
||||
): string | null {
|
||||
switch (googleMimeType) {
|
||||
case GOOGLE_APPS_DOCUMENT:
|
||||
return "application/pdf"
|
||||
case GOOGLE_APPS_SPREADSHEET:
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
case GOOGLE_APPS_PRESENTATION:
|
||||
return "application/pdf"
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getExportExtension(
|
||||
googleMimeType: string
|
||||
): string {
|
||||
switch (googleMimeType) {
|
||||
case GOOGLE_APPS_DOCUMENT:
|
||||
return ".pdf"
|
||||
case GOOGLE_APPS_SPREADSHEET:
|
||||
return ".xlsx"
|
||||
case GOOGLE_APPS_PRESENTATION:
|
||||
return ".pdf"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@ -1,76 +1,23 @@
|
||||
// AES-256-GCM encryption for OAuth tokens at rest in D1.
|
||||
// uses Web Crypto API (available in Cloudflare Workers).
|
||||
// netsuite-specific encrypt/decrypt that delegates to shared crypto
|
||||
// with the netsuite-specific PBKDF2 salt.
|
||||
|
||||
const ALGORITHM = "AES-GCM"
|
||||
const KEY_LENGTH = 256
|
||||
const IV_LENGTH = 12
|
||||
const TAG_LENGTH = 128
|
||||
import {
|
||||
encrypt as sharedEncrypt,
|
||||
decrypt as sharedDecrypt,
|
||||
} from "@/lib/crypto"
|
||||
|
||||
async function deriveKey(secret: string): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder()
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "PBKDF2" },
|
||||
false,
|
||||
["deriveKey"]
|
||||
)
|
||||
|
||||
// static salt is fine here - the encryption key itself
|
||||
// is the secret, and each ciphertext gets a unique IV
|
||||
const salt = encoder.encode("compass-netsuite-tokens")
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations: 100000,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: ALGORITHM, length: KEY_LENGTH },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
}
|
||||
const NETSUITE_SALT = "compass-netsuite-tokens"
|
||||
|
||||
export async function encrypt(
|
||||
plaintext: string,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
const key = await deriveKey(secret)
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
|
||||
key,
|
||||
encoder.encode(plaintext)
|
||||
)
|
||||
|
||||
// pack as iv:ciphertext in base64
|
||||
const packed = new Uint8Array(iv.length + ciphertext.byteLength)
|
||||
packed.set(iv)
|
||||
packed.set(new Uint8Array(ciphertext), iv.length)
|
||||
|
||||
return btoa(String.fromCharCode(...packed))
|
||||
return sharedEncrypt(plaintext, secret, NETSUITE_SALT)
|
||||
}
|
||||
|
||||
export async function decrypt(
|
||||
encoded: string,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
const key = await deriveKey(secret)
|
||||
|
||||
const packed = Uint8Array.from(atob(encoded), c => c.charCodeAt(0))
|
||||
const iv = packed.slice(0, IV_LENGTH)
|
||||
const ciphertext = packed.slice(IV_LENGTH)
|
||||
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
|
||||
key,
|
||||
ciphertext
|
||||
)
|
||||
|
||||
return new TextDecoder().decode(plaintext)
|
||||
return sharedDecrypt(encoded, secret, NETSUITE_SALT)
|
||||
}
|
||||
|
||||
@ -17,7 +17,8 @@ function isPublicPath(pathname: string): boolean {
|
||||
return (
|
||||
publicPaths.includes(pathname) ||
|
||||
pathname.startsWith("/api/auth/") ||
|
||||
pathname.startsWith("/api/netsuite/")
|
||||
pathname.startsWith("/api/netsuite/") ||
|
||||
pathname.startsWith("/api/google/")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user