refactor: initial frontend wiring for new api

This commit is contained in:
2025-12-15 00:13:10 +00:00
parent 528aa943fa
commit 05edf69ca7
63 changed files with 1876 additions and 1991 deletions

View File

@@ -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": [

View File

@@ -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

View File

@@ -13,3 +13,8 @@ storage:
mode: hierarchical
backend: fs
root_path: ./data
cors:
allow_origins:
- http://localhost:3000
allow_credentials: true

View File

@@ -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",

View File

@@ -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": [

View File

@@ -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

BIN
apps/backend/drexa Executable file

Binary file not shown.

View File

@@ -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"`
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -85,13 +85,24 @@ func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error {
}
directoryID := c.Params("directoryID")
node, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, account.ID, directoryID)
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)
}
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,12 +360,104 @@ 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)
}
}
// 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)
}
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)
}
@@ -368,6 +471,8 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
}
// moveItemsToDirectory moves files and directories into this directory
// @Summary Move items to 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.

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}
_, err := db.NewUpdate().Model(node).
WherePK().
Where("deleted_at IS NULL").
func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*Node) ([]*Node, error) {
if len(nodes) == 0 {
return nil, nil
}
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
}

View File

@@ -4,3 +4,4 @@ VITE_CONVEX_URL=
VITE_CONVEX_SITE_URL=
# this is the url to the file proxy
FILE_PROXY_URL=
API_URL=

View File

@@ -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",

View File

@@ -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<Account | null>(null)

View File

@@ -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),
})

View File

@@ -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)
},
})

View File

