diff --git a/apps/backend/cmd/docs/openapi.json b/apps/backend/cmd/docs/openapi.json index c2cd1af..361acbb 100644 --- a/apps/backend/cmd/docs/openapi.json +++ b/apps/backend/cmd/docs/openapi.json @@ -218,6 +218,83 @@ } } } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete multiple directories permanently or move them to trash. Deleting directories also affects all their contents. All items must be directories.", + "tags": [ + "directories" + ], + "summary": "Bulk delete directories", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "Comma-separated list of directory IDs to delete", + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Move to trash instead of permanent delete", + "name": "trash", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "204": { + "description": "Directories deleted", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "All items must be directories", + "content": { + "*/*": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } } }, "/accounts/{accountID}/directories/{directoryID}": { @@ -621,6 +698,85 @@ } } }, + "/accounts/{accountID}/files": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete multiple files permanently or move them to trash. All items must be files.", + "tags": [ + "files" + ], + "summary": "Bulk delete files", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "Comma-separated list of file IDs to delete", + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Move to trash instead of permanent delete", + "name": "trash", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "204": { + "description": "Files deleted", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "All items must be files", + "content": { + "*/*": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/accounts/{accountID}/files/{fileID}": { "get": { "security": [ diff --git a/apps/backend/config.example.yaml b/apps/backend/config.example.yaml index 4668de0..137b763 100644 --- a/apps/backend/config.example.yaml +++ b/apps/backend/config.example.yaml @@ -31,5 +31,22 @@ storage: cookie: # Domain for cross-subdomain auth cookies. # Set this when frontend and API are on different subdomains (e.g., "app.com" for web.app.com + api.app.com). - # Leave empty for single-domain or localhost setups. + # Leave empty for same-host cookies (localhost, single domain). # domain: app.com + # Secure flag for cookies. If not set, automatically determined from request protocol (true for HTTPS, false for HTTP). + # Set explicitly to override automatic detection (useful for local development with HTTPS). + # secure: false + +cors: + # Allowed origins for cross-origin requests. + # Required when frontend and API are on different domains. + # If not specified, CORS will be restrictive (only same-origin requests allowed). + # Example for cross-domain setup: + # allow_origins: + # - http://localhost:3000 + # - https://app.example.com + # Allow credentials (cookies, authorization headers) in cross-origin requests. + # Should be true when using cookies for authentication in cross-domain setups. + # Note: When allow_credentials is true, you must explicitly specify allow_origins + # (wildcard "*" is not allowed with credentials for security reasons). + # allow_credentials: true diff --git a/apps/backend/config.yaml b/apps/backend/config.yaml index c7a198d..26975fb 100644 --- a/apps/backend/config.yaml +++ b/apps/backend/config.yaml @@ -13,3 +13,8 @@ storage: mode: hierarchical backend: fs root_path: ./data + +cors: + allow_origins: + - http://localhost:3000 + allow_credentials: true diff --git a/apps/backend/docs/docs.go b/apps/backend/docs/docs.go index af4f05f..8d384aa 100644 --- a/apps/backend/docs/docs.go +++ b/apps/backend/docs/docs.go @@ -447,10 +447,13 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Move one or more files or directories into this directory. All items must currently be in the same source directory.", + "description": "Move one or more files or directories into this directory. Returns detailed status for each item including which were successfully moved, which had conflicts, and which encountered errors.", "consumes": [ "application/json" ], + "produces": [ + "application/json" + ], "tags": [ "directories" ], @@ -482,10 +485,10 @@ const docTemplate = `{ } ], "responses": { - "204": { - "description": "Items moved successfully", + "200": { + "description": "Move operation results with moved, conflict, and error states", "schema": { - "type": "string" + "$ref": "#/definitions/internal_catalog.moveItemsToDirectoryResponse" } }, "400": { @@ -511,15 +514,6 @@ const docTemplate = `{ "type": "string" } } - }, - "409": { - "description": "Name conflict in target directory", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } } } } @@ -1433,6 +1427,109 @@ const docTemplate = `{ } } }, + "internal_catalog.moveItemError": { + "description": "Error details for a failed item move", + "type": "object", + "properties": { + "error": { + "description": "Error message describing what went wrong", + "type": "string", + "example": "permission denied" + }, + "id": { + "description": "ID of the item that failed to move", + "type": "string", + "example": "mElnUNCm8F22" + } + } + }, + "internal_catalog.moveItemsToDirectoryResponse": { + "description": "Response from moving items to a directory with status for each item", + "type": "object", + "properties": { + "conflicts": { + "description": "Array of IDs of items that conflicted with existing items in the target directory", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "xYz123AbC456" + ] + }, + "errors": { + "description": "Array of errors that occurred during the move operation", + "type": "array", + "items": { + "$ref": "#/definitions/internal_catalog.moveItemError" + } + }, + "items": { + "description": "Array of items included in the request (files and directories)", + "type": "array", + "items": { + "$ref": "#/definitions/internal_catalog.moveResponseItem" + } + }, + "moved": { + "description": "Array of IDs of successfully moved items", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "mElnUNCm8F22", + "kRp2XYTq9A55" + ] + } + } + }, + "internal_catalog.moveResponseItem": { + "description": "Item included in the move operation. Check \"kind\" field to determine type: \"file\" (has size, mimeType) or \"directory\"", + "type": "object", + "properties": { + "createdAt": { + "description": "When the item was created (ISO 8601)", + "type": "string", + "example": "2024-12-13T15:04:05Z" + }, + "deletedAt": { + "description": "When the item was trashed, null if not trashed (ISO 8601)", + "type": "string", + "example": "2024-12-14T10:00:00Z" + }, + "id": { + "description": "Unique item identifier", + "type": "string", + "example": "mElnUNCm8F22" + }, + "kind": { + "description": "Item type: \"file\" or \"directory\"", + "type": "string", + "example": "file" + }, + "mimeType": { + "description": "MIME type (only for files)", + "type": "string", + "example": "application/pdf" + }, + "name": { + "description": "Item name", + "type": "string", + "example": "document.pdf" + }, + "size": { + "description": "File size in bytes (only for files)", + "type": "integer", + "example": 1048576 + }, + "updatedAt": { + "description": "When the item was last updated (ISO 8601)", + "type": "string", + "example": "2024-12-13T16:30:00Z" + } + } + }, "internal_catalog.patchDirectoryRequest": { "description": "Request to update directory properties", "type": "object", diff --git a/apps/backend/docs/swagger.json b/apps/backend/docs/swagger.json index 93296fa..448306e 100644 --- a/apps/backend/docs/swagger.json +++ b/apps/backend/docs/swagger.json @@ -187,6 +187,65 @@ } } } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete multiple directories permanently or move them to trash. Deleting directories also affects all their contents. All items must be directories.", + "tags": [ + "directories" + ], + "summary": "Bulk delete directories", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Comma-separated list of directory IDs to delete", + "name": "id", + "in": "query", + "required": true + }, + { + "type": "boolean", + "default": false, + "description": "Move to trash instead of permanent delete", + "name": "trash", + "in": "query" + } + ], + "responses": { + "204": { + "description": "Directories deleted", + "schema": { + "type": "string" + } + }, + "400": { + "description": "All items must be directories", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Not authenticated", + "schema": { + "type": "string" + } + } + } } }, "/accounts/{accountID}/directories/{directoryID}": { @@ -512,6 +571,67 @@ } } }, + "/accounts/{accountID}/files": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete multiple files permanently or move them to trash. All items must be files.", + "tags": [ + "files" + ], + "summary": "Bulk delete files", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Comma-separated list of file IDs to delete", + "name": "id", + "in": "query", + "required": true + }, + { + "type": "boolean", + "default": false, + "description": "Move to trash instead of permanent delete", + "name": "trash", + "in": "query" + } + ], + "responses": { + "204": { + "description": "Files deleted", + "schema": { + "type": "string" + } + }, + "400": { + "description": "All items must be files", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Not authenticated", + "schema": { + "type": "string" + } + } + } + } + }, "/accounts/{accountID}/files/{fileID}": { "get": { "security": [ diff --git a/apps/backend/docs/swagger.yaml b/apps/backend/docs/swagger.yaml index 31db283..e3cac32 100644 --- a/apps/backend/docs/swagger.yaml +++ b/apps/backend/docs/swagger.yaml @@ -225,6 +225,85 @@ definitions: example: kRp2XYTq9A55 type: string type: object + internal_catalog.moveItemError: + description: Error details for a failed item move + properties: + error: + description: Error message describing what went wrong + example: permission denied + type: string + id: + description: ID of the item that failed to move + example: mElnUNCm8F22 + type: string + type: object + internal_catalog.moveItemsToDirectoryResponse: + description: Response from moving items to a directory with status for each item + properties: + conflicts: + description: Array of IDs of items that conflicted with existing items in + the target directory + example: + - xYz123AbC456 + items: + type: string + type: array + errors: + description: Array of errors that occurred during the move operation + items: + $ref: '#/definitions/internal_catalog.moveItemError' + type: array + items: + description: Array of items included in the request (files and directories) + items: + $ref: '#/definitions/internal_catalog.moveResponseItem' + type: array + moved: + description: Array of IDs of successfully moved items + example: + - mElnUNCm8F22 + - kRp2XYTq9A55 + items: + type: string + type: array + type: object + internal_catalog.moveResponseItem: + description: 'Item included in the move operation. Check "kind" field to determine + type: "file" (has size, mimeType) or "directory"' + properties: + createdAt: + description: When the item was created (ISO 8601) + example: "2024-12-13T15:04:05Z" + type: string + deletedAt: + description: When the item was trashed, null if not trashed (ISO 8601) + example: "2024-12-14T10:00:00Z" + type: string + id: + description: Unique item identifier + example: mElnUNCm8F22 + type: string + kind: + description: 'Item type: "file" or "directory"' + example: file + type: string + mimeType: + description: MIME type (only for files) + example: application/pdf + type: string + name: + description: Item name + example: document.pdf + type: string + size: + description: File size in bytes (only for files) + example: 1048576 + type: integer + updatedAt: + description: When the item was last updated (ISO 8601) + example: "2024-12-13T16:30:00Z" + type: string + type: object internal_catalog.patchDirectoryRequest: description: Request to update directory properties properties: @@ -616,8 +695,9 @@ paths: post: consumes: - application/json - description: Move one or more files or directories into this directory. All - items must currently be in the same source directory. + description: Move one or more files or directories into this directory. Returns + detailed status for each item including which were successfully moved, which + had conflicts, and which encountered errors. parameters: - description: Account ID format: uuid @@ -636,11 +716,13 @@ paths: required: true schema: $ref: '#/definitions/internal_catalog.postDirectoryContentRequest' + produces: + - application/json responses: - "204": - description: Items moved successfully + "200": + description: Move operation results with moved, conflict, and error states schema: - type: string + $ref: '#/definitions/internal_catalog.moveItemsToDirectoryResponse' "400": description: Invalid request or items not in same directory schema: @@ -657,12 +739,6 @@ paths: additionalProperties: type: string type: object - "409": - description: Name conflict in target directory - schema: - additionalProperties: - type: string - type: object security: - BearerAuth: [] summary: Move items to directory diff --git a/apps/backend/drexa b/apps/backend/drexa new file mode 100755 index 0000000..fd74480 Binary files /dev/null and b/apps/backend/drexa differ diff --git a/apps/backend/internal/account/account.go b/apps/backend/internal/account/account.go index c580e1d..4fe1d88 100644 --- a/apps/backend/internal/account/account.go +++ b/apps/backend/internal/account/account.go @@ -14,14 +14,19 @@ type Account struct { // Unique account identifier ID uuid.UUID `bun:",pk,type:uuid" json:"id" example:"550e8400-e29b-41d4-a716-446655440000"` + // ID of the user who owns this account UserID uuid.UUID `bun:"user_id,notnull,type:uuid" json:"userId" example:"550e8400-e29b-41d4-a716-446655440001"` + // Current storage usage in bytes StorageUsageBytes int64 `bun:"storage_usage_bytes,notnull" json:"storageUsageBytes" example:"1073741824"` + // Maximum storage quota in bytes StorageQuotaBytes int64 `bun:"storage_quota_bytes,notnull" json:"storageQuotaBytes" example:"10737418240"` + // When the account was created (ISO 8601) CreatedAt time.Time `bun:"created_at,notnull,nullzero" json:"createdAt" example:"2024-12-13T15:04:05Z"` + // When the account was last updated (ISO 8601) UpdatedAt time.Time `bun:"updated_at,notnull,nullzero" json:"updatedAt" example:"2024-12-13T16:30:00Z"` } diff --git a/apps/backend/internal/account/http.go b/apps/backend/internal/account/http.go index 71c24fa..4bdc35d 100644 --- a/apps/backend/internal/account/http.go +++ b/apps/backend/internal/account/http.go @@ -54,6 +54,7 @@ func NewHTTPHandler(accountService *Service, authService *auth.Service, db *bun. } func (h *HTTPHandler) RegisterRoutes(api fiber.Router) fiber.Router { + api.Get("/accounts", h.authMiddleware, h.listAccounts) api.Post("/accounts", h.registerAccount) account := api.Group("/accounts/:accountID") @@ -86,6 +87,24 @@ func (h *HTTPHandler) accountMiddleware(c *fiber.Ctx) error { return c.Next() } +// listAccounts lists all accounts for the authenticated user +// @Summary List accounts +// @Description Retrieve all accounts for the authenticated user +// @Tags accounts +// @Produce json +// @Security BearerAuth +// @Success 200 {array} Account "List of accounts for the authenticated user" +// @Failure 401 {string} string "Not authenticated" +// @Router /accounts [get] +func (h *HTTPHandler) listAccounts(c *fiber.Ctx) error { + u := reqctx.AuthenticatedUser(c).(*user.User) + accounts, err := h.accountService.ListAccounts(c.Context(), h.db, u.ID) + if err != nil { + return httperr.Internal(err) + } + return c.JSON(accounts) +} + // getAccount retrieves account information // @Summary Get account // @Description Retrieve account details including storage usage and quota diff --git a/apps/backend/internal/account/service.go b/apps/backend/internal/account/service.go index ea9ddf2..11ab633 100644 --- a/apps/backend/internal/account/service.go +++ b/apps/backend/internal/account/service.go @@ -90,6 +90,18 @@ func (s *Service) CreateAccount(ctx context.Context, db bun.IDB, userID uuid.UUI return account, nil } +func (s *Service) ListAccounts(ctx context.Context, db bun.IDB, userID uuid.UUID) ([]*Account, error) { + var accounts []*Account + err := db.NewSelect().Model(&accounts).Where("user_id = ?", userID).Scan(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return make([]*Account, 0), nil + } + return nil, err + } + return accounts, nil +} + func (s *Service) AccountByUserID(ctx context.Context, db bun.IDB, userID uuid.UUID) (*Account, error) { var account Account err := db.NewSelect().Model(&account).Where("user_id = ?", userID).Scan(ctx) diff --git a/apps/backend/internal/auth/cookies.go b/apps/backend/internal/auth/cookies.go index fa5d59b..6948708 100644 --- a/apps/backend/internal/auth/cookies.go +++ b/apps/backend/internal/auth/cookies.go @@ -11,6 +11,10 @@ type CookieConfig struct { // Domain for cross-subdomain cookies (e.g., "app.com" for web.app.com + api.app.com). // Leave empty for same-host cookies (localhost, single domain). Domain string + // Secure controls whether cookies are only sent over HTTPS. + // If nil, automatically set based on request protocol (true for HTTPS, false for HTTP). + // If explicitly set, this value is used regardless of protocol. + Secure *bool } // authCookies returns auth cookies from the given fiber context. @@ -29,28 +33,37 @@ func authCookies(c *fiber.Ctx) map[string]string { } // setAuthCookies sets HTTP-only auth cookies with security settings derived from the request. -// Secure flag is based on actual protocol (works automatically with proxies/tunnels). +// Secure flag is based on actual protocol (works automatically with proxies/tunnels), +// unless explicitly set in cfg.Secure. func setAuthCookies(c *fiber.Ctx, accessToken, refreshToken string, cfg CookieConfig) { secure := c.Protocol() == "https" - c.Cookie(&fiber.Cookie{ + accessTokenCookie := &fiber.Cookie{ Name: cookieKeyAccessToken, Value: accessToken, Path: "/", - Domain: cfg.Domain, Expires: time.Now().Add(accessTokenValidFor), SameSite: fiber.CookieSameSiteLaxMode, HTTPOnly: true, Secure: secure, - }) - c.Cookie(&fiber.Cookie{ + } + if cfg.Domain != "" { + accessTokenCookie.Domain = cfg.Domain + } + + refreshTokenCookie := &fiber.Cookie{ Name: cookieKeyRefreshToken, Value: refreshToken, Path: "/", - Domain: cfg.Domain, Expires: time.Now().Add(refreshTokenValidFor), SameSite: fiber.CookieSameSiteLaxMode, HTTPOnly: true, Secure: secure, - }) + } + if cfg.Domain != "" { + refreshTokenCookie.Domain = cfg.Domain + } + + c.Cookie(accessTokenCookie) + c.Cookie(refreshTokenCookie) } diff --git a/apps/backend/internal/catalog/directory.go b/apps/backend/internal/catalog/directory.go index c5fa988..adced8c 100644 --- a/apps/backend/internal/catalog/directory.go +++ b/apps/backend/internal/catalog/directory.go @@ -85,12 +85,23 @@ func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error { } directoryID := c.Params("directoryID") - node, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, account.ID, directoryID) - if err != nil { - if errors.Is(err, virtualfs.ErrNodeNotFound) { - return c.SendStatus(fiber.StatusNotFound) + + var node *virtualfs.Node + if directoryID == "root" { + n, err := h.vfs.FindRootDirectory(c.Context(), h.db, account.ID) + if err != nil { + return httperr.Internal(err) } - return httperr.Internal(err) + node = n + } else { + n, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, account.ID, directoryID) + if err != nil { + if errors.Is(err, virtualfs.ErrNodeNotFound) { + return c.SendStatus(fiber.StatusNotFound) + } + return httperr.Internal(err) + } + node = n } c.Locals("directory", node) @@ -349,23 +360,117 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error { shouldTrash := c.Query("trash") == "true" if shouldTrash { - err = h.vfs.SoftDeleteNode(c.Context(), h.db, node) + _, err := h.vfs.SoftDeleteNode(c.Context(), tx, node) if err != nil { return httperr.Internal(err) } + + err = tx.Commit() + if err != nil { + return httperr.Internal(err) + } + + return c.JSON(directoryInfoFromNode(node)) } else { - err = h.vfs.PermanentlyDeleteNode(c.Context(), h.db, node) + err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node) if err != nil { return httperr.Internal(err) } + + err = tx.Commit() + if err != nil { + return httperr.Internal(err) + } + + return c.SendStatus(fiber.StatusNoContent) } - err = tx.Commit() +} + +// deleteDirectories removes multiple directories +// @Summary Bulk delete directories +// @Description Delete multiple directories permanently or move them to trash. Deleting directories also affects all their contents. All items must be directories. +// @Tags directories +// @Security BearerAuth +// @Param accountID path string true "Account ID" format(uuid) +// @Param id query string true "Comma-separated list of directory IDs to delete" example:"kRp2XYTq9A55,xYz123AbC456" +// @Param trash query bool false "Move to trash instead of permanent delete" default(false) +// @Success 204 {string} string "Directories deleted" +// @Failure 400 {object} map[string]string "All items must be directories" +// @Failure 401 {string} string "Not authenticated" +// @Router /accounts/{accountID}/directories [delete] +func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error { + account := account.CurrentAccount(c) + if account == nil { + return c.SendStatus(fiber.StatusUnauthorized) + } + + idq := c.Query("id", "") + if idq == "" { + return c.SendStatus(fiber.StatusNoContent) + } + + ids := strings.Split(idq, ",") + if len(ids) == 0 { + return c.SendStatus(fiber.StatusNoContent) + } + + shouldTrash := c.Query("trash") == "true" + + tx, err := h.db.BeginTx(c.Context(), nil) + if err != nil { + return httperr.Internal(err) + } + defer tx.Rollback() + + nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, account.ID, ids) if err != nil { return httperr.Internal(err) } - return c.SendStatus(fiber.StatusNoContent) + if len(nodes) == 0 { + return c.SendStatus(fiber.StatusNoContent) + } + + for _, node := range nodes { + if node.Kind != virtualfs.NodeKindDirectory { + return httperr.NewHTTPError(fiber.StatusBadRequest, "all items must be directories", nil) + } + } + + if shouldTrash { + deleted, err := h.vfs.SoftDeleteNodes(c.Context(), tx, nodes) + if err != nil { + return httperr.Internal(err) + } + + err = tx.Commit() + if err != nil { + return httperr.Internal(err) + } + + res := make([]DirectoryInfo, 0, len(deleted)) + for _, node := range deleted { + res = append(res, directoryInfoFromNode(node)) + } + + return c.JSON(deleted) + } else { + for _, node := range nodes { + err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node) + if err != nil { + return httperr.Internal(err) + } + } + + err = tx.Commit() + if err != nil { + return httperr.Internal(err) + } + + return c.SendStatus(fiber.StatusNoContent) + } + } // moveItemsToDirectory moves files and directories into this directory diff --git a/apps/backend/internal/catalog/file.go b/apps/backend/internal/catalog/file.go index 58553ae..75aad5b 100644 --- a/apps/backend/internal/catalog/file.go +++ b/apps/backend/internal/catalog/file.go @@ -2,7 +2,7 @@ package catalog import ( "errors" - "fmt" + "strings" "time" "github.com/get-drexa/drexa/internal/account" @@ -168,8 +168,6 @@ func (h *HTTPHandler) patchFile(c *fiber.Ctx) error { return httperr.Internal(err) } - fmt.Printf("node deleted at: %v\n", node.DeletedAt) - return c.JSON(FileInfo{ ID: node.PublicID, Name: node.Name, @@ -206,19 +204,20 @@ func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error { shouldTrash := c.Query("trash") == "true" if shouldTrash { - err = h.vfs.SoftDeleteNode(c.Context(), tx, node) + deleted, err := h.vfs.SoftDeleteNode(c.Context(), tx, node) + if err != nil { + if errors.Is(err, virtualfs.ErrNodeNotFound) { + return c.SendStatus(fiber.StatusNotFound) + } + return httperr.Internal(err) + } + + err = tx.Commit() if err != nil { return httperr.Internal(err) } - return c.JSON(FileInfo{ - ID: node.PublicID, - Name: node.Name, - Size: node.Size, - MimeType: node.MimeType, - CreatedAt: node.CreatedAt, - UpdatedAt: node.UpdatedAt, - DeletedAt: node.DeletedAt, - }) + + return c.JSON(fileInfoFromNode(deleted)) } else { err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node) if err != nil { @@ -233,3 +232,85 @@ func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } } + +// deleteFiles removes multiple files +// @Summary Bulk delete files +// @Description Delete multiple files permanently or move them to trash. All items must be files. +// @Tags files +// @Security BearerAuth +// @Param accountID path string true "Account ID" format(uuid) +// @Param id query string true "Comma-separated list of file IDs to delete" example:"mElnUNCm8F22,kRp2XYTq9A55" +// @Param trash query bool false "Move to trash instead of permanent delete" default(false) +// @Success 204 {string} string "Files deleted" +// @Failure 400 {object} map[string]string "All items must be files" +// @Failure 401 {string} string "Not authenticated" +// @Router /accounts/{accountID}/files [delete] +func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error { + account := account.CurrentAccount(c) + if account == nil { + return c.SendStatus(fiber.StatusUnauthorized) + } + + idq := c.Query("id", "") + if idq == "" { + return c.SendStatus(fiber.StatusNoContent) + } + + ids := strings.Split(idq, ",") + if len(ids) == 0 { + return c.SendStatus(fiber.StatusNoContent) + } + + shouldTrash := c.Query("trash") == "true" + + tx, err := h.db.BeginTx(c.Context(), nil) + if err != nil { + return httperr.Internal(err) + } + defer tx.Rollback() + + nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, account.ID, ids) + if err != nil { + return httperr.Internal(err) + } + + if len(nodes) == 0 { + return c.SendStatus(fiber.StatusNoContent) + } + + if shouldTrash { + deleted, err := h.vfs.SoftDeleteNodes(c.Context(), tx, nodes) + if err != nil { + return httperr.Internal(err) + } + + err = tx.Commit() + if err != nil { + return httperr.Internal(err) + } + + res := make([]FileInfo, 0, len(deleted)) + for _, node := range deleted { + res = append(res, fileInfoFromNode(node)) + } + + return c.JSON(res) + + } else { + err = h.vfs.PermanentlyDeleteFiles(c.Context(), tx, nodes) + if err != nil { + if errors.Is(err, virtualfs.ErrUnsupportedOperation) { + return httperr.NewHTTPError(fiber.StatusBadRequest, "all items must be files", err) + } + return httperr.Internal(err) + } + + err = tx.Commit() + if err != nil { + return httperr.Internal(err) + } + + return c.SendStatus(fiber.StatusNoContent) + } + +} diff --git a/apps/backend/internal/catalog/http.go b/apps/backend/internal/catalog/http.go index 37ad8cb..b54c778 100644 --- a/apps/backend/internal/catalog/http.go +++ b/apps/backend/internal/catalog/http.go @@ -30,6 +30,8 @@ func NewHTTPHandler(vfs *virtualfs.VirtualFS, db *bun.DB) *HTTPHandler { } func (h *HTTPHandler) RegisterRoutes(api fiber.Router) { + api.Delete("/files", h.deleteFiles) + fg := api.Group("/files/:fileID") fg.Use(h.currentFileMiddleware) fg.Get("/", h.fetchFile) @@ -38,6 +40,7 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) { fg.Delete("/", h.deleteFile) api.Post("/directories", h.createDirectory) + api.Delete("/directories", h.deleteDirectories) dg := api.Group("/directories/:directoryID") dg.Use(h.currentDirectoryMiddleware) @@ -47,3 +50,35 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) { dg.Patch("/", h.patchDirectory) dg.Delete("/", h.deleteDirectory) } + +func fileInfoFromNode(node *virtualfs.Node) FileInfo { + return FileInfo{ + Kind: DirItemKindFile, + ID: node.PublicID, + Name: node.Name, + Size: node.Size, + MimeType: node.MimeType, + } +} + +func directoryInfoFromNode(node *virtualfs.Node) DirectoryInfo { + return DirectoryInfo{ + Kind: DirItemKindDirectory, + ID: node.PublicID, + Name: node.Name, + CreatedAt: node.CreatedAt, + UpdatedAt: node.UpdatedAt, + DeletedAt: node.DeletedAt, + } +} + +func toDirectoryItem(node *virtualfs.Node) any { + switch node.Kind { + default: + return FileInfo{} + case virtualfs.NodeKindDirectory: + return directoryInfoFromNode(node) + case virtualfs.NodeKindFile: + return fileInfoFromNode(node) + } +} diff --git a/apps/backend/internal/drexa/config.go b/apps/backend/internal/drexa/config.go index a3c89e1..44bb650 100644 --- a/apps/backend/internal/drexa/config.go +++ b/apps/backend/internal/drexa/config.go @@ -28,6 +28,7 @@ type Config struct { JWT JWTConfig `yaml:"jwt"` Storage StorageConfig `yaml:"storage"` Cookie CookieConfig `yaml:"cookie"` + CORS CORSConfig `yaml:"cors"` } type ServerConfig struct { @@ -55,9 +56,20 @@ type StorageConfig struct { // CookieConfig controls auth cookie behavior. // Domain is optional - only needed for cross-subdomain setups (e.g., "app.com" for web.app.com + api.app.com). -// Secure flag is derived from the request protocol automatically. +// Secure flag is derived from the request protocol automatically, unless explicitly set. type CookieConfig struct { Domain string `yaml:"domain"` + Secure *bool `yaml:"secure"` +} + +// CORSConfig controls Cross-Origin Resource Sharing behavior. +// AllowOrigins specifies which origins are allowed to make cross-origin requests. +// If empty, CORS will allow all origins (not recommended for production). +// AllowCredentials enables sending credentials (cookies, authorization headers) in cross-origin requests. +// This should be true when using cookies for authentication in cross-domain setups. +type CORSConfig struct { + AllowOrigins []string `yaml:"allow_origins"` + AllowCredentials bool `yaml:"allow_credentials"` } // ConfigFromFile loads configuration from a YAML file. @@ -159,5 +171,10 @@ func (c *Config) Validate() []error { } } + // CORS validation + if c.CORS.AllowCredentials && len(c.CORS.AllowOrigins) == 0 { + errs = append(errs, errors.New("cors.allow_origins is required when cors.allow_credentials is true (cannot use wildcard '*' with credentials)")) + } + return errs } diff --git a/apps/backend/internal/drexa/server.go b/apps/backend/internal/drexa/server.go index 1e26d81..2c4f29a 100644 --- a/apps/backend/internal/drexa/server.go +++ b/apps/backend/internal/drexa/server.go @@ -3,6 +3,7 @@ package drexa import ( "context" "fmt" + "strings" "github.com/get-drexa/drexa/internal/account" "github.com/get-drexa/drexa/internal/auth" @@ -14,6 +15,7 @@ import ( "github.com/get-drexa/drexa/internal/user" "github.com/get-drexa/drexa/internal/virtualfs" "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/uptrace/bun" "github.com/uptrace/bun/extra/bundebug" @@ -44,6 +46,16 @@ func NewServer(c Config) (*Server, error) { }) app.Use(logger.New()) + // Configure CORS middleware + corsConfig := cors.Config{ + AllowOrigins: "", + AllowCredentials: c.CORS.AllowCredentials, + } + if len(c.CORS.AllowOrigins) > 0 { + corsConfig.AllowOrigins = strings.Join(c.CORS.AllowOrigins, ",") + } + app.Use(cors.New(corsConfig)) + db := database.NewFromPostgres(c.Database.PostgresURL) db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true))) @@ -92,6 +104,7 @@ func NewServer(c Config) (*Server, error) { cookieConfig := auth.CookieConfig{ Domain: c.Cookie.Domain, + Secure: c.Cookie.Secure, } authMiddleware := auth.NewAuthMiddleware(authService, db, cookieConfig) diff --git a/apps/backend/internal/user/user.go b/apps/backend/internal/user/user.go index 56ad448..b7629e9 100644 --- a/apps/backend/internal/user/user.go +++ b/apps/backend/internal/user/user.go @@ -18,10 +18,10 @@ type User struct { // User's display name DisplayName string `bun:"display_name" json:"displayName" example:"John Doe"` // User's email address - Email string `bun:"email,unique,notnull" json:"email" example:"john@example.com"` - Password password.Hashed `bun:"password,notnull" json:"-" swaggerignore:"true"` - CreatedAt time.Time `bun:"created_at,notnull,nullzero" json:"-" swaggerignore:"true"` - UpdatedAt time.Time `bun:"updated_at,notnull,nullzero" json:"-" swaggerignore:"true"` + Email string `bun:"email,unique,notnull" json:"email" example:"john@example.com"` + Password password.Hashed `bun:"password,notnull" json:"-" swaggerignore:"true"` + CreatedAt time.Time `bun:"created_at,notnull,nullzero" json:"-" swaggerignore:"true"` + UpdatedAt time.Time `bun:"updated_at,notnull,nullzero" json:"-" swaggerignore:"true"` } func newUserID() (uuid.UUID, error) { diff --git a/apps/backend/internal/virtualfs/hierarchical_key_resolver.go b/apps/backend/internal/virtualfs/hierarchical_key_resolver.go index 9e6cfce..d5c0e3d 100644 --- a/apps/backend/internal/virtualfs/hierarchical_key_resolver.go +++ b/apps/backend/internal/virtualfs/hierarchical_key_resolver.go @@ -72,7 +72,7 @@ func (r *HierarchicalKeyResolver) ResolveBulkMoveOps(ctx context.Context, db bun for i, node := range nodes { oldKey := blob.Key(fmt.Sprintf("%s/%s/%s", accountID, oldParentPath, node.Name)) newKey := blob.Key(fmt.Sprintf("%s/%s/%s", accountID, newParentPath, node.Name)) - ops[i] = BlobMoveOp{OldKey: oldKey, NewKey: newKey} + ops[i] = BlobMoveOp{Node: node, OldKey: oldKey, NewKey: newKey} } return ops, nil diff --git a/apps/backend/internal/virtualfs/key_resolver.go b/apps/backend/internal/virtualfs/key_resolver.go index 359d4c7..bac15cb 100644 --- a/apps/backend/internal/virtualfs/key_resolver.go +++ b/apps/backend/internal/virtualfs/key_resolver.go @@ -29,6 +29,7 @@ type DeletionPlan struct { // BlobMoveOp represents a blob move operation from OldKey to NewKey. type BlobMoveOp struct { + Node *Node OldKey blob.Key NewKey blob.Key } diff --git a/apps/backend/internal/virtualfs/vfs.go b/apps/backend/internal/virtualfs/vfs.go index cc1340b..4de167f 100644 --- a/apps/backend/internal/virtualfs/vfs.go +++ b/apps/backend/internal/virtualfs/vfs.go @@ -37,6 +37,17 @@ type CreateFileOptions struct { Name string } +type MoveFileError struct { + Node *Node + Error error +} + +type MoveFilesResult struct { + Moved []*Node + Conflicts []*Node + Errors []MoveFileError +} + const RootDirectoryName = "root" func New(blobStore blob.Store, keyResolver BlobKeyResolver) (*VirtualFS, error) { @@ -97,6 +108,26 @@ func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, accou return nodes, nil } +func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID) (*Node, error) { + root := new(Node) + + err := db.NewSelect().Model(root). + Where("account_id = ?", accountID). + Where("parent_id IS NULL"). + Where("status = ?", NodeStatusReady). + Where("deleted_at IS NULL"). + Scan(ctx) + if err != nil { + return nil, err + } + + if root.Kind != NodeKindDirectory { + return nil, ErrNodeNotFound + } + + return root, nil +} + func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node) ([]*Node, error) { if !node.IsAccessible() { return nil, ErrNodeNotFound @@ -299,26 +330,43 @@ func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, accountID return node, nil } -func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node) error { - if !node.IsAccessible() { - return ErrNodeNotFound +func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node) (*Node, error) { + deleted, err := vfs.SoftDeleteNodes(ctx, db, []*Node{node}) + if err != nil { + return nil, err + } + if len(deleted) == 0 { + return nil, ErrNodeNotFound + } + return deleted[0], nil +} + +func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*Node) ([]*Node, error) { + if len(nodes) == 0 { + return nil, nil } - _, err := db.NewUpdate().Model(node). - WherePK(). - Where("deleted_at IS NULL"). + deletableNodes := make([]*Node, 0, len(nodes)) + nodeIDs := make([]uuid.UUID, 0, len(nodes)) + for _, node := range nodes { + if node.IsAccessible() { + nodeIDs = append(nodeIDs, node.ID) + deletableNodes = append(deletableNodes, node) + } + } + + _, err := db.NewUpdate().Model(deletableNodes). + Where("id IN (?)", bun.In(nodeIDs)). Where("status = ?", NodeStatusReady). + Where("deleted_at IS NULL"). Set("deleted_at = NOW()"). Returning("deleted_at"). Exec(ctx) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ErrNodeNotFound - } - return err + return nil, err } - return nil + return deletableNodes, nil } func (vfs *VirtualFS) RestoreNode(ctx context.Context, db bun.IDB, node *Node) error { @@ -447,23 +495,53 @@ func (vfs *VirtualFS) MoveNode(ctx context.Context, db bun.IDB, node *Node, pare // All nodes MUST have the same current parent directory; this constraint enables an // optimization where parent paths are computed only once (2 recursive queries total) // rather than computing full paths for each node individually (N queries). -func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, nodes []*Node, newParentID uuid.UUID) error { +func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, nodes []*Node, newParentID uuid.UUID) (*MoveFilesResult, error) { if len(nodes) == 0 { - return nil + return nil, nil } // Validate all nodes are accessible nodeIDs := make([]uuid.UUID, len(nodes)) + nodeNames := make([]string, len(nodes)) for i, node := range nodes { if !node.IsAccessible() { - return ErrNodeNotFound + return nil, ErrNodeNotFound } nodeIDs[i] = node.ID + nodeNames[i] = node.Name } - moveOps, err := vfs.keyResolver.ResolveBulkMoveOps(ctx, db, nodes, newParentID) + var conflicts []*Node + err := db.NewSelect().Model(&conflicts). + Where("account_id = ?", nodes[0].AccountID). + Where("parent_id = ?", newParentID). + Where("name IN (?)", bun.In(nodeNames)). + Scan(ctx) if err != nil { - return err + return nil, err + } + + conflictID := make(map[uuid.UUID]struct{}) + for _, c := range conflicts { + conflictID[c.ID] = struct{}{} + } + + movableNodes := make([]*Node, 0, len(nodes)-len(conflicts)) + for _, node := range nodes { + if _, ok := conflictID[node.ID]; !ok { + movableNodes = append(movableNodes, node) + } + } + + if len(movableNodes) == 0 { + return &MoveFilesResult{ + Conflicts: conflicts, + }, nil + } + + moveOps, err := vfs.keyResolver.ResolveBulkMoveOps(ctx, db, movableNodes, newParentID) + if err != nil { + return nil, err } _, err = db.NewUpdate(). @@ -474,17 +552,23 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, Set("parent_id = ?", newParentID). Exec(ctx) if err != nil { - if database.IsUniqueViolation(err) { - return ErrNodeConflict - } - return err + return nil, err } + errs := []MoveFileError{} + for _, op := range moveOps { if op.OldKey != op.NewKey { err = vfs.blobStore.Move(ctx, op.OldKey, op.NewKey) if err != nil { - return err + if errors.Is(err, blob.ErrConflict) { + // somehow the node is not conflicting in vfs + // but is conflicting in the blob store + // this is a catatrophic error, so the whole operation + // is considered a failure + return nil, ErrNodeConflict + } + errs = append(errs, MoveFileError{Node: op.Node, Error: err}) } } } @@ -493,7 +577,11 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, node.ParentID = newParentID } - return nil + return &MoveFilesResult{ + Moved: movableNodes, + Conflicts: conflicts, + Errors: errs, + }, nil } func (vfs *VirtualFS) RealPath(ctx context.Context, db bun.IDB, node *Node) (Path, error) { @@ -503,6 +591,45 @@ func (vfs *VirtualFS) RealPath(ctx context.Context, db bun.IDB, node *Node) (Pat return buildNoteAbsolutePath(ctx, db, node) } +func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, nodes []*Node) error { + if len(nodes) == 0 { + return nil + } + + for _, n := range nodes { + if n.Kind != NodeKindFile { + return ErrUnsupportedOperation + } + } + + deletedIDs := make([]uuid.UUID, 0, len(nodes)) + for _, n := range nodes { + err := vfs.permanentlyDeleteFileNode(ctx, db, n) + if err != nil { + if errors.Is(err, blob.ErrNotFound) { + // no op if the blob does not exist + continue + } + return err + } else { + deletedIDs = append(deletedIDs, n.ID) + } + } + + if len(deletedIDs) == 0 { + return nil + } + + _, err := db.NewDelete().Model((*Node)(nil)). + Where("id IN (?)", bun.In(deletedIDs)). + Exec(ctx) + if err != nil { + return err + } + + return nil +} + func (vfs *VirtualFS) PermanentlyDeleteNode(ctx context.Context, db bun.IDB, node *Node) error { switch node.Kind { case NodeKindFile: @@ -522,6 +649,10 @@ func (vfs *VirtualFS) permanentlyDeleteFileNode(ctx context.Context, db bun.IDB, err = vfs.blobStore.Delete(ctx, key) if err != nil { + if errors.Is(err, blob.ErrNotFound) { + // no op if the blob does not exist + return nil + } return err } diff --git a/apps/drive-web/.env.sample b/apps/drive-web/.env.sample index 837fc12..4823297 100644 --- a/apps/drive-web/.env.sample +++ b/apps/drive-web/.env.sample @@ -3,4 +3,5 @@ VITE_CONVEX_URL= # this is the convex url for invoking http actions VITE_CONVEX_SITE_URL= # this is the url to the file proxy -FILE_PROXY_URL= \ No newline at end of file +FILE_PROXY_URL= +API_URL= \ No newline at end of file diff --git a/apps/drive-web/package.json b/apps/drive-web/package.json index abf6d55..d5c6811 100644 --- a/apps/drive-web/package.json +++ b/apps/drive-web/package.json @@ -25,6 +25,7 @@ "@tanstack/react-router": "^1.131.41", "@tanstack/react-table": "^8.21.3", "@tanstack/router-devtools": "^1.131.42", + "arktype": "^2.1.28", "better-auth": "1.3.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/drive-web/src/account/account.ts b/apps/drive-web/src/account/account.ts new file mode 100644 index 0000000..2ee8b9e --- /dev/null +++ b/apps/drive-web/src/account/account.ts @@ -0,0 +1,14 @@ +import { type } from "arktype" +import { atom } from "jotai" + +export const Account = type({ + id: "string", + userId: "string", + createdAt: "string.date.iso.parse", + updatedAt: "string.date.iso.parse", + storageUsageBytes: "number", + storageQuotaBytes: "number", +}) +export type Account = typeof Account.infer + +export const currentAccountAtom = atom(null) diff --git a/apps/drive-web/src/account/api.ts b/apps/drive-web/src/account/api.ts new file mode 100644 index 0000000..33c4c25 --- /dev/null +++ b/apps/drive-web/src/account/api.ts @@ -0,0 +1,11 @@ +import { queryOptions } from "@tanstack/react-query" +import { fetchApi } from "@/lib/api" +import { Account } from "./account" + +export const accountsQuery = queryOptions({ + queryKey: ["accounts"], + queryFn: async () => + fetchApi("GET", "/accounts", { + returns: Account.array(), + }).then(([_, result]) => result), +}) diff --git a/apps/drive-web/src/auth/api.ts b/apps/drive-web/src/auth/api.ts new file mode 100644 index 0000000..1b4bb74 --- /dev/null +++ b/apps/drive-web/src/auth/api.ts @@ -0,0 +1,27 @@ +import { mutationOptions } from "@tanstack/react-query" +import { type } from "arktype" +import { accountsQuery } from "../account/api" +import { fetchApi } from "../lib/api" +import { currentUserQuery } from "../user/api" +import { User } from "../user/user" + +const LoginResponseSchema = type({ + user: User, +}) + +export const loginMutation = mutationOptions({ + mutationFn: async (data: { email: string; password: string }) => { + const [_, result] = await fetchApi("POST", "/auth/login", { + body: JSON.stringify({ + ...data, + tokenDelivery: "cookie", + }), + returns: LoginResponseSchema, + }) + return result + }, + onSuccess: (data, _, __, context) => { + context.client.setQueryData(currentUserQuery.queryKey, data.user) + context.client.invalidateQueries(accountsQuery) + }, +}) diff --git a/apps/drive-web/src/dashboard/dashboard-sidebar.tsx b/apps/drive-web/src/dashboard/dashboard-sidebar.tsx index 42c695e..dc1fe87 100644 --- a/apps/drive-web/src/dashboard/dashboard-sidebar.tsx +++ b/apps/drive-web/src/dashboard/dashboard-sidebar.tsx @@ -1,12 +1,6 @@ -import { api } from "@fileone/convex/api" -import { newDirectoryHandle } from "@fileone/convex/filesystem" -import { useMutation } from "@tanstack/react-query" +import { useMutation, useQuery } from "@tanstack/react-query" import { Link, useLocation, useParams } from "@tanstack/react-router" -import { - useMutation as useConvexMutation, - useQuery as useConvexQuery, -} from "convex/react" -import { useAtomValue, useSetAtom, useStore } from "jotai" +import { useAtom, useAtomValue, useSetAtom } from "jotai" import { CircleXIcon, ClockIcon, @@ -37,9 +31,13 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar" import { formatError } from "@/lib/error" +import { + moveDirectoryItemsMutationAtom, + rootDirectoryQueryAtom, +} from "@/vfs/api" import { Button } from "../components/ui/button" import { LoadingSpinner } from "../components/ui/loading-spinner" -import { clearCutItemsAtom, cutHandlesAtom } from "../files/store" +import { clearCutItemsAtom, cutItemsAtom } from "../files/store" import { backgroundTaskProgressAtom } from "./state" export function DashboardSidebar() { @@ -95,7 +93,9 @@ function MainSidebarMenu() { function AllFilesItem() { const location = useLocation() - const rootDirectory = useConvexQuery(api.files.fetchRootDirectory) + const { data: rootDirectory } = useQuery( + useAtomValue(rootDirectoryQueryAtom), + ) if (!rootDirectory) return null @@ -105,7 +105,7 @@ function AllFilesItem() { asChild isActive={location.pathname.startsWith("/directories")} > - + All Files @@ -116,7 +116,9 @@ function AllFilesItem() { function TrashItem() { const location = useLocation() - const rootDirectory = useConvexQuery(api.files.fetchRootDirectory) + const { data: rootDirectory } = useQuery( + useAtomValue(rootDirectoryQueryAtom), + ) if (!rootDirectory) return null @@ -126,7 +128,7 @@ function TrashItem() { asChild isActive={location.pathname.startsWith("/trash/directories")} > - + Trash @@ -154,26 +156,26 @@ function BackgroundTaskProgressItem() { */ function CutItemsCard() { const { directoryId } = useParams({ strict: false }) - const cutHandles = useAtomValue(cutHandlesAtom) + const [cutItems, setCutItems] = useAtom(cutItemsAtom) const clearCutItems = useSetAtom(clearCutItemsAtom) - const setCutHandles = useSetAtom(cutHandlesAtom) const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom) - const store = useStore() - const _moveItems = useConvexMutation(api.filesystem.moveItems) + const moveDirectoryItemsMutation = useAtomValue( + moveDirectoryItemsMutationAtom, + ) + const { mutate: moveItems } = useMutation({ - mutationFn: _moveItems, + ...moveDirectoryItemsMutation, onMutate: () => { setBackgroundTaskProgress({ label: "Moving items…", }) - const cutHandles = store.get(cutHandlesAtom) clearCutItems() - return { cutHandles } + return { cutItems } }, onError: (error, _variables, context) => { - if (context?.cutHandles) { - setCutHandles(context.cutHandles) + if (context?.cutItems) { + setCutItems(context.cutItems) } toast.error("Failed to move items", { description: formatError(error), @@ -187,13 +189,13 @@ function CutItemsCard() { }, }) - if (cutHandles.length === 0) return null + if (cutItems.length === 0) return null const moveCutItems = () => { if (directoryId) { moveItems({ - targetDirectory: newDirectoryHandle(directoryId), - items: cutHandles, + targetDirectory: directoryId, + items: cutItems, }) } } @@ -204,7 +206,7 @@ function CutItemsCard() {
- {cutHandles.length} Cut + {cutItems.length} Cut Items
diff --git a/apps/drive-web/src/directories/directory-page/context.ts b/apps/drive-web/src/directories/directory-page/context.ts index 6427b7d..2d78717 100644 --- a/apps/drive-web/src/directories/directory-page/context.ts +++ b/apps/drive-web/src/directories/directory-page/context.ts @@ -1,12 +1,9 @@ -import type { Doc } from "@fileone/convex/dataModel" -import type { FileSystemItem } from "@fileone/convex/filesystem" -import type { DirectoryInfo } from "@fileone/convex/types" import { createContext } from "react" +import type { DirectoryContent, DirectoryInfoWithPath } from "@/vfs/vfs" type DirectoryPageContextType = { - rootDirectory: Doc<"directories"> - directory: DirectoryInfo - directoryContent: FileSystemItem[] + directory: DirectoryInfoWithPath + directoryContent: DirectoryContent } export const DirectoryPageContext = createContext( diff --git a/apps/drive-web/src/directories/directory-page/directory-content-context-menu.tsx b/apps/drive-web/src/directories/directory-page/directory-content-context-menu.tsx deleted file mode 100644 index 8e99404..0000000 --- a/apps/drive-web/src/directories/directory-page/directory-content-context-menu.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { api } from "@fileone/convex/api" -import { newFileSystemHandle } from "@fileone/convex/filesystem" -import { useMutation } from "@tanstack/react-query" -import { useMutation as useContextMutation } from "convex/react" -import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai" -import { TextCursorInputIcon, TrashIcon } from "lucide-react" -import { toast } from "sonner" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { - contextMenuTargeItemsAtom, - itemBeingRenamedAtom, - optimisticDeletedItemsAtom, -} from "./state" - -export function DirectoryContentContextMenu({ - children, -}: { - children: React.ReactNode -}) { - const store = useStore() - const [target, setTarget] = useAtom(contextMenuTargeItemsAtom) - const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom) - const moveToTrashMutation = useContextMutation(api.filesystem.moveToTrash) - const { mutate: moveToTrash } = useMutation({ - mutationFn: moveToTrashMutation, - onMutate: ({ handles }) => { - setOptimisticDeletedItems( - (prev) => - new Set([...prev, ...handles.map((handle) => handle.id)]), - ) - }, - onSuccess: ({ deleted, errors }, { handles }) => { - setOptimisticDeletedItems((prev) => { - const newSet = new Set(prev) - for (const handle of handles) { - newSet.delete(handle.id) - } - return newSet - }) - if (errors.length === 0 && deleted.length === handles.length) { - toast.success(`Moved ${handles.length} items to trash`) - } else if (errors.length === handles.length) { - toast.error("Failed to move to trash") - } else { - toast.info( - `Moved ${deleted.length} items to trash; failed to move ${errors.length} items`, - ) - } - }, - }) - - const handleDelete = () => { - const selectedItems = store.get(contextMenuTargeItemsAtom) - if (selectedItems.length > 0) { - moveToTrash({ - handles: selectedItems.map(newFileSystemHandle), - }) - } - } - - return ( - { - if (!open) { - setTarget([]) - } - }} - > - {children} - {target && ( - - - - - Move to trash - - - )} - - ) -} - -function RenameMenuItem() { - const store = useStore() - const target = useAtomValue(contextMenuTargeItemsAtom) - const setItemBeingRenamed = useSetAtom(itemBeingRenamedAtom) - - const handleRename = () => { - const selectedItems = store.get(contextMenuTargeItemsAtom) - if (selectedItems.length === 1) { - // biome-ignore lint/style/noNonNullAssertion: length is checked - const selectedItem = selectedItems[0]! - setItemBeingRenamed({ - originalItem: selectedItem, - name: selectedItem.doc.name, - }) - } - } - - // Only render if exactly one item is selected - if (target.length !== 1) { - return null - } - - return ( - - - Rename - - ) -} diff --git a/apps/drive-web/src/directories/directory-page/directory-content-table.tsx b/apps/drive-web/src/directories/directory-page/directory-content-table.tsx index 23f8462..84cecc7 100644 --- a/apps/drive-web/src/directories/directory-page/directory-content-table.tsx +++ b/apps/drive-web/src/directories/directory-page/directory-content-table.tsx @@ -1,15 +1,3 @@ -import type { Doc } from "@fileone/convex/dataModel" -import { - type DirectoryHandle, - type FileHandle, - type FileSystemHandle, - type FileSystemItem, - FileType, - isSameHandle, - newDirectoryHandle, - newFileHandle, - newFileSystemHandle, -} from "@fileone/convex/filesystem" import { Link, useNavigate } from "@tanstack/react-router" import { type ColumnDef, @@ -23,6 +11,7 @@ import { import { type PrimitiveAtom, useSetAtom, useStore } from "jotai" import { useContext, useEffect, useMemo, useRef } from "react" import { DirectoryIcon } from "@/components/icons/directory-icon" +import { TextFileIcon } from "@/components/icons/text-file-icon" import { Checkbox } from "@/components/ui/checkbox" import { Table, @@ -32,26 +21,26 @@ import { TableHeader, TableRow, } from "@/components/ui/table" +import { type FileDragInfo, useFileDrop } from "@/files/use-file-drop" import { isControlOrCommandKeyActive, keyboardModifierAtom, } from "@/lib/keyboard" -import { TextFileIcon } from "../../components/icons/text-file-icon" -import { type FileDragInfo, useFileDrop } from "../../files/use-file-drop" -import { cn } from "../../lib/utils" +import { cn } from "@/lib/utils" +import type { DirectoryInfo, DirectoryItem, FileInfo } from "@/vfs/vfs" import { DirectoryPageContext } from "./context" -type DirectoryContentTableItemIdFilter = Set +type DirectoryContentTableItemIdFilter = Set type DirectoryContentTableProps = { hiddenItems: DirectoryContentTableItemIdFilter - directoryUrlFn: (directory: Doc<"directories">) => string + directoryUrlFn: (directory: DirectoryInfo) => string fileDragInfoAtom: PrimitiveAtom onContextMenu: ( - row: Row, - table: TableType, + row: Row, + table: TableType, ) => void - onOpenFile: (file: Doc<"files">) => void + onOpenFile: (file: FileInfo) => void } function formatFileSize(bytes: number): string { @@ -65,9 +54,9 @@ function formatFileSize(bytes: number): string { } function useTableColumns( - onOpenFile: (file: Doc<"files">) => void, - directoryUrlFn: (directory: Doc<"directories">) => string, -): ColumnDef[] { + onOpenFile: (file: FileInfo) => void, + directoryUrlFn: (directory: DirectoryInfo) => string, +): ColumnDef[] { return useMemo( () => [ { @@ -100,17 +89,17 @@ function useTableColumns( accessorKey: "doc.name", cell: ({ row }) => { switch (row.original.kind) { - case FileType.File: + case "file": return ( ) - case FileType.Directory: + case "directory": return ( ) @@ -123,13 +112,11 @@ function useTableColumns( accessorKey: "size", cell: ({ row }) => { switch (row.original.kind) { - case FileType.File: + case "file": return ( -
- {formatFileSize(row.original.doc.size)} -
+
{formatFileSize(row.original.size)}
) - case FileType.Directory: + case "directory": return
-
} }, @@ -140,9 +127,7 @@ function useTableColumns( cell: ({ row }) => { return (
- {new Date( - row.original.doc.createdAt, - ).toLocaleString()} + {new Date(row.original.createdAt).toLocaleString()}
) }, @@ -178,8 +163,8 @@ export function DirectoryContentTable({ _columnId, filterValue: DirectoryContentTableItemIdFilter, _addMeta, - ) => !filterValue.has(row.original.doc._id), - getRowId: (row) => row.doc._id, + ) => !filterValue.has(row.original.id), + getRowId: (row) => row.id, }) useEffect( @@ -196,7 +181,7 @@ export function DirectoryContentTable({ ) const handleRowContextMenu = ( - row: Row, + row: Row, _event: React.MouseEvent, ) => { if (!row.getIsSelected()) { @@ -205,7 +190,7 @@ export function DirectoryContentTable({ onContextMenu(row, table) } - const selectRow = (row: Row) => { + const selectRow = (row: Row) => { const keyboardModifiers = store.get(keyboardModifierAtom) const isMultiSelectMode = isControlOrCommandKeyActive(keyboardModifiers) const isRowSelected = row.getIsSelected() @@ -227,10 +212,10 @@ export function DirectoryContentTable({ } } - const handleRowDoubleClick = (row: Row) => { - if (row.original.kind === FileType.Directory) { + const handleRowDoubleClick = (row: Row) => { + if (row.original.kind === "directory") { navigate({ - to: `/directories/${row.original.doc._id}`, + to: `/directories/${row.original.id}`, }) } } @@ -302,8 +287,8 @@ function FileItemRow({ onDoubleClick, fileDragInfoAtom, }: { - table: TableType - row: Row + table: TableType + row: Row onClick: () => void onContextMenu: (e: React.MouseEvent) => void onDoubleClick: () => void @@ -313,39 +298,24 @@ function FileItemRow({ const setFileDragInfo = useSetAtom(fileDragInfoAtom) const { isDraggedOver, dropHandlers } = useFileDrop({ - destItem: - row.original.kind === FileType.Directory - ? newDirectoryHandle(row.original.doc._id) - : null, + destDir: row.original, dragInfoAtom: fileDragInfoAtom, }) const handleDragStart = (_e: React.DragEvent) => { - let source: DirectoryHandle | FileHandle - switch (row.original.kind) { - case FileType.File: - source = newFileHandle(row.original.doc._id) - break - case FileType.Directory: - source = newDirectoryHandle(row.original.doc._id) - break - } - - let draggedItems: FileSystemHandle[] + let draggedItems: DirectoryItem[] // drag all selections, but only if the currently dragged row is also selected if (row.getIsSelected()) { - draggedItems = table - .getSelectedRowModel() - .rows.map((row) => newFileSystemHandle(row.original)) - if (!draggedItems.some((item) => isSameHandle(item, source))) { - draggedItems.push(source) + draggedItems = [...table.getSelectedRowModel().rows] + if (!draggedItems.some((item) => item.id === row.original.id)) { + draggedItems.push(row.original) } } else { - draggedItems = [source] + draggedItems = [row.original] } setFileDragInfo({ - source, + source: row.original, items: draggedItems, }) } @@ -385,8 +355,8 @@ function DirectoryNameCell({ directory, directoryUrlFn, }: { - directory: Doc<"directories"> - directoryUrlFn: (directory: Doc<"directories">) => string + directory: DirectoryInfo + directoryUrlFn: (directory: DirectoryInfo) => string }) { return (
@@ -402,8 +372,8 @@ function FileNameCell({ file, onOpenFile, }: { - file: Doc<"files"> - onOpenFile: (file: Doc<"files">) => void + file: FileInfo + onOpenFile: (file: FileInfo) => void }) { return (
diff --git a/apps/drive-web/src/directories/directory-page/new-directory-dialog.tsx b/apps/drive-web/src/directories/directory-page/new-directory-dialog.tsx index 1a89514..3304972 100644 --- a/apps/drive-web/src/directories/directory-page/new-directory-dialog.tsx +++ b/apps/drive-web/src/directories/directory-page/new-directory-dialog.tsx @@ -1,7 +1,5 @@ -import { api } from "@fileone/convex/api" -import type { Id } from "@fileone/convex/dataModel" import { useMutation } from "@tanstack/react-query" -import { useMutation as useContextMutation } from "convex/react" +import { useAtomValue } from "jotai" import { useId } from "react" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -14,21 +12,26 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" +import { createDirectoryMutationAtom } from "@/vfs/api" +import type { DirectoryInfo } from "@/vfs/vfs" export function NewDirectoryDialog({ open, onOpenChange, - directoryId, + parentDirectory, }: { open: boolean onOpenChange: (open: boolean) => void - directoryId: Id<"directories"> + parentDirectory: DirectoryInfo }) { const formId = useId() + const createDirectoryMutation = useAtomValue(createDirectoryMutationAtom) + const { mutate: createDirectory, isPending: isCreating } = useMutation({ - mutationFn: useContextMutation(api.files.createDirectory), - onSuccess: () => { + ...createDirectoryMutation, + onSuccess: (data, vars, result, context) => { + createDirectoryMutation.onSuccess?.(data, vars, result, context) onOpenChange(false) toast.success("Directory created successfully") }, @@ -41,7 +44,7 @@ export function NewDirectoryDialog({ const name = formData.get("directoryName") as string if (name) { - createDirectory({ name, directoryId }) + createDirectory({ name, parentId: parentDirectory.id }) } } diff --git a/apps/drive-web/src/directories/directory-page/rename-file-dialog.tsx b/apps/drive-web/src/directories/directory-page/rename-file-dialog.tsx index 1811cb2..43daf6b 100644 --- a/apps/drive-web/src/directories/directory-page/rename-file-dialog.tsx +++ b/apps/drive-web/src/directories/directory-page/rename-file-dialog.tsx @@ -1,7 +1,5 @@ -import { api } from "@fileone/convex/api" -import { type FileSystemItem, FileType } from "@fileone/convex/filesystem" import { useMutation } from "@tanstack/react-query" -import { useMutation as useContextMutation } from "convex/react" +import { useAtomValue } from "jotai" import { useId } from "react" import { Button } from "@/components/ui/button" import { @@ -13,9 +11,11 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" +import { renameDirectoryMutationAtom, renameFileMutationAtom } from "@/vfs/api" +import type { DirectoryItem } from "@/vfs/vfs" type RenameFileDialogProps = { - item: FileSystemItem + item: DirectoryItem onRenameSuccess: () => void onClose: () => void } @@ -27,13 +27,22 @@ export function RenameFileDialog({ }: RenameFileDialogProps) { const formId = useId() - const { mutate: renameFile, isPending: isRenaming } = useMutation({ - mutationFn: useContextMutation(api.files.renameFile), - onSuccess: () => { - onRenameSuccess() - }, + const renameFileMutation = useAtomValue(renameFileMutationAtom) + const renameDirectoryMutation = useAtomValue(renameDirectoryMutationAtom) + + const { mutate: renameFile, isPending: isRenamingFile } = useMutation({ + ...renameFileMutation, + onSuccess: onRenameSuccess, }) + const { mutate: renameDirectory, isPending: isRenamingDirectory } = + useMutation({ + ...renameDirectoryMutation, + onSuccess: onRenameSuccess, + }) + + const isRenaming = isRenamingFile || isRenamingDirectory + const onSubmit = (event: React.FormEvent) => { event.preventDefault() @@ -42,14 +51,11 @@ export function RenameFileDialog({ if (newName) { switch (item.kind) { - case FileType.File: - renameFile({ - directoryId: item.doc.directoryId, - itemId: item.doc._id, - newName, - }) + case "file": + renameFile(item) break - default: + case "directory": + renameDirectory(item) break } } @@ -70,7 +76,7 @@ export function RenameFileDialog({
- + diff --git a/apps/drive-web/src/directories/directory-page/state.ts b/apps/drive-web/src/directories/directory-page/state.ts deleted file mode 100644 index 2eb92e6..0000000 --- a/apps/drive-web/src/directories/directory-page/state.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Doc, Id } from "@fileone/convex/dataModel" -import type { FileSystemItem } from "@fileone/convex/filesystem" -import type { RowSelectionState } from "@tanstack/react-table" -import { atom } from "jotai" -import type { FileDragInfo } from "../../files/use-file-drop" - -export const contextMenuTargeItemsAtom = atom([]) -export const optimisticDeletedItemsAtom = atom( - new Set | Id<"directories">>(), -) - -export const selectedFileRowsAtom = atom({}) - -export const itemBeingRenamedAtom = atom<{ - originalItem: FileSystemItem - name: string -} | null>(null) - -export const openedFileAtom = atom | null>(null) - -export const dragInfoAtom = atom(null) diff --git a/apps/drive-web/src/directories/directory-path-breadcrumb.tsx b/apps/drive-web/src/directories/directory-path-breadcrumb.tsx index b327636..ff3a118 100644 --- a/apps/drive-web/src/directories/directory-path-breadcrumb.tsx +++ b/apps/drive-web/src/directories/directory-path-breadcrumb.tsx @@ -1,9 +1,3 @@ -import type { Id } from "@fileone/convex/dataModel" -import type { - DirectoryHandle, - DirectoryPathComponent, -} from "@fileone/convex/filesystem" -import type { DirectoryInfo } from "@fileone/convex/types" import { Link } from "@tanstack/react-router" import type { PrimitiveAtom } from "jotai" import { atom } from "jotai" @@ -24,6 +18,8 @@ import { import type { FileDragInfo } from "@/files/use-file-drop" import { useFileDrop } from "@/files/use-file-drop" import { cn } from "@/lib/utils" +import type { DirectoryInfoWithPath } from "@/vfs/vfs" +import type { PathSegment } from "../lib/path" /** * This is a placeholder file drag info atom that always stores null and is never mutated. @@ -36,15 +32,28 @@ export function DirectoryPathBreadcrumb({ directoryUrlFn, fileDragInfoAtom = nullFileDragInfoAtom, }: { - directory: DirectoryInfo + directory: DirectoryInfoWithPath rootLabel: string - directoryUrlFn: (directory: Id<"directories">) => string + directoryUrlFn: (directoryId: string) => string fileDragInfoAtom?: PrimitiveAtom }) { + if (directory.path.length === 1) { + return ( + + + + {rootLabel} + + + + + ) + } + const breadcrumbItems: React.ReactNode[] = [ + ) => string + directoryUrlFn: (directoryId: string) => string fileDragInfoAtom: PrimitiveAtom }) { const { isDraggedOver, dropHandlers } = useFileDrop({ - destItem: component.handle as DirectoryHandle, + destDir: segment.id, dragInfoAtom: fileDragInfoAtom, }) - const dirName = component.name || rootLabel + const dirName = segment.name || rootLabel return ( @@ -103,9 +112,7 @@ function FilePathBreadcrumbItem({ {...dropHandlers} > - - {dirName} - + {dirName} diff --git a/apps/drive-web/src/files/PickedFileItem.tsx b/apps/drive-web/src/files/PickedFileItem.tsx index 1e5ecdd..2bb6951 100644 --- a/apps/drive-web/src/files/PickedFileItem.tsx +++ b/apps/drive-web/src/files/PickedFileItem.tsx @@ -3,7 +3,7 @@ import { CircleAlertIcon, XIcon } from "lucide-react" import type React from "react" import { Button } from "@/components/ui/button" import { Progress } from "@/components/ui/progress" -import { Tooltip } from "@/components/ui/tooltip" +import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip" import { FileUploadStatusKind, fileUploadStatusAtomFamily } from "./store" import type { PickedFile } from "./upload-file-dialog" @@ -16,7 +16,6 @@ export function PickedFileItem({ }) { const fileUploadAtom = fileUploadStatusAtomFamily(pickedFile.id) const fileUpload = useAtomValue(fileUploadAtom) - console.log("fileUpload", fileUpload) const { file, id } = pickedFile let statusIndicator: React.ReactNode @@ -52,20 +51,7 @@ export function PickedFileItem({ key={id} > {file.name} - {fileUpload ? ( - - ) : ( - - )} + {statusIndicator} ) } diff --git a/apps/drive-web/src/files/file-grid.tsx b/apps/drive-web/src/files/file-grid.tsx index 81fc0ce..a75dc88 100644 --- a/apps/drive-web/src/files/file-grid.tsx +++ b/apps/drive-web/src/files/file-grid.tsx @@ -1,10 +1,10 @@ -import type { Doc, Id } from "@fileone/convex/dataModel" import { memo, useCallback } from "react" import { TextFileIcon } from "@/components/icons/text-file-icon" import { MiddleTruncatedText } from "@/components/ui/middle-truncated-text" import { cn } from "@/lib/utils" +import type { FileInfo } from "./file" -export type FileGridSelection = Set> +export type FileGridSelection = Set export function FileGrid({ files, @@ -12,22 +12,22 @@ export function FileGrid({ onSelectionChange, onContextMenu, }: { - files: Doc<"files">[] + files: FileInfo[] selectedFiles?: FileGridSelection onSelectionChange?: (selection: FileGridSelection) => void - onContextMenu?: (file: Doc<"files">, event: React.MouseEvent) => void + onContextMenu?: (file: FileInfo, event: React.MouseEvent) => void }) { const onItemSelect = useCallback( - (file: Doc<"files">) => { - onSelectionChange?.(new Set([file._id])) + (file: FileInfo) => { + onSelectionChange?.(new Set([file.id])) }, [onSelectionChange], ) const onItemContextMenu = useCallback( - (file: Doc<"files">, event: React.MouseEvent) => { + (file: FileInfo, event: React.MouseEvent) => { onContextMenu?.(file, event) - onSelectionChange?.(new Set([file._id])) + onSelectionChange?.(new Set([file.id])) }, [onContextMenu, onSelectionChange], ) @@ -36,8 +36,8 @@ export function FileGrid({
{files.map((file) => ( - onSelect?: (file: Doc<"files">) => void - onContextMenu?: (file: Doc<"files">, event: React.MouseEvent) => void + file: FileInfo + onSelect?: (file: FileInfo) => void + onContextMenu?: (file: FileInfo, event: React.MouseEvent) => void }) { return ( - - - ) : null} - - - ) -} - -function DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) { - return ( -
- - - {directory.name} - -
- ) -} - -function FileNameCell({ initialName }: { initialName: string }) { - return ( -
- - {initialName} -
- ) -} diff --git a/apps/drive-web/src/files/files-page.tsx b/apps/drive-web/src/files/files-page.tsx deleted file mode 100644 index a457fc8..0000000 --- a/apps/drive-web/src/files/files-page.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { api } from "@fileone/convex/api" -import { baseName, splitPath } from "@fileone/path" -import { useMutation } from "@tanstack/react-query" -import { Link } from "@tanstack/react-router" -import { useMutation as useConvexMutation } from "convex/react" -import { useSetAtom } from "jotai" -import { - ChevronDownIcon, - Loader2Icon, - PlusIcon, - UploadCloudIcon, -} from "lucide-react" -import { type ChangeEvent, Fragment, useRef } from "react" -import { toast } from "sonner" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { DirectoryIcon } from "../components/icons/directory-icon" -import { TextFileIcon } from "../components/icons/text-file-icon" -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "../components/ui/breadcrumb" -import { Button } from "../components/ui/button" -import { FileTable } from "./file-table" -import { RenameFileDialog } from "./rename-file-dialog" -import { newItemKindAtom } from "./state" - -export function FilesPage({ path }: { path: string }) { - return ( - <> -
- -
- - -
-
-
- -
- - - ) -} - -function FilePathBreadcrumb({ path }: { path: string }) { - const pathComponents = splitPath(path) - const base = baseName(path) - return ( - - - - - All Files - - - {pathComponents.map((p) => ( - - - {p === base ? ( - {p} - ) : ( - - - {p} - - - )} - - ))} - - - ) -} - -// tags: upload, uploadfile, uploadfilebutton, fileupload, fileuploadbutton -function UploadFileButton() { - const generateUploadUrl = useConvexMutation(api.files.generateUploadUrl) - const saveFile = useConvexMutation(api.files.saveFile) - const { mutate: uploadFile, isPending: isUploading } = useMutation({ - mutationFn: async (file: File) => { - const uploadUrl = await generateUploadUrl() - const uploadResult = await fetch(uploadUrl, { - method: "POST", - body: file, - headers: { - "Content-Type": file.type, - }, - }) - const { storageId } = await uploadResult.json() - - await saveFile({ - storageId, - name: file.name, - size: file.size, - mimeType: file.type, - }) - }, - onSuccess: () => { - toast.success("File uploaded successfully.") - }, - }) - - const fileInputRef = useRef(null) - - const handleClick = () => { - fileInputRef.current?.click() - } - - const onFileUpload = async (e: ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - uploadFile(file) - } - } - - return ( - <> - - - - ) -} - -function NewDirectoryItemDropdown() { - const setNewItemKind = useSetAtom(newItemKindAtom) - - const addNewDirectory = () => { - setNewItemKind("directory") - } - - return ( - - - - - - - - Text file - - - - Directory - - - - ) -} diff --git a/apps/drive-web/src/files/image-preview-dialog.tsx b/apps/drive-web/src/files/image-preview-dialog.tsx index dfe89b0..4f4a09c 100644 --- a/apps/drive-web/src/files/image-preview-dialog.tsx +++ b/apps/drive-web/src/files/image-preview-dialog.tsx @@ -1,4 +1,3 @@ -import type { OpenedFile } from "@fileone/convex/filesystem" import { DialogTitle } from "@radix-ui/react-dialog" import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" import { @@ -17,7 +16,8 @@ import { DialogContent, DialogHeader, } from "@/components/ui/dialog" -import { fileShareUrl } from "./file-share" +import { useFileUrl } from "@/vfs/hooks" +import type { FileInfo } from "@/vfs/vfs" const zoomLevelAtom = atom( 1, @@ -35,7 +35,7 @@ export function ImagePreviewDialog({ openedFile, onClose, }: { - openedFile: OpenedFile + openedFile: FileInfo onClose: () => void }) { const setZoomLevel = useSetAtom(zoomLevelAtom) @@ -61,7 +61,7 @@ export function ImagePreviewDialog({ ) } -function PreviewContent({ openedFile }: { openedFile: OpenedFile }) { +function PreviewContent({ openedFile }: { openedFile: FileInfo }) { return ( - {openedFile.file.name} + {openedFile.name}
- +
- +
) } -function Toolbar({ openedFile }: { openedFile: OpenedFile }) { +function Toolbar({ file }: { file: FileInfo }) { const setZoomLevel = useSetAtom(zoomLevelAtom) const zoomInterval = useRef | null>(null) + const fileUrl = useFileUrl(file) useEffect( () => () => { @@ -142,8 +143,8 @@ function Toolbar({ openedFile }: { openedFile: OpenedFile }) { - - - - - - ) -} - -function RenameFileInput() { - const [fileName, setFileName] = useAtom(fielNameAtom) - return ( - setFileName(e.target.value)} - /> - ) -} diff --git a/apps/drive-web/src/files/state.ts b/apps/drive-web/src/files/state.ts deleted file mode 100644 index af510bd..0000000 --- a/apps/drive-web/src/files/state.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Id } from "@fileone/convex/dataModel" -import type { DirectoryItem, DirectoryItemKind } from "@fileone/convex/types" -import type { RowSelectionState } from "@tanstack/react-table" -import { atom } from "jotai" - -export const contextMenuTargeItemAtom = atom(null) -export const optimisticDeletedItemsAtom = atom( - new Set | Id<"directories">>(), -) - -export const selectedFileRowsAtom = atom({}) - -export const newItemKindAtom = atom(null) - -export const itemBeingRenamedAtom = atom<{ - kind: DirectoryItemKind - originalItem: DirectoryItem - name: string -} | null>(null) diff --git a/apps/drive-web/src/files/store.ts b/apps/drive-web/src/files/store.ts index f524989..445441c 100644 --- a/apps/drive-web/src/files/store.ts +++ b/apps/drive-web/src/files/store.ts @@ -1,6 +1,6 @@ -import type { FileSystemHandle } from "@fileone/convex/filesystem" import { atom } from "jotai" import { atomFamily } from "jotai/utils" +import type { DirectoryItem } from "@/vfs/vfs" export enum FileUploadStatusKind { InProgress = "InProgress", @@ -94,7 +94,7 @@ export const hasFileUploadsErrorAtom = atom((get) => { return false }) -export const cutHandlesAtom = atom([]) +export const cutItemsAtom = atom([]) export const clearCutItemsAtom = atom(null, (_, set) => { - set(cutHandlesAtom, []) + set(cutItemsAtom, []) }) diff --git a/apps/drive-web/src/files/upload-file-dialog.tsx b/apps/drive-web/src/files/upload-file-dialog.tsx index 25078b4..69a1e45 100644 --- a/apps/drive-web/src/files/upload-file-dialog.tsx +++ b/apps/drive-web/src/files/upload-file-dialog.tsx @@ -1,4 +1,3 @@ -import type { Doc } from "@fileone/convex/dataModel" import { mutationOptions } from "@tanstack/react-query" import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai" import { atomEffect } from "jotai-effect" @@ -29,7 +28,9 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" +import type { DirectoryInfoWithPath } from "@/vfs/vfs" import { formatError } from "@/lib/error" +import { currentAccountAtom } from "../account/account" import { clearAllFileUploadStatusesAtom, clearFileUploadStatusesAtom, @@ -40,10 +41,10 @@ import { hasFileUploadsErrorAtom, successfulFileUploadCountAtom, } from "./store" -import useUploadFile from "./use-upload-file" +import { uploadFile } from "./upload" type UploadFileDialogProps = { - targetDirectory: Doc<"directories"> + targetDirectory: DirectoryInfoWithPath onClose: () => void } @@ -58,17 +59,22 @@ export const pickedFilesAtom = atom([]) function useUploadFilesAtom({ targetDirectory, }: { - targetDirectory: Doc<"directories"> + targetDirectory: DirectoryInfoWithPath }) { - const uploadFile = useUploadFile({ targetDirectory }) const store = useStore() + const options = useMemo( () => mutationOptions({ mutationFn: async (files: PickedFile[]) => { + const account = store.get(currentAccountAtom) + if (!account) throw new Error("No account selected") + const promises = files.map((pickedFile) => uploadFile({ + account, file: pickedFile.file, + targetDirectory, onStart: () => { store.set( fileUploadStatusAtomFamily(pickedFile.id), @@ -133,8 +139,9 @@ function useUploadFilesAtom({ toast.error(formatError(error)) }, }), - [uploadFile, store.set], + [store, targetDirectory], ) + return useMemo(() => atomWithMutation(() => options), [options]) } type UploadFilesAtom = ReturnType @@ -288,7 +295,7 @@ function UploadDialogHeader({ targetDirectory, }: { uploadFilesAtom: UploadFilesAtom - targetDirectory: Doc<"directories"> + targetDirectory: DirectoryInfoWithPath }) { const { data: uploadResults, isPending: isUploading } = useAtomValue(uploadFilesAtom) diff --git a/apps/drive-web/src/files/upload.ts b/apps/drive-web/src/files/upload.ts new file mode 100644 index 0000000..4fb55b0 --- /dev/null +++ b/apps/drive-web/src/files/upload.ts @@ -0,0 +1,90 @@ +import { type } from "arktype" +import type { Account } from "@/account/account" +import { ApiError, fetchApi } from "@/lib/api" +import type { DirectoryInfoWithPath } from "@/vfs/vfs" + +export const UploadStatus = type.enumerated("pending", "completed", "failed") +export type UploadStatus = typeof UploadStatus.infer + +export const Upload = type({ + id: "string", + status: UploadStatus, + uploadUrl: "string.url", + createdAt: "string.date.iso.parse", + updatedAt: "string.date.iso.parse", +}) +export type Upload = typeof Upload.infer + +export async function uploadFile({ + account, + file, + targetDirectory, + onStart, + onProgress, +}: { + account: Account + file: File + targetDirectory: DirectoryInfoWithPath + onStart: (xhr: XMLHttpRequest) => void + onProgress: (progress: number) => void +}) { + const [, upload] = await fetchApi( + "POST", + `/accounts/${account.id}/uploads`, + { + body: JSON.stringify({ + name: file.name, + parentId: targetDirectory.id, + }), + returns: Upload, + }, + ) + + await putFile({ + file, + uploadUrl: upload.uploadUrl, + onStart, + onProgress, + }) + + await fetchApi("PATCH", `/accounts/${account.id}/uploads/${upload.id}`, { + body: JSON.stringify({ + status: "completed", + }), + returns: Upload, + }) + + return upload +} + +function putFile({ + file, + uploadUrl, + onStart, + onProgress, +}: { + file: File + uploadUrl: string + onStart: (xhr: XMLHttpRequest) => void + onProgress: (progress: number) => void +}): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.upload.addEventListener("progress", (e) => { + onProgress(e.loaded / e.total) + }) + xhr.upload.addEventListener("error", reject) + xhr.addEventListener("load", () => { + if (xhr.status === 200 || xhr.status === 204) { + resolve() + } else { + reject(new ApiError(xhr.status, xhr.response)) + } + }) + xhr.open("PUT", uploadUrl) + xhr.responseType = "json" + xhr.setRequestHeader("Content-Type", file.type) + xhr.send(file) + onStart(xhr) + }) +} diff --git a/apps/drive-web/src/files/use-file-drop.ts b/apps/drive-web/src/files/use-file-drop.ts index 34c886f..62f6542 100644 --- a/apps/drive-web/src/files/use-file-drop.ts +++ b/apps/drive-web/src/files/use-file-drop.ts @@ -1,30 +1,22 @@ -import { api } from "@fileone/convex/api" -import type { Doc, Id } from "@fileone/convex/dataModel" -import * as Err from "@fileone/convex/error" -import { - type DirectoryHandle, - type FileSystemHandle, - isSameHandle, -} from "@fileone/convex/filesystem" import { useMutation } from "@tanstack/react-query" -import { useMutation as useContextMutation } from "convex/react" import type { PrimitiveAtom } from "jotai" -import { useSetAtom, useStore } from "jotai" +import { useAtomValue, useSetAtom, useStore } from "jotai" import { useState } from "react" import { toast } from "sonner" +import { + type MoveDirectoryItemsResult, + moveDirectoryItemsMutationAtom, +} from "@/vfs/api" +import type { DirectoryInfo, DirectoryItem } from "@/vfs/vfs" export interface FileDragInfo { - source: FileSystemHandle - items: FileSystemHandle[] + source: DirectoryItem + items: DirectoryItem[] } export interface UseFileDropOptions { - destItem: DirectoryHandle | null + destDir: DirectoryInfo | string dragInfoAtom: PrimitiveAtom - onDropSuccess?: ( - items: Id<"files">[], - targetDirectory: Doc<"directories">, - ) => void } export interface UseFileDropReturn { @@ -37,7 +29,7 @@ export interface UseFileDropReturn { } export function useFileDrop({ - destItem, + destDir, dragInfoAtom, }: UseFileDropOptions): UseFileDropReturn { const [isDraggedOver, setIsDraggedOver] = useState(false) @@ -45,39 +37,28 @@ export function useFileDrop({ const store = useStore() const { mutate: moveDroppedItems } = useMutation({ - mutationFn: useContextMutation(api.filesystem.moveItems), - onSuccess: ({ - moved, - errors, - }: { - moved: FileSystemHandle[] - errors: Err.ApplicationErrorData[] - }) => { - const conflictCount = errors.reduce((acc, error) => { - if (error.code === Err.ErrorCode.Conflict) { - return acc + 1 - } - return acc - }, 0) + ...useAtomValue(moveDirectoryItemsMutationAtom), + onSuccess: (result: MoveDirectoryItemsResult) => { + const conflictCount = result.conflicts.length if (conflictCount > 0) { toast.warning( - `${moved.length} items moved${conflictCount > 0 ? `, ${conflictCount} conflicts` : ""}`, + `${result.moved.length} items moved${conflictCount > 0 ? `, ${conflictCount} conflicts` : ""}`, ) } else { - toast.success(`${moved.length} items moved!`) + toast.success(`${result.moved.length} items moved!`) } }, }) + const dirId = typeof destDir === "string" ? destDir : destDir.id + const handleDrop = (_e: React.DragEvent) => { const dragInfo = store.get(dragInfoAtom) - if (dragInfo && destItem) { - const items = dragInfo.items.filter( - (item) => !isSameHandle(item, destItem), - ) + if (dragInfo) { + const items = dragInfo.items.filter((item) => item.id !== dirId) if (items.length > 0) { moveDroppedItems({ - targetDirectory: destItem, + targetDirectory: destDir, items, }) } @@ -88,7 +69,7 @@ export function useFileDrop({ const handleDragOver = (e: React.DragEvent) => { const dragInfo = store.get(dragInfoAtom) - if (dragInfo && destItem) { + if (dragInfo && destDir) { e.preventDefault() e.dataTransfer.dropEffect = "move" setIsDraggedOver(true) diff --git a/apps/drive-web/src/files/use-upload-file.ts b/apps/drive-web/src/files/use-upload-file.ts deleted file mode 100644 index 4cdf617..0000000 --- a/apps/drive-web/src/files/use-upload-file.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { api } from "@fileone/convex/api" -import type { Doc, Id } from "@fileone/convex/dataModel" -import { useMutation as useConvexMutation } from "convex/react" -import { useCallback } from "react" - -function useUploadFile({ - targetDirectory, -}: { - targetDirectory: Doc<"directories"> -}) { - const generateUploadUrl = useConvexMutation(api.files.generateUploadUrl) - const saveFile = useConvexMutation(api.filesystem.saveFile) - - async function upload({ - file, - onStart, - onProgress, - }: { - file: File - onStart: (xhr: XMLHttpRequest) => void - onProgress: (progress: number) => void - }) { - const uploadUrl = await generateUploadUrl() - - return new Promise<{ storageId: Id<"_storage"> }>((resolve, reject) => { - const xhr = new XMLHttpRequest() - xhr.upload.addEventListener("progress", (e) => { - onProgress(e.loaded / e.total) - }) - xhr.upload.addEventListener("error", reject) - xhr.addEventListener("load", () => { - resolve( - xhr.response as { - storageId: Id<"_storage"> - }, - ) - }) - xhr.open("POST", uploadUrl) - xhr.responseType = "json" - xhr.setRequestHeader("Content-Type", file.type) - xhr.send(file) - onStart(xhr) - }).then(({ storageId }) => - saveFile({ - storageId, - name: file.name, - directoryId: targetDirectory._id, - }), - ) - } - - return useCallback(upload, []) -} - -export default useUploadFile diff --git a/apps/drive-web/src/lib/api.ts b/apps/drive-web/src/lib/api.ts new file mode 100644 index 0000000..e8cc28b --- /dev/null +++ b/apps/drive-web/src/lib/api.ts @@ -0,0 +1,71 @@ +import { type } from "arktype" + +export type ApiRoute = + | "/auth/login" + | "/auth/tokens" + | "/accounts" + | `/accounts/${string}` + | `/accounts/${string}/uploads` + | `/accounts/${string}/uploads/${string}/content` + | `/accounts/${string}/uploads/${string}` + | `/accounts/${string}/files${string}` + | `/accounts/${string}/files/${string}` + | `/accounts/${string}/files/${string}/content` + | `/accounts/${string}/directories` + | `/accounts/${string}/directories/${string}` + | `/accounts/${string}/directories/${string}/content` + | "/users/me" + +export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" + +const baseApiUrl = new URL( + import.meta.env.VITE_API_URL ?? + `${location.protocol}//${location.host}/api`, +) + +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(`api returned ${status}: ${message}`) + } +} + +export const Nothing = type({}) +export type Nothing = typeof Nothing.infer + +export async function fetchApi( + method: HttpMethod, + route: ApiRoute, + init: RequestInit & { returns: Schema }, +): Promise<[response: Response, data: Schema["inferOut"]]> { + let path: string + if (baseApiUrl.pathname) { + if (baseApiUrl.pathname.endsWith("/")) { + path = `${baseApiUrl.pathname.slice(0, -1)}${route}` + } else { + path = `${baseApiUrl.pathname}${route}` + } + } else { + path = route + } + const url = new URL(path, baseApiUrl) + const response = await fetch(url, { + credentials: "include", + ...init, + method, + headers: { + "Content-Type": "application/json", + }, + }) + if (!response.ok) { + throw new ApiError(response.status, await response.text()) + } + const body = await response.json() + const result = init.returns(body) + if (result instanceof type.errors) { + throw result + } + return [response, result] +} diff --git a/apps/drive-web/src/lib/path.ts b/apps/drive-web/src/lib/path.ts new file mode 100644 index 0000000..05c167f --- /dev/null +++ b/apps/drive-web/src/lib/path.ts @@ -0,0 +1,10 @@ +import { type } from "arktype" + +export const PathSegment = type({ + name: "string", + id: "string", +}) +export type PathSegment = typeof PathSegment.infer + +export const Path = type([PathSegment]) +export type Path = typeof Path.infer diff --git a/apps/drive-web/src/routeTree.gen.ts b/apps/drive-web/src/routeTree.gen.ts index 6e2075f..1793be6 100644 --- a/apps/drive-web/src/routeTree.gen.ts +++ b/apps/drive-web/src/routeTree.gen.ts @@ -15,10 +15,8 @@ import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' import { Route as LoginCallbackRouteImport } from './routes/login_.callback' import { Route as AuthenticatedSidebarLayoutRouteImport } from './routes/_authenticated/_sidebar-layout' -import { Route as AuthenticatedSidebarLayoutRecentRouteImport } from './routes/_authenticated/_sidebar-layout/recent' import { Route as AuthenticatedSidebarLayoutHomeRouteImport } from './routes/_authenticated/_sidebar-layout/home' import { Route as AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/directories.$directoryId' -import { Route as AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/trash.directories.$directoryId' const SignUpRoute = SignUpRouteImport.update({ id: '/sign-up', @@ -49,12 +47,6 @@ const AuthenticatedSidebarLayoutRoute = id: '/_sidebar-layout', getParentRoute: () => AuthenticatedRoute, } as any) -const AuthenticatedSidebarLayoutRecentRoute = - AuthenticatedSidebarLayoutRecentRouteImport.update({ - id: '/recent', - path: '/recent', - getParentRoute: () => AuthenticatedSidebarLayoutRoute, - } as any) const AuthenticatedSidebarLayoutHomeRoute = AuthenticatedSidebarLayoutHomeRouteImport.update({ id: '/home', @@ -67,12 +59,6 @@ const AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute = path: '/directories/$directoryId', getParentRoute: () => AuthenticatedSidebarLayoutRoute, } as any) -const AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute = - AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRouteImport.update({ - id: '/trash/directories/$directoryId', - path: '/trash/directories/$directoryId', - getParentRoute: () => AuthenticatedSidebarLayoutRoute, - } as any) export interface FileRoutesByFullPath { '/login': typeof LoginRoute @@ -80,9 +66,7 @@ export interface FileRoutesByFullPath { '/login/callback': typeof LoginCallbackRoute '/': typeof AuthenticatedIndexRoute '/home': typeof AuthenticatedSidebarLayoutHomeRoute - '/recent': typeof AuthenticatedSidebarLayoutRecentRoute '/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute - '/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute @@ -90,9 +74,7 @@ export interface FileRoutesByTo { '/login/callback': typeof LoginCallbackRoute '/': typeof AuthenticatedIndexRoute '/home': typeof AuthenticatedSidebarLayoutHomeRoute - '/recent': typeof AuthenticatedSidebarLayoutRecentRoute '/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute - '/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -103,9 +85,7 @@ export interface FileRoutesById { '/login_/callback': typeof LoginCallbackRoute '/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/_sidebar-layout/home': typeof AuthenticatedSidebarLayoutHomeRoute - '/_authenticated/_sidebar-layout/recent': typeof AuthenticatedSidebarLayoutRecentRoute '/_authenticated/_sidebar-layout/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute - '/_authenticated/_sidebar-layout/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -115,9 +95,7 @@ export interface FileRouteTypes { | '/login/callback' | '/' | '/home' - | '/recent' | '/directories/$directoryId' - | '/trash/directories/$directoryId' fileRoutesByTo: FileRoutesByTo to: | '/login' @@ -125,9 +103,7 @@ export interface FileRouteTypes { | '/login/callback' | '/' | '/home' - | '/recent' | '/directories/$directoryId' - | '/trash/directories/$directoryId' id: | '__root__' | '/_authenticated' @@ -137,9 +113,7 @@ export interface FileRouteTypes { | '/login_/callback' | '/_authenticated/' | '/_authenticated/_sidebar-layout/home' - | '/_authenticated/_sidebar-layout/recent' | '/_authenticated/_sidebar-layout/directories/$directoryId' - | '/_authenticated/_sidebar-layout/trash/directories/$directoryId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -193,13 +167,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSidebarLayoutRouteImport parentRoute: typeof AuthenticatedRoute } - '/_authenticated/_sidebar-layout/recent': { - id: '/_authenticated/_sidebar-layout/recent' - path: '/recent' - fullPath: '/recent' - preLoaderRoute: typeof AuthenticatedSidebarLayoutRecentRouteImport - parentRoute: typeof AuthenticatedSidebarLayoutRoute - } '/_authenticated/_sidebar-layout/home': { id: '/_authenticated/_sidebar-layout/home' path: '/home' @@ -214,32 +181,19 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport parentRoute: typeof AuthenticatedSidebarLayoutRoute } - '/_authenticated/_sidebar-layout/trash/directories/$directoryId': { - id: '/_authenticated/_sidebar-layout/trash/directories/$directoryId' - path: '/trash/directories/$directoryId' - fullPath: '/trash/directories/$directoryId' - preLoaderRoute: typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRouteImport - parentRoute: typeof AuthenticatedSidebarLayoutRoute - } } } interface AuthenticatedSidebarLayoutRouteChildren { AuthenticatedSidebarLayoutHomeRoute: typeof AuthenticatedSidebarLayoutHomeRoute - AuthenticatedSidebarLayoutRecentRoute: typeof AuthenticatedSidebarLayoutRecentRoute AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute - AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute } const AuthenticatedSidebarLayoutRouteChildren: AuthenticatedSidebarLayoutRouteChildren = { AuthenticatedSidebarLayoutHomeRoute: AuthenticatedSidebarLayoutHomeRoute, - AuthenticatedSidebarLayoutRecentRoute: - AuthenticatedSidebarLayoutRecentRoute, AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute: AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute, - AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute: - AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute, } const AuthenticatedSidebarLayoutRouteWithChildren = diff --git a/apps/drive-web/src/routes/__root.tsx b/apps/drive-web/src/routes/__root.tsx index 64cc4f5..c97f0ee 100644 --- a/apps/drive-web/src/routes/__root.tsx +++ b/apps/drive-web/src/routes/__root.tsx @@ -1,46 +1,46 @@ import "@/styles/globals.css" -import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { createRootRoute, Outlet } from "@tanstack/react-router" -import { ConvexReactClient } from "convex/react" -import { toast } from "sonner" +import { Provider } from "jotai" +import { useHydrateAtoms } from "jotai/utils" +import { queryClientAtom } from "jotai-tanstack-query" +import type React from "react" import { Toaster } from "@/components/ui/sonner" -import { formatError } from "@/lib/error" +import { defaultOnError } from "@/lib/error" import { useKeyboardModifierListener } from "@/lib/keyboard" -import { authClient } from "../auth" export const Route = createRootRoute({ component: RootLayout, }) -const convexClient = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL, { - verbose: true, - expectAuth: true, -}) const queryClient = new QueryClient({ defaultOptions: { + queries: { + throwOnError: false, + }, mutations: { - onError: (error) => { - console.log(error) - toast.error(formatError(error)) - }, + onError: defaultOnError, throwOnError: false, }, }, }) +function HydrateAtoms({ children }: React.PropsWithChildren) { + useHydrateAtoms(new Map([[queryClientAtom, queryClient]])) + return children +} + function RootLayout() { useKeyboardModifierListener() return ( - - - - + + + + + + ) } diff --git a/apps/drive-web/src/routes/_authenticated.tsx b/apps/drive-web/src/routes/_authenticated.tsx index b53abef..1faf68d 100644 --- a/apps/drive-web/src/routes/_authenticated.tsx +++ b/apps/drive-web/src/routes/_authenticated.tsx @@ -1,45 +1,31 @@ -import { - createFileRoute, - Navigate, - Outlet, - useLocation, -} from "@tanstack/react-router" -import { - Authenticated, - AuthLoading, - Unauthenticated, - useConvexAuth, -} from "convex/react" -import { useEffect, useState } from "react" -import { authClient, SessionContext } from "@/auth" +import { createFileRoute, Navigate, Outlet } from "@tanstack/react-router" +import { useAtomValue } from "jotai" +import { atomEffect } from "jotai-effect" +import { atomWithQuery } from "jotai-tanstack-query" +import { accountsQuery } from "@/account/api" import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { currentAccountAtom } from "../account/account" export const Route = createFileRoute("/_authenticated")({ component: AuthenticatedLayout, }) +const accountsAtom = atomWithQuery(() => accountsQuery) +const selectFirstAccountEffect = atomEffect((get, set) => { + const { data: accounts } = get(accountsAtom) + const firstAccount = accounts?.[0] + if (firstAccount && get.peek(currentAccountAtom) === null) { + set(currentAccountAtom, firstAccount) + } +}) + function AuthenticatedLayout() { - const { search } = useLocation() - const { isLoading, isAuthenticated } = useConvexAuth() - const { data: session, isPending: sessionLoading } = authClient.useSession() - const [hasProcessedAuth, setHasProcessedAuth] = useState(false) + const { data: accounts, isLoading: isLoadingAccounts } = + useAtomValue(accountsAtom) - // Check if we're in the middle of processing an auth code - const hasAuthCode = search && typeof search === "object" && "code" in search + useAtomValue(selectFirstAccountEffect) - // Track when auth processing is complete - useEffect(() => { - if (!sessionLoading && !isLoading) { - // Delay to ensure auth state is fully synchronized - const timer = setTimeout(() => { - setHasProcessedAuth(true) - }, 0) - return () => clearTimeout(timer) - } - }, [sessionLoading, isLoading]) - - // Show loading during auth code processing or while auth state is syncing - if (hasAuthCode || sessionLoading || isLoading || !hasProcessedAuth) { + if (isLoadingAccounts) { return (
@@ -47,25 +33,9 @@ function AuthenticatedLayout() { ) } - return ( - <> - - {session ? ( - - - - ) : ( - - )} - - - - - -
- -
-
- - ) + if (!accounts) { + return + } + + return } diff --git a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx b/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx index fa2bb25..c1c7867 100644 --- a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx +++ b/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx @@ -1,18 +1,6 @@ -import { api } from "@fileone/convex/api" -import type { Doc, Id } from "@fileone/convex/dataModel" -import { - type FileSystemItem, - newFileSystemHandle, - type OpenedFile, -} from "@fileone/convex/filesystem" -import { useMutation } from "@tanstack/react-query" +import { useMutation, useQuery } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" import type { Row, Table } from "@tanstack/react-table" -import { - useMutation as useContextMutation, - useMutation as useConvexMutation, - useQuery as useConvexQuery, -} from "convex/react" import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai" import { ChevronDownIcon, @@ -47,9 +35,20 @@ import { NewDirectoryDialog } from "@/directories/directory-page/new-directory-d import { RenameFileDialog } from "@/directories/directory-page/rename-file-dialog" import { DirectoryPathBreadcrumb } from "@/directories/directory-path-breadcrumb" import { FilePreviewDialog } from "@/files/file-preview-dialog" -import { cutHandlesAtom, inProgressFileUploadCountAtom } from "@/files/store" +import { cutItemsAtom, inProgressFileUploadCountAtom } from "@/files/store" import { UploadFileDialog } from "@/files/upload-file-dialog" import type { FileDragInfo } from "@/files/use-file-drop" +import { + directoryContentQueryAtom, + directoryInfoQueryAtom, + moveToTrashMutationAtom, +} from "@/vfs/api" +import type { + DirectoryInfo, + DirectoryInfoWithPath, + DirectoryItem, + FileInfo, +} from "@/vfs/vfs" export const Route = createFileRoute( "/_authenticated/_sidebar-layout/directories/$directoryId", @@ -68,55 +67,54 @@ type NewDirectoryDialogData = { type UploadFileDialogData = { kind: DialogKind.UploadFile - directory: Doc<"directories"> + directory: DirectoryInfoWithPath } type ActiveDialogData = NewDirectoryDialogData | UploadFileDialogData // MARK: atoms -const contextMenuTargetItemsAtom = atom([]) +const contextMenuTargetItemsAtom = atom([]) const activeDialogDataAtom = atom(null) const fileDragInfoAtom = atom(null) -const optimisticDeletedItemsAtom = atom( - new Set | Id<"directories">>(), -) -const openedFileAtom = atom(null) +const optimisticDeletedItemsAtom = atom(new Set()) +const openedFileAtom = atom(null) const itemBeingRenamedAtom = atom<{ - originalItem: FileSystemItem + originalItem: DirectoryItem name: string } | null>(null) // MARK: page entry function RouteComponent() { const { directoryId } = Route.useParams() - const rootDirectory = useConvexQuery(api.files.fetchRootDirectory) - const directory = useConvexQuery(api.files.fetchDirectory, { - directoryId, - }) - const directoryContent = useConvexQuery( - api.filesystem.fetchDirectoryContent, - { - directoryId, - trashed: false, - }, + const { data: directoryInfo, isLoading: isLoadingDirectoryInfo, error: directoryInfoError } = useQuery( + useAtomValue(directoryInfoQueryAtom(directoryId)), ) + const { data: directoryContent, isLoading: isLoadingDirectoryContent, error: directoryContentError } = + useQuery(useAtomValue(directoryContentQueryAtom(directoryId))) const directoryUrlById = useCallback( - (directoryId: Id<"directories">) => `/directories/${directoryId}`, + (directoryId: string) => `/directories/${directoryId}`, [], ) + + console.log({ directoryInfoError, directoryContentError }) - if (!directory || !directoryContent || !rootDirectory) { + if (isLoadingDirectoryInfo || isLoadingDirectoryContent) { return } + if (!directoryInfo || !directoryContent) { + // TODO: handle empty state/error + return null + } + return (
{ if (!open) { setData(null) @@ -148,7 +146,7 @@ function RouteComponent() { /> {data?.kind === DialogKind.UploadFile && ( setData(null)} /> )} @@ -194,29 +192,18 @@ function _DirectoryContentTable() { const setOpenedFile = useSetAtom(openedFileAtom) const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom) - const { mutate: openFile } = useMutation({ - mutationFn: useConvexMutation(api.filesystem.openFile), - onSuccess: (openedFile: OpenedFile) => { - setOpenedFile(openedFile) - }, - onError: (error) => { - console.error(error) - toast.error("Failed to open file") - }, - }) - - const onTableOpenFile = (file: Doc<"files">) => { - openFile({ fileId: file._id }) + const onTableOpenFile = (file: FileInfo) => { + setOpenedFile(file) } const directoryUrlFn = useCallback( - (directory: Doc<"directories">) => `/directories/${directory._id}`, + (directory: DirectoryInfo) => `/directories/${directory.id}`, [], ) const handleContextMenuRequest = ( - row: Row, - table: Table, + row: Row, + table: Table, ) => { if (row.getIsSelected()) { setContextMenuTargetItems( @@ -251,44 +238,34 @@ function DirectoryContentContextMenu({ const [target, setTarget] = useAtom(contextMenuTargetItemsAtom) const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom) const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom) - const setCutHandles = useSetAtom(cutHandlesAtom) - const moveToTrashMutation = useContextMutation(api.filesystem.moveToTrash) + const setCutItems = useSetAtom(cutItemsAtom) const { mutate: moveToTrash } = useMutation({ - mutationFn: moveToTrashMutation, - onMutate: ({ handles }) => { + ...useAtomValue(moveToTrashMutationAtom), + onMutate: (items) => { setBackgroundTaskProgress({ label: "Moving items to trash…", }) setOptimisticDeletedItems( - (prev) => - new Set([...prev, ...handles.map((handle) => handle.id)]), + (prev) => new Set([...prev, ...items.map((item) => item.id)]), ) }, - onSuccess: ({ deleted, errors }, { handles }) => { + onSuccess: (trashedItems) => { setBackgroundTaskProgress(null) setOptimisticDeletedItems((prev) => { const newSet = new Set(prev) - for (const handle of handles) { - newSet.delete(handle.id) + for (const item of trashedItems) { + newSet.delete(item.id) } return newSet }) - if (errors.length === 0 && deleted.length === handles.length) { - toast.success(`Moved ${handles.length} items to trash`) - } else if (errors.length === handles.length) { - toast.error("Failed to move to trash") - } else { - toast.info( - `Moved ${deleted.length} items to trash; failed to move ${errors.length} items`, - ) - } + toast.success(`Moved ${trashedItems.length} items to trash`) }, - onError: (_err, { handles }) => { + onError: (_err, items) => { setOptimisticDeletedItems((prev) => { const newSet = new Set(prev) - for (const handle of handles) { - newSet.delete(handle.id) + for (const item of items) { + newSet.delete(item.id) } return newSet }) @@ -298,16 +275,14 @@ function DirectoryContentContextMenu({ const handleCut = () => { const selectedItems = store.get(contextMenuTargetItemsAtom) if (selectedItems.length > 0) { - setCutHandles(selectedItems.map(newFileSystemHandle)) + setCutItems(selectedItems) } } const handleDelete = () => { const selectedItems = store.get(contextMenuTargetItemsAtom) if (selectedItems.length > 0) { - moveToTrash({ - handles: selectedItems.map(newFileSystemHandle), - }) + moveToTrash(selectedItems) } } @@ -352,7 +327,7 @@ function RenameMenuItem() { const selectedItem = selectedItems[0]! setItemBeingRenamed({ originalItem: selectedItem, - name: selectedItem.doc.name, + name: selectedItem.name, }) } } diff --git a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/recent.tsx b/apps/drive-web/src/routes/_authenticated/_sidebar-layout/recent.tsx deleted file mode 100644 index fa1d528..0000000 --- a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/recent.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { api } from "@fileone/convex/api" -import type { Doc } from "@fileone/convex/dataModel" -import { newFileHandle } from "@fileone/convex/filesystem" -import { useMutation } from "@tanstack/react-query" -import { createFileRoute, Link } from "@tanstack/react-router" -import { - useMutation as useConvexMutation, - useQuery as useConvexQuery, -} from "convex/react" -import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" -import { FolderInputIcon, TrashIcon } from "lucide-react" -import { useCallback } from "react" -import { toast } from "sonner" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { backgroundTaskProgressAtom } from "@/dashboard/state" -import type { FileGridSelection } from "@/files/file-grid" -import { FileGrid } from "@/files/file-grid" -import { formatError } from "@/lib/error" - -export const Route = createFileRoute("/_authenticated/_sidebar-layout/recent")({ - component: RouteComponent, -}) - -const selectedFilesAtom = atom(new Set() as FileGridSelection) -const contextMenuTargetItem = atom | null>(null) - -function RouteComponent() { - return ( -
- - - -
- ) -} - -function RecentFilesGrid() { - const recentFiles = useConvexQuery(api.filesystem.fetchRecentFiles, { - limit: 100, - }) - const [selectedFiles, setSelectedFiles] = useAtom(selectedFilesAtom) - const setContextMenuTargetItem = useSetAtom(contextMenuTargetItem) - - const handleContextMenu = useCallback( - (file: Doc<"files">, _event: React.MouseEvent) => { - setContextMenuTargetItem(file) - }, - [setContextMenuTargetItem], - ) - - return ( - - ) -} - -function RecentFilesContextMenu({ children }: { children: React.ReactNode }) { - const targetItem = useAtomValue(contextMenuTargetItem) - const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom) - - const { mutate: moveToTrash } = useMutation({ - mutationFn: useConvexMutation(api.filesystem.moveToTrash), - onMutate: () => { - setBackgroundTaskProgress({ - label: "Moving to trash…", - }) - }, - onSuccess: () => { - setBackgroundTaskProgress(null) - toast.success("Moved to trash") - }, - onError: (error) => { - toast.error("Failed to move to trash", { - description: formatError(error), - }) - }, - }) - - return ( - - -
{children}
-
- {targetItem && ( - - - - - Open in directory - - - { - moveToTrash({ - handles: [newFileHandle(targetItem._id)], - }) - }} - > - - Move to trash - - - )} -
- ) -} diff --git a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx b/apps/drive-web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx deleted file mode 100644 index 587fb00..0000000 --- a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import { api } from "@fileone/convex/api" -import type { Doc, Id } from "@fileone/convex/dataModel" -import { - type FileSystemItem, - FileType, - newFileSystemHandle, -} from "@fileone/convex/filesystem" -import { useMutation } from "@tanstack/react-query" -import { createFileRoute } from "@tanstack/react-router" -import type { Row, Table } from "@tanstack/react-table" -import { - useMutation as useConvexMutation, - useQuery as useConvexQuery, -} from "convex/react" -import { atom, useAtom, useSetAtom, useStore } from "jotai" -import { ShredderIcon, TrashIcon, UndoIcon } from "lucide-react" -import { useCallback, useContext } from "react" -import { toast } from "sonner" -import { Button } from "@/components/ui/button" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { - Dialog, - DialogClose, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { WithAtom } from "@/components/with-atom" -import { DirectoryPageContext } from "@/directories/directory-page/context" -import { DirectoryContentTable } from "@/directories/directory-page/directory-content-table" -import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton" -import { DirectoryPathBreadcrumb } from "@/directories/directory-path-breadcrumb" -import type { FileDragInfo } from "@/files/use-file-drop" -import { backgroundTaskProgressAtom } from "../../../dashboard/state" - -export const Route = createFileRoute( - "/_authenticated/_sidebar-layout/trash/directories/$directoryId", -)({ - component: RouteComponent, -}) - -enum ActiveDialogKind { - DeleteConfirmation = "DeleteConfirmation", - EmptyTrashConfirmation = "EmptyTrashConfirmation", -} - -const contextMenuTargetItemsAtom = atom([]) -const fileDragInfoAtom = atom(null) -const activeDialogAtom = atom(null) -const openedFileAtom = atom | null>(null) -const optimisticRemovedItemsAtom = atom( - new Set | Id<"directories">>(), -) - -function RouteComponent() { - const { directoryId } = Route.useParams() - const rootDirectory = useConvexQuery(api.files.fetchRootDirectory) - const directory = useConvexQuery(api.files.fetchDirectory, { - directoryId, - }) - const directoryContent = useConvexQuery( - api.filesystem.fetchDirectoryContent, - { - directoryId, - trashed: true, - }, - ) - const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom) - const setOpenedFile = useSetAtom(openedFileAtom) - - const directoryUrlFn = useCallback( - (directory: Doc<"directories">) => - `/trash/directories/${directory._id}`, - [], - ) - - const directoryUrlById = useCallback( - (directoryId: Id<"directories">) => `/trash/directories/${directoryId}`, - [], - ) - - if (!directory || !directoryContent || !rootDirectory) { - return - } - - const handleContextMenuRequest = ( - row: Row, - table: Table, - ) => { - if (row.getIsSelected()) { - setContextMenuTargetItems( - table.getSelectedRowModel().rows.map((row) => row.original), - ) - } else { - setContextMenuTargetItems([row.original]) - } - } - - return ( - -
- -
- -
-
- - -
- - {(optimisticRemovedItems) => ( - - )} - -
-
- - - -
- ) -} - -function TableContextMenu({ children }: React.PropsWithChildren) { - const setActiveDialog = useSetAtom(activeDialogAtom) - - return ( - - {children} - - - { - setActiveDialog(ActiveDialogKind.DeleteConfirmation) - }} - > - - Delete permanently - - - - ) -} - -function RestoreContextMenuItem() { - const store = useStore() - const setOptimisticRemovedItems = useSetAtom(optimisticRemovedItemsAtom) - const restoreItemsMutation = useConvexMutation(api.filesystem.restoreItems) - - const { mutate: restoreItems } = useMutation({ - mutationFn: restoreItemsMutation, - onMutate: ({ handles }) => { - setBackgroundTaskProgress({ - label: "Restoring items…", - }) - setOptimisticRemovedItems( - new Set(handles.map((handle) => handle.id)), - ) - }, - onSuccess: ({ restored, errors }) => { - setBackgroundTaskProgress(null) - if (errors.length === 0) { - if (restored.files > 0 && restored.directories > 0) { - toast.success( - `Restored ${restored.files} files and ${restored.directories} directories`, - ) - } else if (restored.files > 0) { - toast.success(`Restored ${restored.files} files`) - } else if (restored.directories > 0) { - toast.success( - `Restored ${restored.directories} directories`, - ) - } - } else { - toast.warning( - `Restored ${restored.files} files and ${restored.directories} directories; failed to restore ${errors.length} items`, - ) - } - }, - onError: (_err, { handles }) => { - setOptimisticRemovedItems((prev) => { - const newSet = new Set(prev) - for (const handle of handles) { - newSet.delete(handle.id) - } - return newSet - }) - }, - }) - const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom) - - const onClick = () => { - const targetItems = store.get(contextMenuTargetItemsAtom) - restoreItems({ - handles: targetItems.map(newFileSystemHandle), - }) - } - - return ( - - - Restore - - ) -} - -function EmptyTrashButton() { - const setActiveDialog = useSetAtom(activeDialogAtom) - - return ( - - ) -} - -function DeleteConfirmationDialog() { - const { rootDirectory } = useContext(DirectoryPageContext) - const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom) - const [targetItems, setTargetItems] = useAtom(contextMenuTargetItemsAtom) - const setOptimisticRemovedItems = useSetAtom(optimisticRemovedItemsAtom) - - const deletePermanentlyMutation = useConvexMutation( - api.filesystem.permanentlyDeleteItems, - ) - const { mutate: deletePermanently, isPending: isDeleting } = useMutation({ - mutationFn: deletePermanentlyMutation, - onMutate: ({ handles }) => { - setOptimisticRemovedItems( - (prev) => - new Set([...prev, ...handles.map((handle) => handle.id)]), - ) - }, - onSuccess: ({ deleted, errors }, { handles }) => { - setOptimisticRemovedItems((prev) => { - const newSet = new Set(prev) - for (const handle of handles) { - newSet.delete(handle.id) - } - return newSet - }) - if (errors.length === 0) { - toast.success( - `Deleted ${deleted.files} files and ${deleted.directories} directories`, - ) - } else { - toast.warning( - `Deleted ${deleted.files} files and ${deleted.directories} directories; failed to delete ${errors.length} items`, - ) - } - setActiveDialog(null) - setTargetItems([]) - }, - }) - - const onOpenChange = (open: boolean) => { - if (open) { - setActiveDialog(ActiveDialogKind.DeleteConfirmation) - } else { - setActiveDialog(null) - } - } - - const confirmDelete = () => { - deletePermanently({ - handles: - targetItems.length > 0 - ? targetItems.map(newFileSystemHandle) - : [ - newFileSystemHandle({ - kind: FileType.Directory, - doc: rootDirectory, - }), - ], - }) - } - - return ( - - - - - Permanently delete {targetItems.length} items? - - - -

- {targetItems.length} items will be permanently deleted. They - will be IRRECOVERABLE. -

- - - - - - - -
-
- ) -} - -function EmptyTrashConfirmationDialog() { - const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom) - - const { mutate: emptyTrash, isPending: isEmptying } = useMutation({ - mutationFn: useConvexMutation(api.filesystem.emptyTrash), - onSuccess: () => { - toast.success("Trash emptied successfully") - setActiveDialog(null) - }, - }) - - function onOpenChange(open: boolean) { - if (open) { - setActiveDialog(ActiveDialogKind.EmptyTrashConfirmation) - } else { - setActiveDialog(null) - } - } - - function confirmEmpty() { - emptyTrash(undefined) - } - - return ( - - - - Empty your trash? - - -

- All items in the trash will be permanently deleted. They - will be IRRECOVERABLE. -

- - - - - - - -
-
- ) -} diff --git a/apps/drive-web/src/routes/_authenticated/index.tsx b/apps/drive-web/src/routes/_authenticated/index.tsx index 47994f6..96d34c6 100644 --- a/apps/drive-web/src/routes/_authenticated/index.tsx +++ b/apps/drive-web/src/routes/_authenticated/index.tsx @@ -5,5 +5,5 @@ export const Route = createFileRoute("/_authenticated/")({ }) function RouteComponent() { - return + return } diff --git a/apps/drive-web/src/routes/login.tsx b/apps/drive-web/src/routes/login.tsx index 93491d0..f8a0cc9 100644 --- a/apps/drive-web/src/routes/login.tsx +++ b/apps/drive-web/src/routes/login.tsx @@ -1,6 +1,8 @@ import { useMutation } from "@tanstack/react-query" -import { createFileRoute } from "@tanstack/react-router" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { useSetAtom } from "jotai" import { GalleryVerticalEnd } from "lucide-react" +import { loginMutation } from "@/auth/api" import { Button } from "@/components/ui/button" import { Card, @@ -18,7 +20,7 @@ import { } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { cn } from "@/lib/utils" -import { type AuthErrorCode, authClient, BetterAuthError } from "../auth" +import { currentAccountAtom } from "../account/account" export const Route = createFileRoute("/login")({ component: RouteComponent, @@ -67,28 +69,16 @@ function LoginFormCard({ className, ...props }: React.ComponentProps<"div">) { } function LoginForm() { - const { - mutate: signIn, - isPending, - error: signInError, - } = useMutation({ - mutationFn: async ({ - email, - password, - }: { - email: string - password: string - }) => { - const { data: signInData, error } = await authClient.signIn.email({ - email, - password, - callbackURL: "/home", - rememberMe: true, + const navigate = useNavigate() + + const { mutate: signIn, isPending } = useMutation({ + ...loginMutation, + onSuccess: (data, vars, result, context) => { + loginMutation.onSuccess?.(data, vars, result, context) + navigate({ + to: "/", + replace: true, }) - if (error) { - throw new BetterAuthError(error.code as AuthErrorCode) - } - return signInData }, }) diff --git a/apps/drive-web/src/user/api.ts b/apps/drive-web/src/user/api.ts new file mode 100644 index 0000000..b39de9a --- /dev/null +++ b/apps/drive-web/src/user/api.ts @@ -0,0 +1,14 @@ +import { queryOptions } from "@tanstack/react-query" +import { atomWithQuery } from "jotai-tanstack-query" +import { fetchApi } from "../lib/api" +import { User } from "./user" + +export const currentUserQuery = queryOptions({ + queryKey: ["currentUser"], + queryFn: async () => + fetchApi("GET", "/users/me", { + returns: User, + }).then(([_, result]) => result), +}) + +export const currentUserAtom = atomWithQuery(() => currentUserQuery) diff --git a/apps/drive-web/src/user/user.ts b/apps/drive-web/src/user/user.ts new file mode 100644 index 0000000..fe471fc --- /dev/null +++ b/apps/drive-web/src/user/user.ts @@ -0,0 +1,9 @@ +import { type } from "arktype" + +export const User = type({ + id: "string", + displayName: "string", + email: "string", +}) + +export type User = typeof User.infer diff --git a/apps/drive-web/src/vfs/api.ts b/apps/drive-web/src/vfs/api.ts new file mode 100644 index 0000000..e79b1ba --- /dev/null +++ b/apps/drive-web/src/vfs/api.ts @@ -0,0 +1,255 @@ +import { mutationOptions, queryOptions, skipToken } from "@tanstack/react-query" +import { type } from "arktype" +import { atom } from "jotai" +import { atomFamily } from "jotai/utils" +import { currentAccountAtom } from "@/account/account" +import { fetchApi } from "@/lib/api" +import { + DirectoryContent, + DirectoryInfo, + DirectoryInfoWithPath, + DirectoryItem, + FileInfo, +} from "./vfs" + +/** + * This atom derives the file url for a given file. + * It is recommended to use {@link useFileUrl} instead of using this atom directly. + */ +export const fileUrlAtom = atomFamily((fileId: string) => + atom((get) => { + const account = get(currentAccountAtom) + if (!account) { + return "" + } + return `${import.meta.env.VITE_API_URL}/accounts/${account.id}/files/${fileId}/content` + }), +) + +export const rootDirectoryQueryAtom = atom((get) => { + const account = get(currentAccountAtom) + return queryOptions({ + queryKey: ["accounts", account?.id, "directories", "root"], + queryFn: account + ? () => + fetchApi( + "GET", + `/accounts/${account.id}/directories/root?include=path`, + { returns: DirectoryInfoWithPath }, + ).then(([_, result]) => result) + : skipToken, + }) +}) + +export const directoryInfoQueryAtom = atomFamily((directoryId: string) => + atom((get) => { + const account = get(currentAccountAtom) + return queryOptions({ + queryKey: ["accounts", account?.id, "directories", directoryId], + queryFn: account + ? () => + fetchApi( + "GET", + `/accounts/${account.id}/directories/${directoryId}?include=path`, + { returns: DirectoryInfoWithPath }, + ).then(([_, result]) => result) + : skipToken, + }) + }), +) + +export const directoryContentQueryAtom = atomFamily((directoryId: string) => + atom((get) => { + const account = get(currentAccountAtom) + return queryOptions({ + queryKey: [ + "accounts", + account?.id, + "directories", + directoryId, + "content", + ], + queryFn: account + ? () => + fetchApi( + "GET", + `/accounts/${account.id}/directories/${directoryId}/content`, + { returns: DirectoryContent }, + ).then(([_, result]) => result) + : skipToken, + }) + }), +) + +// Directory Mutations + +export const createDirectoryMutationAtom = atom((get) => { + const account = get(currentAccountAtom) + return mutationOptions({ + mutationFn: async (data: { name: string; parentId: string }) => { + if (!account) throw new Error("No account selected") + return fetchApi("POST", `/accounts/${account.id}/directories`, { + body: JSON.stringify({ + name: data.name, + parentId: data.parentId, + }), + returns: DirectoryInfoWithPath, + }).then(([_, result]) => result) + }, + onSuccess: (data, _variables, _context, { client }) => { + client.setQueryData( + get(directoryInfoQueryAtom(data.id)).queryKey, + data, + ) + }, + }) +}) + +export const MoveDirectoryItemsResult = type({ + items: DirectoryItem.array(), + moved: "string[]", + conflicts: "string[]", + errors: type({ + id: "string", + error: "string", + }).array(), +}) +export type MoveDirectoryItemsResult = typeof MoveDirectoryItemsResult.infer + +export const moveDirectoryItemsMutationAtom = atom((get) => + mutationOptions({ + mutationFn: async ({ + targetDirectory, + items, + }: { + targetDirectory: DirectoryInfo | string + items: DirectoryItem[] + }) => { + const account = get(currentAccountAtom) + if (!account) { + throw new Error("Account not found") + } + + const dirId = + typeof targetDirectory === "string" + ? targetDirectory + : targetDirectory.id + + const [, result] = await fetchApi( + "POST", + `/accounts/${account.id}/directories/${dirId}/content`, + { + body: JSON.stringify({ + items: items.map((item) => item.id), + }), + returns: MoveDirectoryItemsResult, + }, + ) + return result + }, + }), +) + +export const moveToTrashMutationAtom = atom((get) => + mutationOptions({ + mutationFn: async (items: DirectoryItem[]) => { + const account = get(currentAccountAtom) + if (!account) { + throw new Error("Account not found") + } + + const fileIds: string[] = [] + const directoryIds: string[] = [] + for (const item of items) { + switch (item.kind) { + case "file": + fileIds.push(item.id) + break + case "directory": + directoryIds.push(item.id) + break + } + } + + const fileDeleteParams = new URLSearchParams() + fileDeleteParams.set("id", fileIds.join(",")) + fileDeleteParams.set("trash", "true") + const deleteFilesPromise = fetchApi( + "DELETE", + `/accounts/${account.id}/files?${fileDeleteParams.toString()}`, + { + returns: FileInfo.array(), + }, + ) + + const directoryDeleteParams = new URLSearchParams() + directoryDeleteParams.set("id", directoryIds.join(",")) + directoryDeleteParams.set("trash", "true") + const deleteDirectoriesPromise = fetchApi( + "DELETE", + `/accounts/${account.id}/directories?${directoryDeleteParams.toString()}`, + { + returns: DirectoryInfo.array(), + }, + ) + + const [[, deletedFiles], [, deletedDirectories]] = + await Promise.all([ + deleteFilesPromise, + deleteDirectoriesPromise, + ]) + + return [...deletedFiles, ...deletedDirectories] + }, + }), +) + +export const renameFileMutationAtom = atom((get) => + mutationOptions({ + mutationFn: async (file: FileInfo) => { + const account = get(currentAccountAtom) + if (!account) { + throw new Error("Account not found") + } + + const [, result] = await fetchApi( + "PATCH", + `/accounts/${account.id}/files/${file.id}`, + { + body: JSON.stringify({ name: file.name }), + returns: FileInfo, + }, + ) + + return result + }, + }), +) + +export const renameDirectoryMutationAtom = atom((get) => + mutationOptions({ + mutationFn: async (directory: DirectoryInfo) => { + const account = get(currentAccountAtom) + if (!account) { + throw new Error("Account not found") + } + + const [, result] = await fetchApi( + "PATCH", + `/accounts/${account.id}/directories/${directory.id}`, + { + body: JSON.stringify({ name: directory.name }), + returns: DirectoryInfo, + }, + ) + + return result + }, + onSuccess: (data, _variables, _context, { client }) => { + client.setQueryData( + get(directoryInfoQueryAtom(data.id)).queryKey, + (prev) => (prev ? { ...prev, name: data.name } : undefined), + ) + }, + }), +) diff --git a/apps/drive-web/src/vfs/hooks.ts b/apps/drive-web/src/vfs/hooks.ts new file mode 100644 index 0000000..8a65e0f --- /dev/null +++ b/apps/drive-web/src/vfs/hooks.ts @@ -0,0 +1,15 @@ +import { useAtomValue } from "jotai" +import { useEffect } from "react" +import { fileUrlAtom } from "./api" +import type { FileInfo } from "./vfs" + +export function useFileUrl(file: FileInfo) { + const fileUrl = useAtomValue(fileUrlAtom(file.id)) + useEffect( + () => () => { + fileUrlAtom.remove(file.id) + }, + [file.id], + ) + return fileUrl +} diff --git a/apps/drive-web/src/vfs/vfs.ts b/apps/drive-web/src/vfs/vfs.ts new file mode 100644 index 0000000..778139d --- /dev/null +++ b/apps/drive-web/src/vfs/vfs.ts @@ -0,0 +1,35 @@ +import { type } from "arktype" +import { Path } from "@/lib/path" + +export const FileInfo = type({ + kind: "'file'", + id: "string", + name: "string", + size: "number", + mimeType: "string", + createdAt: "string.date.iso.parse", + updatedAt: "string.date.iso.parse", + "deletedAt?": "string.date.iso.parse", +}) +export type FileInfo = typeof FileInfo.infer + +export const DirectoryInfo = type({ + kind: "'directory'", + id: "string", + name: "string", + createdAt: "string.date.iso.parse", + updatedAt: "string.date.iso.parse", + "deletedAt?": "string.date.iso.parse", +}) +export type DirectoryInfo = typeof DirectoryInfo.infer + +export const DirectoryInfoWithPath = DirectoryInfo.and({ + path: Path, +}) +export type DirectoryInfoWithPath = typeof DirectoryInfoWithPath.infer + +export const DirectoryItem = type.or(DirectoryInfo, FileInfo) +export type DirectoryItem = typeof DirectoryItem.infer + +export const DirectoryContent = DirectoryItem.array() +export type DirectoryContent = typeof DirectoryContent.infer diff --git a/apps/drive-web/src/vite-env.d.ts b/apps/drive-web/src/vite-env.d.ts index 2bf437f..3df00a0 100644 --- a/apps/drive-web/src/vite-env.d.ts +++ b/apps/drive-web/src/vite-env.d.ts @@ -2,8 +2,11 @@ declare global { interface ImportMetaEnv { - readonly VITE_CONVEX_URL: string - readonly VITE_CONVEX_SITE_URL: string + readonly VITE_API_URL: string + } + + interface ImportMeta { + readonly env: ImportMetaEnv } } diff --git a/bun.lock b/bun.lock index bd70f52..a530d92 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "fileone", @@ -49,6 +50,7 @@ "@tanstack/react-router": "^1.131.41", "@tanstack/react-table": "^8.21.3", "@tanstack/router-devtools": "^1.131.42", + "arktype": "^2.1.28", "better-auth": "1.3.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -549,6 +551,8 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "arkregex": ["arkregex@0.0.4", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-biS/FkvSwQq59TZ453piUp8bxMui11pgOMV9WHAnli1F8o0ayNCZzUwQadL/bGIUic5TkS/QlPcyMuI8ZIwedQ=="], + "arktype": ["arktype@2.1.23", "", { "dependencies": { "@ark/regex": "0.0.0", "@ark/schema": "0.50.0", "@ark/util": "0.50.0" } }, "sha512-tyxNWX6xJVMb2EPJJ3OjgQS1G/vIeQRrZuY4DeBNQmh8n7geS+czgbauQWB6Pr+RXiOO8ChEey44XdmxsqGmfQ=="], "asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="], @@ -855,6 +859,10 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@drexa/auth/@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + + "@fileone/web/arktype": ["arktype@2.1.28", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.4" } }, "sha512-LVZqXl2zWRpNFnbITrtFmqeqNkPPo+KemuzbGSY6jvJwCb4v8NsDzrWOLHnQgWl26TkJeWWcUNUeBpq2Mst1/Q=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], @@ -875,6 +883,8 @@ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "arkregex/@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="], + "better-auth/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -886,5 +896,11 @@ "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + + "@drexa/auth/@types/bun/bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + + "@fileone/web/arktype/@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="], + + "@fileone/web/arktype/@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="], } }