@@ -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")}
>
<Link to={`/directories/${rootDirectory._id}`}>
<Link to={`/directories/${rootDirectory.id}`}>
<FilesIcon />
<span>All Files</span>
</Link>
@@ -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")}
>
<Link to={`/trash/directories/${rootDirectory._id}`}>
<Link to={`/trash/directories/${rootDirectory.id}`}>
<TrashIcon />
<span>Trash</span>
</Link>
@@ -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() {
<CardHeader className="px-3.5 py-1.5! gap-0 border-b border-b-primary-foreground/10 bg-primary text-primary-foreground">
<CardTitle className="p-0 m-0 text-xs uppercase">
<div className="flex items-center gap-1.5">
<ScissorsIcon size={16} /> {cutHandles.length} Cut
<ScissorsIcon size={16} /> {cutItems.length} Cut
Items
</div>
</CardTitle>

View File

@@ -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<DirectoryPageContextType>(

View File

@@ -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 (
<ContextMenu
onOpenChange={(open) => {
if (!open) {
setTarget([])
}
}}
>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
{target && (
<ContextMenuContent>
<RenameMenuItem />
<ContextMenuItem onClick={handleDelete}>
<TrashIcon />
Move to trash
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
)
}
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 (
<ContextMenuItem onClick={handleRename}>
<TextCursorInputIcon />
Rename
</ContextMenuItem>
)
}

View File

@@ -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<FileSystemItem["doc"]["_id"]>
type DirectoryContentTableItemIdFilter = Set<string>
type DirectoryContentTableProps = {
hiddenItems: DirectoryContentTableItemIdFilter
directoryUrlFn: (directory: Doc<"directories">) => string
directoryUrlFn: (directory: DirectoryInfo) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
onContextMenu: (
row: Row<FileSystemItem>,
table: TableType<FileSystemItem>,
row: Row<DirectoryItem>,
table: TableType<DirectoryItem>,
) => 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<FileSystemItem>[] {
onOpenFile: (file: FileInfo) => void,
directoryUrlFn: (directory: DirectoryInfo) => string,
): ColumnDef<DirectoryItem>[] {
return useMemo(
() => [
{
@@ -100,17 +89,17 @@ function useTableColumns(
accessorKey: "doc.name",
cell: ({ row }) => {
switch (row.original.kind) {
case FileType.File:
case "file":
return (
<FileNameCell
file={row.original.doc}
file={row.original}
onOpenFile={onOpenFile}
/>
)
case FileType.Directory:
case "directory":
return (
<DirectoryNameCell
directory={row.original.doc}
directory={row.original}
directoryUrlFn={directoryUrlFn}
/>
)
@@ -123,13 +112,11 @@ function useTableColumns(
accessorKey: "size",
cell: ({ row }) => {
switch (row.original.kind) {
case FileType.File:
case "file":
return (
<div>
{formatFileSize(row.original.doc.size)}
</div>
<div>{formatFileSize(row.original.size)}</div>
)
case FileType.Directory:
case "directory":
return <div className="font-mono">-</div>
}
},
@@ -140,9 +127,7 @@ function useTableColumns(
cell: ({ row }) => {
return (
<div>
{new Date(
row.original.doc.createdAt,
).toLocaleString()}
{new Date(row.original.createdAt).toLocaleString()}
</div>
)
},
@@ -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<FileSystemItem>,
row: Row<DirectoryItem>,
_event: React.MouseEvent,
) => {
if (!row.getIsSelected()) {
@@ -205,7 +190,7 @@ export function DirectoryContentTable({
onContextMenu(row, table)
}
const selectRow = (row: Row<FileSystemItem>) => {
const selectRow = (row: Row<DirectoryItem>) => {
const keyboardModifiers = store.get(keyboardModifierAtom)
const isMultiSelectMode = isControlOrCommandKeyActive(keyboardModifiers)
const isRowSelected = row.getIsSelected()
@@ -227,10 +212,10 @@ export function DirectoryContentTable({
}
}
const handleRowDoubleClick = (row: Row<FileSystemItem>) => {
if (row.original.kind === FileType.Directory) {
const handleRowDoubleClick = (row: Row<DirectoryItem>) => {
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<FileSystemItem>
row: Row<FileSystemItem>
table: TableType<DirectoryItem>
row: Row<DirectoryItem>
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 (
<div className="flex w-full items-center gap-2">
@@ -402,8 +372,8 @@ function FileNameCell({
file,
onOpenFile,
}: {
file: Doc<"files">
onOpenFile: (file: Doc<"files">) => void
file: FileInfo
onOpenFile: (file: FileInfo) => void
}) {
return (
<div className="flex w-full items-center gap-2">

View File

@@ -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 })
}
}

View File

@@ -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<HTMLFormElement>) => {
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({
</DialogHeader>
<form id={formId} onSubmit={onSubmit}>
<RenameFileInput initialValue={item.doc.name} />
<RenameFileInput initialValue={item.name} />
</form>
<DialogFooter>

View File

@@ -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<FileSystemItem[]>([])
export const optimisticDeletedItemsAtom = atom(
new Set<Id<"files"> | Id<"directories">>(),
)
export const selectedFileRowsAtom = atom<RowSelectionState>({})
export const itemBeingRenamedAtom = atom<{
originalItem: FileSystemItem
name: string
} | null>(null)
export const openedFileAtom = atom<Doc<"files"> | null>(null)
export const dragInfoAtom = atom<FileDragInfo | null>(null)

View File

@@ -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<FileDragInfo | null>
}) {
if (directory.path.length === 1) {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>{rootLabel}</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator />
</BreadcrumbList>
</Breadcrumb>
)
}
const breadcrumbItems: React.ReactNode[] = [
<FilePathBreadcrumbItem
key={directory.path[0].handle.id}
component={directory.path[0]}
key={directory.path[0].id}
segment={directory.path[0]}
rootLabel={rootLabel}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
@@ -52,10 +61,10 @@ export function DirectoryPathBreadcrumb({
]
for (let i = 1; i < directory.path.length - 1; i++) {
breadcrumbItems.push(
<Fragment key={directory.path[i]?.handle.id}>
<Fragment key={directory.path[i]!.id}>
<BreadcrumbSeparator />
<FilePathBreadcrumbItem
component={directory.path[i]!}
segment={directory.path[i]!}
rootLabel={rootLabel}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
@@ -78,22 +87,22 @@ export function DirectoryPathBreadcrumb({
}
function FilePathBreadcrumbItem({
component,
segment,
rootLabel,
directoryUrlFn,
fileDragInfoAtom,
}: {
component: DirectoryPathComponent
segment: PathSegment
rootLabel: string
directoryUrlFn: (directory: Id<"directories">) => string
directoryUrlFn: (directoryId: string) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
}) {
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 (
<Tooltip open={isDraggedOver}>
@@ -103,9 +112,7 @@ function FilePathBreadcrumbItem({
{...dropHandlers}
>
<BreadcrumbLink asChild>
<Link to={directoryUrlFn(component.handle.id)}>
{dirName}
</Link>
<Link to={directoryUrlFn(segment.id)}>{dirName}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
</TooltipTrigger>

View File

@@ -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}
>
<span>{file.name}</span>
{fileUpload ? (
<Progress
className="max-w-20"
value={fileUpload.progress * 100}
/>
) : (
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(pickedFile)}
>
<XIcon className="size-4" />
</Button>
)}
{statusIndicator}
</li>
)
}

View File

@@ -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<Id<"files">>
export type FileGridSelection = Set<string>
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({
<div className="grid auto-cols-max grid-flow-col gap-3">
{files.map((file) => (
<FileGridItem
selected={selectedFiles.has(file._id)}
key={file._id}
selected={selectedFiles.has(file.id)}
key={file.id}
file={file}
onSelect={onItemSelect}
onContextMenu={onItemContextMenu}
@@ -54,14 +54,14 @@ const FileGridItem = memo(function FileGridItem({
onContextMenu,
}: {
selected: boolean
file: Doc<"files">
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 (
<button
type="button"
key={file._id}
key={file.id}
className={cn(
"flex flex-col gap-2 items-center justify-center w-24 p-[calc(var(--spacing)*1+1px)] rounded-md",
{ "bg-muted border border-border p-1": selected },

View File

@@ -1,14 +1,14 @@
import type { OpenedFile } from "@fileone/convex/filesystem"
import type { FileInfo } from "./file"
import { ImagePreviewDialog } from "./image-preview-dialog"
export function FilePreviewDialog({
openedFile,
onClose,
}: {
openedFile: OpenedFile
openedFile: FileInfo
onClose: () => void
}) {
switch (openedFile.file.mimeType) {
switch (openedFile.mimeType) {
case "image/jpeg":
case "image/png":
case "image/gif":

View File

@@ -1,422 +0,0 @@
import { api } from "@fileone/convex/api"
import type { Doc } from "@fileone/convex/dataModel"
import type { DirectoryItem } from "@fileone/convex/types"
import { useMutation } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import {
type ColumnDef,
flexRender,
getCoreRowModel,
type Row,
useReactTable,
} from "@tanstack/react-table"
import { useMutation as useContextMutation, useQuery } from "convex/react"
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
import { CheckIcon, TextCursorInputIcon, TrashIcon, XIcon } from "lucide-react"
import { useEffect, useId, useRef } from "react"
import { toast } from "sonner"
import { DirectoryIcon } from "@/components/icons/directory-icon"
import { Checkbox } from "@/components/ui/checkbox"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { TextFileIcon } from "../components/icons/text-file-icon"
import { Button } from "../components/ui/button"
import { LoadingSpinner } from "../components/ui/loading-spinner"
import { withDefaultOnError } from "../lib/error"
import { cn } from "../lib/utils"
import {
contextMenuTargeItemAtom,
itemBeingRenamedAtom,
newItemKindAtom,
optimisticDeletedItemsAtom,
} from "./state"
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
const columns: ColumnDef<DirectoryItem>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={row.getToggleSelectedHandler()}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 24,
},
{
header: "Name",
accessorKey: "doc.name",
cell: ({ row }) => {
switch (row.original.kind) {
case "file":
return <FileNameCell initialName={row.original.doc.name} />
case "directory":
return <DirectoryNameCell directory={row.original.doc} />
}
},
size: 1000,
},
{
header: "Size",
accessorKey: "size",
cell: ({ row }) => {
switch (row.original.kind) {
case "file":
return <div>{formatFileSize(row.original.doc.size)}</div>
case "directory":
return <div className="font-mono">-</div>
}
},
},
{
header: "Created At",
accessorKey: "createdAt",
cell: ({ row }) => {
return (
<div>
{new Date(row.original.doc.createdAt).toLocaleString()}
</div>
)
},
},
]
export function FileTable({ path }: { path: string }) {
return (
<FileTableContextMenu>
<div className="w-full">
<FileTableContent path={path} />
</div>
</FileTableContextMenu>
)
}
export function FileTableContextMenu({
children,
}: {
children: React.ReactNode
}) {
const store = useStore()
const target = useAtomValue(contextMenuTargeItemAtom)
const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom)
const moveToTrashMutation = useContextMutation(api.files.moveToTrash)
const setItemBeingRenamed = useSetAtom(itemBeingRenamedAtom)
const { mutate: moveToTrash } = useMutation({
mutationFn: moveToTrashMutation,
onMutate: ({ itemId }) => {
setOptimisticDeletedItems((prev) => new Set([...prev, itemId]))
},
onSuccess: (itemId) => {
setOptimisticDeletedItems((prev) => {
const newSet = new Set(prev)
newSet.delete(itemId)
return newSet
})
toast.success("Moved to trash")
},
})
const handleRename = () => {
const selectedItem = store.get(contextMenuTargeItemAtom)
if (selectedItem) {
setItemBeingRenamed({
kind: selectedItem.kind,
originalItem: selectedItem,
name: selectedItem.doc.name,
})
}
}
const handleDelete = () => {
const selectedItem = store.get(contextMenuTargeItemAtom)
if (selectedItem) {
moveToTrash({
kind: selectedItem.kind,
itemId: selectedItem.doc._id,
})
}
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
{target && (
<ContextMenuContent>
<ContextMenuItem onClick={handleRename}>
<TextCursorInputIcon />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={handleDelete}>
<TrashIcon />
Move to trash
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
)
}
export function FileTableContent({ path }: { path: string }) {
const directory = useQuery(api.files.fetchDirectoryContent, { path })
const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom)
const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemAtom)
const store = useStore()
const handleRowContextMenu = (
row: Row<DirectoryItem>,
_event: React.MouseEvent,
) => {
const target = store.get(contextMenuTargeItemAtom)
if (target === row.original) {
setContextMenuTargetItem(null)
} else {
selectRow(row)
setContextMenuTargetItem(row.original)
}
}
const table = useReactTable({
data: directory || [],
columns,
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
enableGlobalFilter: true,
globalFilterFn: (row, _columnId, _filterValue, _addMeta) => {
return !optimisticDeletedItems.has(row.original.doc._id)
},
getRowId: (row) => row.doc._id,
})
const selectRow = (row: Row<DirectoryItem>) => {
console.log("row.getIsSelected()", row.getIsSelected())
if (!row.getIsSelected()) {
table.toggleAllPageRowsSelected(false)
row.toggleSelected(true)
}
}
if (!directory) {
return null
}
return (
<div className="overflow-hidden">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow className="px-4" key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
className="first:pl-4 last:pr-4"
key={header.id}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={() => {
selectRow(row)
}}
onContextMenu={(e) => {
handleRowContextMenu(row, e)
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell
className="first:pl-4 last:pr-4"
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<NoResultsRow />
)}
<NewItemRow />
</TableBody>
</Table>
</div>
)
}
function NoResultsRow() {
const newItemKind = useAtomValue(newItemKindAtom)
if (newItemKind) {
return null
}
return (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
No results.
</TableCell>
</TableRow>
)
}
function NewItemRow() {
const inputRef = useRef<HTMLInputElement>(null)
const newItemFormId = useId()
const [newItemKind, setNewItemKind] = useAtom(newItemKindAtom)
const { mutate: createDirectory, isPending } = useMutation({
mutationFn: useContextMutation(api.files.createDirectory),
onSuccess: () => {
setNewItemKind(null)
},
onError: withDefaultOnError(() => {
setTimeout(() => {
inputRef.current?.focus()
}, 1)
}),
})
useEffect(() => {
if (newItemKind) {
setTimeout(() => {
inputRef.current?.focus()
}, 1)
}
}, [newItemKind])
if (!newItemKind) {
return null
}
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const itemName = formData.get("itemName") as string
if (itemName) {
createDirectory({ name: itemName })
} else {
toast.error("Please enter a name.")
}
}
const clearNewItemKind = () => {
// setItemBeingAdded(null)
setNewItemKind(null)
}
return (
<TableRow className={cn("align-middle", { "opacity-50": isPending })}>
<TableCell />
<TableCell className="p-0">
<div className="flex items-center gap-2 px-2 py-1 h-full">
{isPending ? (
<LoadingSpinner className="size-6" />
) : (
<DirectoryIcon />
)}
<form
className="w-full"
id={newItemFormId}
onSubmit={onSubmit}
>
<input
ref={inputRef}
type="text"
name="itemName"
defaultValue={newItemKind}
disabled={isPending}
className="w-full h-8 px-2 bg-transparent border border-input rounded-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
/>
</form>
</div>
</TableCell>
<TableCell />
<TableCell align="right" className="space-x-2 p-1">
{!isPending ? (
<>
<Button
type="button"
form={newItemFormId}
variant="ghost"
size="icon"
onClick={clearNewItemKind}
>
<XIcon />
</Button>
<Button type="submit" form={newItemFormId} size="icon">
<CheckIcon />
</Button>
</>
) : null}
</TableCell>
</TableRow>
)
}
function DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) {
return (
<div className="flex w-full items-center gap-2">
<DirectoryIcon className="size-4" />
<Link className="hover:underline" to={`/files/${directory.path}`}>
{directory.name}
</Link>
</div>
)
}
function FileNameCell({ initialName }: { initialName: string }) {
return (
<div className="flex w-full items-center gap-2">
<TextFileIcon className="size-4" />
{initialName}
</div>
)
}

View File

@@ -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 (
<>
<header className="flex py-1 shrink-0 items-center gap-2 border-b px-4 w-full">
<FilePathBreadcrumb path={path} />
<div className="ml-auto flex flex-row gap-2">
<NewDirectoryItemDropdown />
<UploadFileButton />
</div>
</header>
<div className="w-full">
<FileTable path={path} />
</div>
<RenameFileDialog />
</>
)
}
function FilePathBreadcrumb({ path }: { path: string }) {
const pathComponents = splitPath(path)
const base = baseName(path)
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/files">All Files</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{pathComponents.map((p) => (
<Fragment key={p}>
<BreadcrumbSeparator />
{p === base ? (
<BreadcrumbPage>{p}</BreadcrumbPage>
) : (
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={`/files/${p}`}>{p}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
)}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
)
}
// 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<HTMLInputElement>(null)
const handleClick = () => {
fileInputRef.current?.click()
}
const onFileUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
uploadFile(file)
}
}
return (
<>
<input
hidden
onChange={onFileUpload}
ref={fileInputRef}
type="file"
name="files"
/>
<Button
size="sm"
type="button"
onClick={handleClick}
disabled={isUploading}
>
{isUploading ? (
<Loader2Icon className="animate-spin size-4" />
) : (
<UploadCloudIcon className="size-4" />
)}
{isUploading ? "Uploading" : "Upload File"}
</Button>
</>
)
}
function NewDirectoryItemDropdown() {
const setNewItemKind = useSetAtom(newItemKindAtom)
const addNewDirectory = () => {
setNewItemKind("directory")
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
<PlusIcon className="size-4" />
New
<ChevronDownIcon className="pl-1 size-4 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<TextFileIcon />
Text file
</DropdownMenuItem>
<DropdownMenuItem onClick={addNewDirectory}>
<DirectoryIcon />
Directory
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -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 (
<DialogContent
showCloseButton={false}
@@ -69,10 +69,10 @@ function PreviewContent({ openedFile }: { openedFile: OpenedFile }) {
>
<DialogHeader className="overflow-auto border-b border-b-border p-4 flex flex-row items-center justify-between">
<DialogTitle className="truncate flex-1">
{openedFile.file.name}
{openedFile.name}
</DialogTitle>
<div className="flex flex-row items-center space-x-2">
<Toolbar openedFile={openedFile} />
<Toolbar file={openedFile} />
<Button variant="ghost" size="icon" asChild>
<DialogClose>
<XIcon />
@@ -82,15 +82,16 @@ function PreviewContent({ openedFile }: { openedFile: OpenedFile }) {
</div>
</DialogHeader>
<div className="w-full h-full flex items-center justify-center max-h-[calc(100vh-10rem)] overflow-auto">
<ImagePreview openedFile={openedFile} />
<ImagePreview file={openedFile} />
</div>
</DialogContent>
)
}
function Toolbar({ openedFile }: { openedFile: OpenedFile }) {
function Toolbar({ file }: { file: FileInfo }) {
const setZoomLevel = useSetAtom(zoomLevelAtom)
const zoomInterval = useRef<ReturnType<typeof setInterval> | null>(null)
const fileUrl = useFileUrl(file)
useEffect(
() => () => {
@@ -142,8 +143,8 @@ function Toolbar({ openedFile }: { openedFile: OpenedFile }) {
</Button>
<Button asChild>
<a
href={fileShareUrl(openedFile.shareToken)}
download={openedFile.file.name}
href={fileUrl}
download={file.name}
target="_blank"
className="flex flex-row items-center"
>
@@ -174,12 +175,13 @@ function ResetZoomButton() {
)
}
function ImagePreview({ openedFile }: { openedFile: OpenedFile }) {
function ImagePreview({ file }: { file: FileInfo }) {
const zoomLevel = useAtomValue(zoomLevelAtom)
const fileUrl = useFileUrl(file)
return (
<img
src={fileShareUrl(openedFile.shareToken)}
alt={openedFile.file.name}
src={fileUrl}
alt={file.name}
className="object-contain"
style={{ transform: `scale(${zoomLevel})` }}
/>

View File

@@ -1,111 +0,0 @@
import { api } from "@fileone/convex/api"
import { useMutation } from "@tanstack/react-query"
import { useMutation as useContextMutation } from "convex/react"
import { atom, useAtom, useStore } from "jotai"
import { useId } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { itemBeingRenamedAtom } from "./state"
const fielNameAtom = atom(
(get) => get(itemBeingRenamedAtom)?.name,
(get, set, newName: string) => {
const current = get(itemBeingRenamedAtom)
if (current) {
set(itemBeingRenamedAtom, {
...current,
name: newName,
})
}
},
)
export function RenameFileDialog() {
const [itemBeingRenamed, setItemBeingRenamed] =
useAtom(itemBeingRenamedAtom)
const store = useStore()
const formId = useId()
const { mutate: renameFile, isPending: isRenaming } = useMutation({
mutationFn: useContextMutation(api.files.renameFile),
onSuccess: () => {
setItemBeingRenamed(null)
toast.success("File renamed successfully")
},
})
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const itemBeingRenamed = store.get(itemBeingRenamedAtom)
if (itemBeingRenamed) {
const formData = new FormData(event.currentTarget)
const newName = formData.get("itemName") as string
if (newName) {
switch (itemBeingRenamed.originalItem.kind) {
case "file":
renameFile({
directoryId:
itemBeingRenamed.originalItem.doc.directoryId,
itemId: itemBeingRenamed.originalItem.doc._id,
newName,
})
break
default:
break
}
}
}
}
return (
<Dialog
open={itemBeingRenamed !== null}
onOpenChange={(open) =>
setItemBeingRenamed(open ? itemBeingRenamed : null)
}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Rename File</DialogTitle>
</DialogHeader>
<form id={formId} onSubmit={onSubmit}>
<RenameFileInput />
</form>
<DialogFooter>
<DialogClose asChild>
<Button loading={isRenaming} variant="outline">
<span>Cancel</span>
</Button>
</DialogClose>
<Button loading={isRenaming} type="submit" form={formId}>
<span>Rename</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function RenameFileInput() {
const [fileName, setFileName] = useAtom(fielNameAtom)
return (
<Input
value={fileName}
name="itemName"
onChange={(e) => setFileName(e.target.value)}
/>
)
}

View File

@@ -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<DirectoryItem | null>(null)
export const optimisticDeletedItemsAtom = atom(
new Set<Id<"files"> | Id<"directories">>(),
)
export const selectedFileRowsAtom = atom<RowSelectionState>({})
export const newItemKindAtom = atom<DirectoryItemKind | null>(null)
export const itemBeingRenamedAtom = atom<{
kind: DirectoryItemKind
originalItem: DirectoryItem
name: string
} | null>(null)

View File

@@ -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<FileSystemHandle[]>([])
export const cutItemsAtom = atom<DirectoryItem[]>([])
export const clearCutItemsAtom = atom(null, (_, set) => {
set(cutHandlesAtom, [])
set(cutItemsAtom, [])
})

View File

@@ -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<PickedFile[]>([])
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<typeof useUploadFilesAtom>
@@ -288,7 +295,7 @@ function UploadDialogHeader({
targetDirectory,
}: {
uploadFilesAtom: UploadFilesAtom
targetDirectory: Doc<"directories">
targetDirectory: DirectoryInfoWithPath
}) {
const { data: uploadResults, isPending: isUploading } =
useAtomValue(uploadFilesAtom)

View File

@@ -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<void> {
return new Promise<void>((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)
})
}

View File

@@ -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<FileDragInfo | null>
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)

View File

@@ -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

View File

@@ -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<Schema extends type.Any>(
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]
}

View File

@@ -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

View File

@@ -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 =

View File

@@ -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: {
mutations: {
onError: (error) => {
console.log(error)
toast.error(formatError(error))
queries: {
throwOnError: false,
},
mutations: {
onError: defaultOnError,
throwOnError: false,
},
},
})
function HydrateAtoms({ children }: React.PropsWithChildren) {
useHydrateAtoms(new Map([[queryClientAtom, queryClient]]))
return children
}
function RootLayout() {
useKeyboardModifierListener()
return (
<QueryClientProvider client={queryClient}>
<ConvexBetterAuthProvider
client={convexClient}
authClient={authClient}
>
<Provider>
<HydrateAtoms>
<Outlet />
<Toaster />
</ConvexBetterAuthProvider>
</HydrateAtoms>
</Provider>
</QueryClientProvider>
)
}

View File

@@ -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 (
<div className="flex h-screen w-full items-center justify-center">
<LoadingSpinner className="size-10" />
@@ -47,25 +33,9 @@ function AuthenticatedLayout() {
)
}
return (
<>
<Authenticated>
{session ? (
<SessionContext value={session}>
<Outlet />
</SessionContext>
) : (
<Outlet />
)}
</Authenticated>
<Unauthenticated>
<Navigate replace to="/login" />
</Unauthenticated>
<AuthLoading>
<div className="flex h-screen w-full items-center justify-center">
<LoadingSpinner className="size-10" />
</div>
</AuthLoading>
</>
)
if (!accounts) {
return <Navigate replace to="/login" />
}
return <Outlet />
}

View File

@@ -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<FileSystemItem[]>([])
const contextMenuTargetItemsAtom = atom<DirectoryItem[]>([])
const activeDialogDataAtom = atom<ActiveDialogData | null>(null)
const fileDragInfoAtom = atom<FileDragInfo | null>(null)
const optimisticDeletedItemsAtom = atom(
new Set<Id<"files"> | Id<"directories">>(),
)
const openedFileAtom = atom<OpenedFile | null>(null)
const optimisticDeletedItemsAtom = atom(new Set<string>())
const openedFileAtom = atom<FileInfo | null>(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}`,
[],
)
if (!directory || !directoryContent || !rootDirectory) {
console.log({ directoryInfoError, directoryContentError })
if (isLoadingDirectoryInfo || isLoadingDirectoryContent) {
return <DirectoryPageSkeleton />
}
if (!directoryInfo || !directoryContent) {
// TODO: handle empty state/error
return null
}
return (
<DirectoryPageContext
value={{ rootDirectory, directory, directoryContent }}
value={{ directory: directoryInfo, directoryContent }}
>
<header className="flex py-2 shrink-0 items-center gap-2 border-b px-4 w-full">
<DirectoryPathBreadcrumb
directory={directory}
directory={directoryInfo}
rootLabel="All Files"
directoryUrlFn={directoryUrlById}
fileDragInfoAtom={fileDragInfoAtom}
@@ -139,7 +137,7 @@ function RouteComponent() {
<>
<NewDirectoryDialog
open={data?.kind === DialogKind.NewDirectory}
directoryId={directory._id}
parentDirectory={directoryInfo}
onOpenChange={(open) => {
if (!open) {
setData(null)
@@ -148,7 +146,7 @@ function RouteComponent() {
/>
{data?.kind === DialogKind.UploadFile && (
<UploadFileDialog
targetDirectory={data.directory}
targetDirectory={directoryInfo}
onClose={() => 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<FileSystemItem>,
table: Table<FileSystemItem>,
row: Row<DirectoryItem>,
table: Table<DirectoryItem>,
) => {
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,
})
}
}

View File

@@ -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<Doc<"files"> | null>(null)
function RouteComponent() {
return (
<main className="p-4">
<RecentFilesContextMenu>
<RecentFilesGrid />
</RecentFilesContextMenu>
</main>
)
}
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 (
<FileGrid
files={recentFiles ?? []}
selectedFiles={selectedFiles}
onSelectionChange={setSelectedFiles}
onContextMenu={handleContextMenu}
/>
)
}
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 (
<ContextMenu>
<ContextMenuTrigger asChild>
<div>{children}</div>
</ContextMenuTrigger>
{targetItem && (
<ContextMenuContent>
<ContextMenuItem>
<Link
to={`/directories/${targetItem.directoryId}`}
className="flex flex-row items-center gap-2"
>
<FolderInputIcon />
Open in directory
</Link>
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={() => {
moveToTrash({
handles: [newFileHandle(targetItem._id)],
})
}}
>
<TrashIcon />
Move to trash
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
)
}

View File

@@ -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<FileSystemItem[]>([])
const fileDragInfoAtom = atom<FileDragInfo | null>(null)
const activeDialogAtom = atom<ActiveDialogKind | null>(null)
const openedFileAtom = atom<Doc<"files"> | null>(null)
const optimisticRemovedItemsAtom = atom(
new Set<Id<"files"> | 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 <DirectoryPageSkeleton />
}
const handleContextMenuRequest = (
row: Row<FileSystemItem>,
table: Table<FileSystemItem>,
) => {
if (row.getIsSelected()) {
setContextMenuTargetItems(
table.getSelectedRowModel().rows.map((row) => row.original),
)
} else {
setContextMenuTargetItems([row.original])
}
}
return (
<DirectoryPageContext
value={{ rootDirectory, directory, directoryContent }}
>
<header className="flex py-2 shrink-0 items-center gap-2 border-b px-4 w-full">
<DirectoryPathBreadcrumb
directory={directory}
rootLabel="Trash"
directoryUrlFn={directoryUrlById}
/>
<div className="ml-auto flex flex-row gap-2">
<EmptyTrashButton />
</div>
</header>
<TableContextMenu>
<div className="w-full">
<WithAtom atom={optimisticRemovedItemsAtom}>
{(optimisticRemovedItems) => (
<DirectoryContentTable
hiddenItems={optimisticRemovedItems}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={handleContextMenuRequest}
onOpenFile={setOpenedFile}
/>
)}
</WithAtom>
</div>
</TableContextMenu>
<DeleteConfirmationDialog />
<EmptyTrashConfirmationDialog />
</DirectoryPageContext>
)
}
function TableContextMenu({ children }: React.PropsWithChildren) {
const setActiveDialog = useSetAtom(activeDialogAtom)
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<RestoreContextMenuItem />
<ContextMenuItem
variant="destructive"
onClick={() => {
setActiveDialog(ActiveDialogKind.DeleteConfirmation)
}}
>
<ShredderIcon />
Delete permanently
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
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 (
<ContextMenuItem onClick={onClick}>
<UndoIcon />
Restore
</ContextMenuItem>
)
}
function EmptyTrashButton() {
const setActiveDialog = useSetAtom(activeDialogAtom)
return (
<Button
size="sm"
type="button"
variant="destructive"
onClick={() => {
setActiveDialog(ActiveDialogKind.EmptyTrashConfirmation)
}}
>
<TrashIcon className="size-4" />
Empty trash
</Button>
)
}
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 (
<Dialog
open={activeDialog === ActiveDialogKind.DeleteConfirmation}
onOpenChange={onOpenChange}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Permanently delete {targetItems.length} items?
</DialogTitle>
</DialogHeader>
<p>
{targetItems.length} items will be permanently deleted. They
will be IRRECOVERABLE.
</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={isDeleting}>
Go back
</Button>
</DialogClose>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={isDeleting}
loading={isDeleting}
>
Yes, delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
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 (
<Dialog
open={activeDialog === ActiveDialogKind.EmptyTrashConfirmation}
onOpenChange={onOpenChange}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Empty your trash?</DialogTitle>
</DialogHeader>
<p>
All items in the trash will be permanently deleted. They
will be IRRECOVERABLE.
</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={isEmptying}>
No, go back
</Button>
</DialogClose>
<Button
variant="destructive"
onClick={confirmEmpty}
disabled={isEmptying}
loading={isEmptying}
>
Yes, empty trash
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -5,5 +5,5 @@ export const Route = createFileRoute("/_authenticated/")({
})
function RouteComponent() {
return <Navigate replace to="/recent" />
return <Navigate replace to="/home" />
}

View File

@@ -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
},
})

View File

@@ -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)

View File

@@ -0,0 +1,9 @@
import { type } from "arktype"
export const User = type({
id: "string",
displayName: "string",
email: "string",
})
export type User = typeof User.infer

View File

@@ -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),
)
},
}),
)

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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=="],
}
}