Compare commits

..

63 Commits

Author SHA1 Message Date
ad99bca7fd style: rename unused param 2025-11-08 18:41:30 +00:00
b241f4e211 feat: add clear uploads button
add a clear upload button that is visible when there are upload errors

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 18:38:43 +00:00
027a315a04 style: apply biome formatting to config and generated files
- Convert spaces to tabs in tsconfig files
- Format CLI commands and prompts
- Update generated Convex files
- Format betterauth adapter and schema

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 18:03:32 +00:00
015524cd63 style: apply biome formatting to UI components
- Convert spaces to tabs for consistency
- Add 'type' modifier to React imports
- Format component code

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 18:03:26 +00:00
4ebb3fe620 style: format imports and code style
- Consolidate multi-line imports
- Apply consistent formatting

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 18:03:21 +00:00
b8c46217f7 chore: remove debug console.logs and add error handling
- Remove console.log statements from upload file dialog
- Add onError handler to display error toasts
- Update ErrorCode reference in use-file-drop

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 18:03:15 +00:00
94d6a22ab2 refactor: update remaining error imports to use ErrorCode
- Replace Err.Code with ErrorCode throughout convex model files
- Update error() function calls to use new signature
- Remove unused Err namespace imports

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 18:03:10 +00:00
f20f1a93c7 refactor: replace namespace import with direct type import
- Replace 'import type * as Err' with direct ApplicationErrorData import
- Update Err.ApplicationErrorData references to ApplicationErrorData

This improves code clarity and follows the project's import conventions.

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 17:56:42 +00:00
acfe1523df refactor: wrap all errors in ConvexError
- Update error() helper to throw ConvexError instead of plain objects
- Add isApplicationConvexError() type guard for client-side error checking
- Fix Vite config to include convex/values in optimizeDeps for proper instanceof checks
- Update error handling to check ConvexError wrapper and extract data property

This ensures all application errors are properly typed and can be identified
using instanceof ConvexError on the client side.

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 17:56:28 +00:00
9b8367ade4 feat: add basic storage usage tracking 2025-11-02 18:12:33 +00:00
d2c09f5d0f feat: add ctx menu to recent file items 2025-10-29 00:00:52 +00:00
952a0e41b4 feat: add className prop to MiddleTruncatedText 2025-10-29 00:00:23 +00:00
8f194eec55 chore: remove console log 2025-10-28 20:26:35 +00:00
a8c7a8f60b feat: basic recent file browsing 2025-10-28 20:26:28 +00:00
7fe5184e81 fix: remove file preview dialog in trash page 2025-10-28 19:58:24 +00:00
3209ce1cd2 chore: remove unused import 2025-10-21 23:58:18 +00:00
af5d887bd1 fix: update last accessed at on open file 2025-10-21 23:54:25 +00:00
a862442979 chore: remove bun dev line elision 2025-10-21 23:45:35 +00:00
6234c5efd3 feat: initial impl of file proxy 2025-10-21 23:45:04 +00:00
6eded27121 feat[cli]: add run script for cli 2025-10-20 00:18:33 +00:00
0307cbbf61 style[convex]: reorganize imports 2025-10-20 00:17:50 +00:00
d0893e13be feat[convex]: api key auth support 2025-10-20 00:17:41 +00:00
a4544a3f09 feat[cli]: new admin cli
new admin cli for general admin task. only supports api key generation
for now
2025-10-20 00:15:42 +00:00
14e2ee1e28 feat[auth]: custom hasher & api key validation
Co-authored-by: Ona <no-reply@ona.com>
2025-10-20 00:14:50 +00:00
e58caa6b16 feat: auth pkg and file proxy scaffold
Co-authored-by: Ona <no-reply@ona.com>
2025-10-19 17:05:15 +00:00
c0f852ad35 fix: directory table optimistic update
fix optimistic update not working for directory table and trash table
2025-10-18 22:58:23 +00:00
efd4eefa49 fix: trash page breadcrumb
Co-authored-by: Ona <no-reply@ona.com>
2025-10-18 20:14:13 +00:00
1ae649850a feat: make dir path breadcrumb generic 2025-10-18 19:55:14 +00:00
cd9dee9371 refactor: add import maps for generated code
- Add export mappings in @fileone/convex package.json for cleaner imports
- Map @fileone/convex/dataModel to _generated/dataModel.d.ts
- Map @fileone/convex/api to _generated/api.js
- Map @fileone/convex/server to _generated/server.js
- Update all imports across packages/convex and apps/drive-web
- Maintain backward compatibility with _generated/* exports

Co-authored-by: Ona <no-reply@ona.com>
2025-10-18 19:32:05 +00:00
25796ab609 refactor: migrate to vite and restructure repo
Co-authored-by: Ona <no-reply@ona.com>
2025-10-18 14:02:20 +00:00
83a5f92506 feat: implement comprehensive access control system
- Add authorizedGet function for secure resource access
- Implement ownership verification for all file/directory operations
- Use security through obscurity (not found vs access denied)
- Optimize bulk operations by removing redundant authorization checks
- Move generateFileUrl to filesystem.ts as fetchFileUrl with proper auth
- Ensure all database access goes through authorization layer

Co-authored-by: Ona <no-reply@ona.com>
2025-10-16 21:43:23 +00:00
b802cb5aec feat: add last access time to files 2025-10-16 20:56:16 +00:00
49b76934b2 feat: show upload success in upload dialog title 2025-10-13 22:55:25 +00:00
2ed8be94f1 feat: upload file dialog err handling & new flow
- add basic err handling to upload file dialog.
- rework the upload flow. now, on all successful uploads, the dialog
won't auto disappear. if some fails, the dialog will allow for retry.
2025-10-12 23:48:21 +00:00
b17de812b9 feat: show in progress upload in upload btn 2025-10-12 17:09:42 +00:00
0c7e4c43e7 feat: decouple btn loading from disabled state 2025-10-12 17:09:11 +00:00
03d36a2c80 feat: implement empty trash 2025-10-12 14:31:02 +00:00
5eff2fa756 fix: upload dialog title in root dir 2025-10-12 13:22:59 +00:00
0e460370da feat: initial bulk file upload dialog 2025-10-12 00:43:31 +00:00
bcc0f9f5e2 fix: duplicate outlet in _authenticated 2025-10-12 00:43:05 +00:00
bf2087cded refactor: migrate betterauth to local install
also added a login page

Co-authored-by: Ona <no-reply@ona.com>
2025-10-05 23:25:20 +00:00
f7bc5fd958 fix: root directory creation
Co-authored-by: Ona <no-reply@ona.com>
2025-10-05 22:14:44 +00:00
1fcdaf4f86 feat: improve sign up form err handling 2025-10-05 20:49:41 +00:00
483aa19351 refactor: use betterauth instead of workos 2025-10-05 20:21:45 +00:00
b654f50ddd fix: broken dir path breadcrumb links 2025-10-05 15:01:55 +00:00
33b235517c feat: impl file/dir restoration from trash 2025-10-05 14:29:45 +00:00
4978a173a8 feat: global progress indicator in sidebar
add a global progress indicator in the dashboard sidebar that can be
used to indicate progress for any background task

Co-authored-by: Ona <no-reply@ona.com>
2025-10-05 14:17:12 +00:00
4686744fd0 feat: add file preview in trash page 2025-10-05 13:29:17 +00:00
b745ad273e feat: make empty trash btn destructive
Co-authored-by: Ona <no-reply@ona.com>
2025-10-05 13:25:16 +00:00
7c29f642f1 feat: make move to trash ctx menu item destructive 2025-10-05 00:59:02 +00:00
6eef4d9c30 fix: sidebar items not highlighted in non-root dir
fix all files and trash items not highlighed when navigated into
non-root dirs
2025-10-05 00:55:12 +00:00
9149243e95 fix: root label not applied for non-root dirs
fix breadcrumb root label not correctly displayed when breadcrumb is
displaying path for non-root dirs
2025-10-05 00:54:22 +00:00
b43a88c6fc feat: tailor root breadcrumb label
in all files page, the root breadcrumb label says "All Files", and
"Trash" in trash page
2025-10-05 00:48:36 +00:00
94b35df0e5 fix: no hover effect for no result row 2025-10-05 00:46:29 +00:00
19e52feebb impl: permanent file deletion
implement trash page and permanent file deletion logic

Co-authored-by: Ona <no-reply@ona.com>
2025-10-05 00:41:59 +00:00
e806d442b7 fix: duplicate toast import 2025-10-04 15:20:32 +00:00
57369d10fe fix: rename dialog not working 2025-10-04 15:19:50 +00:00
9282e75bef fix: directory content table context menu
fix radix throwing event.preventDefault is not a function error on right
click in DirectoryContentTable

Co-authored-by: Ona <no-reply@ona.com>
2025-10-04 14:56:53 +00:00
875aae74e8 refactor: make dir content table reusable 2025-10-04 14:09:25 +00:00
c2d9010508 feat: add trash page 2025-10-03 23:23:05 +00:00
0e686a1f85 refactor[db]: store time as unix ms
Co-authored-by: Ona <no-reply@ona.com>
2025-10-03 21:23:51 +00:00
1d8a117b93 chore: remove unused imports 2025-10-03 20:40:23 +00:00
022f3c4726 feat: hide rename ctx menu item in multi select
Co-authored-by: Ona <no-reply@ona.com>
2025-09-28 15:58:37 +00:00
171 changed files with 9627 additions and 2019 deletions

View File

@@ -1,10 +1,13 @@
# this is the url to the convex instance (NOT THE DASHBOARD)
CONVEX_SELF_HOSTED_URL=
CONVEX_SELF_HOSTED_ADMIN_KEY=
CONVEX_URL=
WORKOS_CLIENT_ID=
WORKOS_CLIENT_SECRET=
WORKOS_API_KEY=
# this is the url to the convex instance (NOT THE DASHBOARD)
CONVEX_URL=
# this is the convex url for invoking http actions
CONVEX_SITE_URL=
# this is the url to the convex instance (NOT THE DASHBOARD)
BUN_PUBLIC_CONVEX_URL=
BUN_PUBLIC_WORKOS_CLIENT_ID=
BUN_PUBLIC_WORKOS_REDIRECT_URI=
# this is the convex url for invoking http actions
BUN_PUBLIC_CONVEX_SITE_URL=

View File

@@ -5,7 +5,8 @@ backend: convex
# Project structure
This project uses npm workspaces.
- `packages/convex` - convex functions and models
- `packages/web` - frontend dashboard
- `apps/drive-web` - frontend dashboard
- `apps/file-proxy` - proxies uploaded files via opaque share tokens
- `packages/path` - path utils
# General Guidelines

60
apps/cli/README.md Normal file
View File

@@ -0,0 +1,60 @@
# @drexa/cli
Admin CLI tool for managing Drexa resources.
## Usage
From the project root:
```bash
bun drexa <command> [subcommand] [options]
```
## Commands
### `generate apikey`
Generate a new API key for authentication.
```bash
bun drexa generate apikey
```
The command will interactively prompt you for (using Node.js readline):
- **Prefix**: A short identifier for the key (e.g., 'proxy', 'admin'). Cannot contain dashes.
- **Key byte length**: Length of the key in bytes (default: 32)
- **Description**: A description of what this key is for
- **Expiration date**: Optional expiration date in YYYY-MM-DD format
The command will output:
- **Unhashed key**: Save this securely - it won't be shown again
- **Hashed key**: Store this in your database
- **Description**: The description you provided
- **Expiration date**: When the key expires (if set)
## Development
Run the CLI directly:
```bash
bun run apps/cli/index.ts <command>
```
## Project Structure
```
apps/cli/
├── index.ts # Main entry point
├── prompts.ts # Interactive prompt utilities
└── commands/ # Command structure mirrors CLI structure
└── generate/
├── index.ts # Generate command group
└── apikey.ts # API key generation command
```
## Adding New Commands
1. Create a new directory under `commands/` for command groups
2. Create command files following the pattern in `commands/generate/apikey.ts`
3. Export commands from an `index.ts` in the command group directory
4. Register the command group in the main `index.ts`

View File

@@ -0,0 +1,68 @@
import { generateApiKey, newPrefix } from "@drexa/auth"
import chalk from "chalk"
import { Command } from "commander"
import { promptNumber, promptOptionalDate, promptText } from "../../prompts.ts"
export const apikeyCommand = new Command("apikey")
.description("Generate a new API key")
.action(async () => {
console.log(chalk.bold.blue("\n🔑 Generate API Key\n"))
// Prompt for all required information
const prefixInput = await promptText(
"Enter API key prefix (e.g., 'proxy', 'admin'):",
)
const prefix = newPrefix(prefixInput)
if (!prefix) {
console.error(
chalk.red(
'✗ Invalid prefix: cannot contain "-" character. Please use alphanumeric characters only.',
),
)
process.exit(1)
}
const keyByteLength = await promptNumber("Enter key byte length:", 32)
const description = await promptText("Enter description:")
const expiresAt = await promptOptionalDate("Enter expiration date")
console.log(chalk.dim("\n⏳ Generating API key...\n"))
// Generate the API key
const result = await generateApiKey({
prefix,
keyByteLength,
description,
expiresAt,
})
// Display results
console.log(chalk.green.bold("✓ API Key Generated Successfully!\n"))
console.log(chalk.gray("─".repeat(60)))
console.log(
chalk.yellow.bold(
"\n⚠ IMPORTANT: Save the unhashed key now. It won't be shown again!\n",
),
)
console.log(chalk.bold("Unhashed Key ") + chalk.dim("(save this):"))
console.log(chalk.green(` ${result.unhashedKey}\n`))
console.log(chalk.gray("─".repeat(60)))
console.log(
chalk.bold("\nHashed Key ") +
chalk.dim("(store this in your database):"),
)
console.log(chalk.dim(` ${result.hashedKey}\n`))
console.log(chalk.bold("Description:"))
console.log(chalk.white(` ${result.description}\n`))
if (result.expiresAt) {
console.log(chalk.bold("Expires At:"))
console.log(chalk.yellow(` ${result.expiresAt.toISOString()}\n`))
} else {
console.log(chalk.bold("Expires At:"))
console.log(chalk.dim(" Never\n"))
}
console.log(chalk.gray("─".repeat(60)) + "\n")
})

View File

@@ -0,0 +1,6 @@
import { Command } from "commander"
import { apikeyCommand } from "./apikey.ts"
export const generateCommand = new Command("generate")
.description("Generate various resources")
.addCommand(apikeyCommand)

17
apps/cli/index.ts Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bun
import { Command } from "commander"
import { generateCommand } from "./commands/generate/index.ts"
const program = new Command()
program
.name("drexa")
.description("Drexa CLI - Admin tools for managing Drexa resources")
.version("0.1.0")
// Register command groups
program.addCommand(generateCommand)
// Parse command line arguments
program.parse()

23
apps/cli/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@drexa/cli",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"drexa": "./index.ts"
},
"scripts": {
"cli": "bun run index.ts"
},
"dependencies": {
"@drexa/auth": "workspace:*",
"chalk": "^5.3.0",
"commander": "^12.1.0"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

111
apps/cli/prompts.ts Normal file
View File

@@ -0,0 +1,111 @@
import * as readline from "node:readline/promises"
import chalk from "chalk"
function createReadlineInterface() {
return readline.createInterface({
input: process.stdin,
output: process.stdout,
})
}
export async function promptText(message: string): Promise<string> {
const rl = createReadlineInterface()
try {
const input = await rl.question(chalk.cyan(`${message} `))
if (!input || input.trim() === "") {
console.error(chalk.red("✗ Input is required"))
process.exit(1)
}
return input.trim()
} finally {
rl.close()
}
}
export async function promptNumber(
message: string,
defaultValue?: number,
): Promise<number> {
const rl = createReadlineInterface()
try {
const defaultStr = defaultValue
? chalk.dim(` (default: ${defaultValue})`)
: ""
const input = await rl.question(chalk.cyan(`${message}${defaultStr} `))
if ((!input || input.trim() === "") && defaultValue !== undefined) {
return defaultValue
}
if (!input || input.trim() === "") {
console.error(chalk.red("✗ Input is required"))
process.exit(1)
}
const num = Number.parseInt(input.trim(), 10)
if (Number.isNaN(num) || num <= 0) {
console.error(chalk.red("✗ Please enter a valid positive number"))
process.exit(1)
}
return num
} finally {
rl.close()
}
}
export async function promptOptionalDate(
message: string,
): Promise<Date | undefined> {
const rl = createReadlineInterface()
try {
const input = await rl.question(
chalk.cyan(`${message} `) +
chalk.dim("(optional, format: YYYY-MM-DD) "),
)
if (!input || input.trim() === "") {
return undefined
}
const date = new Date(input.trim())
if (Number.isNaN(date.getTime())) {
console.error(
chalk.red("✗ Invalid date format. Please use YYYY-MM-DD"),
)
process.exit(1)
}
if (date < new Date()) {
console.error(chalk.red("✗ Expiration date must be in the future"))
process.exit(1)
}
return date
} finally {
rl.close()
}
}
export async function promptConfirm(
message: string,
defaultValue = false,
): Promise<boolean> {
const rl = createReadlineInterface()
try {
const defaultStr = defaultValue
? chalk.dim(" (Y/n)")
: chalk.dim(" (y/N)")
const input = await rl.question(chalk.cyan(`${message}${defaultStr} `))
if (!input || input.trim() === "") {
return defaultValue
}
const normalized = input.toLowerCase().trim()
return normalized === "y" || normalized === "yes"
} finally {
rl.close()
}
}

83
apps/cli/test-example.md Normal file
View File

@@ -0,0 +1,83 @@
# Testing the CLI
To test the API key generation interactively, run:
```bash
bun drexa generate apikey
```
## Example Session
The CLI now uses **chalk** for beautiful colored output!
```
$ bun drexa generate apikey
🔑 Generate API Key
Enter API key prefix (e.g., 'proxy', 'admin'): testkey
Enter key byte length: (default: 32)
Enter description: Test API Key for development
Enter expiration date (optional, format: YYYY-MM-DD):
⏳ Generating API key...
✓ API Key Generated Successfully!
────────────────────────────────────────────────────────────
⚠️ IMPORTANT: Save the unhashed key now. It won't be shown again!
Unhashed Key (save this):
sk-testkey-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789
────────────────────────────────────────────────────────────
Hashed Key (store this in your database):
$argon2id$v=19$m=4,t=3,p=1$...
Description:
Test API Key for development
Expires At:
Never
────────────────────────────────────────────────────────────
```
### Color Scheme
- **Prompts**: Cyan text with dimmed hints
- **Success messages**: Green with checkmark
- **Warnings**: Yellow with warning icon
- **Errors**: Red with X mark
- **Important data**: Green (unhashed key), dimmed (hashed key)
- **Separators**: Gray lines
## Testing with Invalid Input
### Invalid prefix (contains dash)
```bash
$ bun drexa generate apikey
Enter API key prefix (e.g., 'proxy', 'admin'): test-key
✗ Invalid prefix: cannot contain "-" character. Please use alphanumeric characters only.
```
### Invalid key byte length
```bash
$ bun drexa generate apikey
Enter API key prefix (e.g., 'proxy', 'admin'): testkey
Enter key byte length: (default: 32) -5
✗ Please enter a valid positive number
```
### Invalid date format
```bash
$ bun drexa generate apikey
Enter API key prefix (e.g., 'proxy', 'admin'): testkey
Enter key byte length: (default: 32)
Enter description: Test
Enter expiration date (optional, format: YYYY-MM-DD): invalid-date
✗ Invalid date format. Please use YYYY-MM-DD
```
All error messages are displayed in red for better visibility.

28
apps/cli/tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@@ -0,0 +1,6 @@
# this is the url to the convex instance (NOT THE DASHBOARD)
VITE_CONVEX_URL=
# this is the convex url for invoking http actions
VITE_CONVEX_SITE_URL=
# this is the url to the file proxy
FILE_PROXY_URL=

View File

@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bun + React</title>
<title>Drive</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./entry.tsx"></script>
<script type="module" src="/src/entry.tsx"></script>
</body>
</html>

View File

@@ -4,18 +4,20 @@
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/server.tsx",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
"start": "NODE_ENV=production bun src/index.tsx",
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"format": "biome format --write"
},
"dependencies": {
"@convex-dev/workos": "^0.0.1",
"@convex-dev/better-auth": "^0.8.9",
"@fileone/convex": "workspace:*",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -23,15 +25,18 @@
"@tanstack/react-router": "^1.131.41",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-devtools": "^1.131.42",
"@workos-inc/authkit-react": "^0.12.0",
"bun-plugin-tailwind": "latest",
"better-auth": "1.3.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.27.0",
"convex-helpers": "^0.1.104",
"jotai": "^2.14.0",
"jotai-effect": "^2.1.3",
"jotai-scope": "^0.9.5",
"jotai-tanstack-query": "^0.11.0",
"lucide-react": "^0.544.0",
"motion": "^12.23.16",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"react": "^19",
"react-dom": "^19",
@@ -42,7 +47,11 @@
},
"devDependencies": {
"@tanstack/router-cli": "^1.131.41",
"@tanstack/router-plugin": "^1.133.13",
"@types/node": "^22.10.5",
"@types/react": "^19",
"@types/react-dom": "^19"
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.0.4",
"vite": "^7.1.10"
}
}

View File

@@ -0,0 +1,50 @@
import { type FormEvent, useRef } from "react"
export function APITester() {
const responseInputRef = useRef<HTMLTextAreaElement>(null)
const testEndpoint = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
try {
const form = e.currentTarget
const formData = new FormData(form)
const endpoint = formData.get("endpoint") as string
const url = new URL(endpoint, location.href)
const method = formData.get("method") as string
const res = await fetch(url, { method })
const data = await res.json()
responseInputRef.current!.value = JSON.stringify(data, null, 2)
} catch (error) {
responseInputRef.current!.value = String(error)
}
}
return (
<div className="api-tester">
<form onSubmit={testEndpoint} className="endpoint-row">
<select name="method" className="method">
<option value="GET">GET</option>
<option value="PUT">PUT</option>
</select>
<input
type="text"
name="endpoint"
defaultValue="/api/hello"
className="url-input"
placeholder="/api/hello"
/>
<button type="submit" className="send-button">
Send
</button>
</form>
<textarea
ref={responseInputRef}
readOnly
placeholder="Response will appear here..."
className="response-area"
/>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import {
convexClient,
crossDomainClient,
} from "@convex-dev/better-auth/client/plugins"
import { createAuthClient } from "better-auth/react"
import { createContext, useContext } from "react"
export type AuthErrorCode = keyof typeof authClient.$ERROR_CODES
export class BetterAuthError extends Error {
constructor(public readonly errorCode: AuthErrorCode) {
super(`better-auth error: ${errorCode}`)
}
}
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_CONVEX_SITE_URL,
plugins: [convexClient(), crossDomainClient()],
})
export type Session = NonNullable<
Awaited<ReturnType<typeof authClient.useSession>>["data"]
>
export const SessionContext = createContext<Session | null>(null)
export function useSession() {
const context = useContext(SessionContext)
if (!context) {
throw new Error("useSession must be used within a SessionProvider")
}
return context
}

View File

@@ -0,0 +1,109 @@
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import type * as React from "react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className,
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -54,7 +54,7 @@ function Button({
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
disabled={loading || props.disabled}
disabled={props.disabled}
{...props}
>
{asChild ? (

View File

@@ -0,0 +1,92 @@
import type * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,30 @@
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import type * as React from "react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -36,7 +36,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-xs",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/20",
className,
)}
{...props}

View File

@@ -0,0 +1,241 @@
import { cva, type VariantProps } from "class-variance-authority"
import { useMemo } from "react"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className,
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className,
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
},
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className,
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className,
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors) {
return null
}
if (errors?.length === 1 && errors[0]?.message) {
return errors[0].message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@@ -0,0 +1,21 @@
import type * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,22 @@
import * as LabelPrimitive from "@radix-ui/react-label"
import type * as React from "react"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,21 @@
import { cn } from "@/lib/utils"
function MiddleTruncatedText({
children,
className,
}: {
children: string
className?: string
}) {
const LAST_PART_LENGTH = 3
const lastPart = children.slice(children.length - LAST_PART_LENGTH)
const firstPart = children.slice(0, children.length - LAST_PART_LENGTH)
return (
<p className={cn("max-w-full flex", className)}>
<span className="flex-1 truncate">{firstPart}</span>
<span className="w-min">{lastPart}</span>
</p>
)
}
export { MiddleTruncatedText }

View File

@@ -0,0 +1,29 @@
import * as ProgressPrimitive from "@radix-ui/react-progress"
import type * as React from "react"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,28 @@
"use client"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import type * as React from "react"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,139 @@
"use client"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import type * as React from "react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -92,7 +92,7 @@ function SidebarProvider({
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
}, [isMobile, setOpen])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@@ -124,15 +124,7 @@ function SidebarProvider({
setOpenMobile,
toggleSidebar,
}),
[
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
],
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
)
return (

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,59 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import type * as React from "react"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,64 @@
import {
type Atom,
type ExtractAtomArgs,
type ExtractAtomResult,
type ExtractAtomValue,
type PrimitiveAtom,
type SetStateAction,
useAtom,
type WritableAtom,
} from "jotai"
import type * as React from "react"
type SetAtom<Args extends unknown[], Result> = (...args: Args) => Result
export function WithAtom<Value, Args extends unknown[], Result>(props: {
atom: WritableAtom<Value, Args, Result>
children: (
value: Awaited<Value>,
setAtom: SetAtom<Args, Result>,
) => React.ReactNode
}): React.ReactNode
export function WithAtom<Value>(props: {
atom: PrimitiveAtom<Value>
children: (
value: Awaited<Value>,
setAtom: SetAtom<[SetStateAction<Value>], void>,
) => React.ReactNode
}): React.ReactNode
export function WithAtom<Value>(props: {
atom: Atom<Value>
children: (value: Awaited<Value>, setAtom: never) => React.ReactNode
}): React.ReactNode
export function WithAtom<
AtomType extends WritableAtom<unknown, never[], unknown>,
>(props: {
atom: AtomType
children: (
value: Awaited<ExtractAtomValue<AtomType>>,
setAtom: SetAtom<
ExtractAtomArgs<AtomType>,
ExtractAtomResult<AtomType>
>,
) => React.ReactNode
}): React.ReactNode
export function WithAtom<AtomType extends Atom<unknown>>(props: {
atom: AtomType
children: (
value: Awaited<ExtractAtomValue<AtomType>>,
setAtom: never,
) => React.ReactNode
}): React.ReactNode
export function WithAtom<Value, Args extends unknown[], Result>({
atom,
children,
}: {
atom: Atom<Value> | WritableAtom<Value, Args, Result>
children: (
value: Awaited<Value>,
setAtom: SetAtom<Args, Result> | never,
) => React.ReactNode
}) {
const [value, setAtom] = useAtom(atom as WritableAtom<Value, Args, Result>)
return children(value, setAtom)
}

View File

@@ -0,0 +1,169 @@
import { api } from "@fileone/convex/api"
import { Link, useLocation } from "@tanstack/react-router"
import { useQuery as useConvexQuery } from "convex/react"
import { useAtomValue } from "jotai"
import {
ClockIcon,
FilesIcon,
LogOutIcon,
SettingsIcon,
TrashIcon,
User2Icon,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { LoadingSpinner } from "../components/ui/loading-spinner"
import { backgroundTaskProgressAtom } from "./state"
export function DashboardSidebar() {
return (
<Sidebar variant="inset" collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<UserMenu />
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<MainSidebarMenu />
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<BackgroundTaskProgressItem />
</SidebarMenu>
</SidebarFooter>
</Sidebar>
)
}
function MainSidebarMenu() {
const location = useLocation()
const isActive = (path: string) => {
if (path === "/") {
return location.pathname === "/"
}
return location.pathname.startsWith(path)
}
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive("/recent")}>
<Link to="/recent">
<ClockIcon />
<span>Recent</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<AllFilesItem />
<TrashItem />
</SidebarMenu>
)
}
function AllFilesItem() {
const location = useLocation()
const rootDirectory = useConvexQuery(api.files.fetchRootDirectory)
if (!rootDirectory) return null
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={location.pathname.startsWith("/directories")}
>
<Link to={`/directories/${rootDirectory._id}`}>
<FilesIcon />
<span>All Files</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
function TrashItem() {
const location = useLocation()
const rootDirectory = useConvexQuery(api.files.fetchRootDirectory)
if (!rootDirectory) return null
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={location.pathname.startsWith("/trash/directories")}
>
<Link to={`/trash/directories/${rootDirectory._id}`}>
<TrashIcon />
<span>Trash</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
function BackgroundTaskProgressItem() {
const backgroundTaskProgress = useAtomValue(backgroundTaskProgressAtom)
if (!backgroundTaskProgress) return null
return (
<SidebarMenuItem className="flex items-center gap-2 opacity-80 text-sm">
<LoadingSpinner />
{backgroundTaskProgress.label}
</SidebarMenuItem>
)
}
function UserMenu() {
function handleSignOut() {}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton size="lg" asChild>
<a href="/">
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<User2Icon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">
Acme Inc
</span>
<span className="truncate text-xs">Enterprise</span>
</div>
</a>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="start" side="bottom">
<DropdownMenuItem>
<SettingsIcon />
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSignOut}>
<LogOutIcon />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,9 @@
import { atom } from "jotai"
type BackgroundTaskProgress = {
label: string
}
export const backgroundTaskProgressAtom = atom<BackgroundTaskProgress | null>(
null,
)

View File

@@ -1,6 +1,6 @@
import type { Doc } from "@fileone/convex/_generated/dataModel"
import type { DirectoryInfo } from "@fileone/convex/model/directories"
import type { FileSystemItem } from "@fileone/convex/model/filesystem"
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"
type DirectoryPageContextType = {

View File

@@ -0,0 +1,116 @@
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,5 +1,4 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc } from "@fileone/convex/_generated/dataModel"
import type { Doc } from "@fileone/convex/dataModel"
import {
type DirectoryHandle,
type FileHandle,
@@ -10,30 +9,21 @@ import {
newDirectoryHandle,
newFileHandle,
newFileSystemHandle,
} from "@fileone/convex/model/filesystem"
import { useMutation } from "@tanstack/react-query"
} from "@fileone/convex/filesystem"
import { Link, useNavigate } from "@tanstack/react-router"
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
type Row,
type Table as TableType,
useReactTable,
} from "@tanstack/react-table"
import { useMutation as useContextMutation } from "convex/react"
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
import { TextCursorInputIcon, TrashIcon } from "lucide-react"
import { useContext, useEffect, useRef } from "react"
import { toast } from "sonner"
import { type PrimitiveAtom, useSetAtom, useStore } from "jotai"
import { useContext, useEffect, useMemo, useRef } from "react"
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,
@@ -47,16 +37,22 @@ import {
keyboardModifierAtom,
} from "@/lib/keyboard"
import { TextFileIcon } from "../../components/icons/text-file-icon"
import { useFileDrop } from "../../files/use-file-drop"
import { type FileDragInfo, useFileDrop } from "../../files/use-file-drop"
import { cn } from "../../lib/utils"
import { DirectoryPageContext } from "./context"
import {
contextMenuTargeItemsAtom,
dragInfoAtom,
itemBeingRenamedAtom,
openedFileAtom,
optimisticDeletedItemsAtom,
} from "./state"
type DirectoryContentTableItemIdFilter = Set<FileSystemItem["doc"]["_id"]>
type DirectoryContentTableProps = {
hiddenItems: DirectoryContentTableItemIdFilter
directoryUrlFn: (directory: Doc<"directories">) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
onContextMenu: (
row: Row<FileSystemItem>,
table: TableType<FileSystemItem>,
) => void
onOpenFile: (file: Doc<"files">) => void
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B"
@@ -68,180 +64,121 @@ function formatFileSize(bytes: number): string {
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
const columns: ColumnDef<FileSystemItem>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => {
table.toggleAllPageRowsSelected(!!value)
}}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => {
e.stopPropagation()
}}
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 FileType.File:
return <FileNameCell file={row.original.doc} />
case FileType.Directory:
return <DirectoryNameCell directory={row.original.doc} />
}
},
size: 1000,
},
{
header: "Size",
accessorKey: "size",
cell: ({ row }) => {
switch (row.original.kind) {
case FileType.File:
return <div>{formatFileSize(row.original.doc.size)}</div>
case FileType.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 DirectoryContentTable() {
return (
<DirectoryContentTableContextMenu>
<div className="w-full">
<DirectoryContentTableContent />
</div>
</DirectoryContentTableContextMenu>
function useTableColumns(
onOpenFile: (file: Doc<"files">) => void,
directoryUrlFn: (directory: Doc<"directories">) => string,
): ColumnDef<FileSystemItem>[] {
return useMemo(
() => [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => {
table.toggleAllPageRowsSelected(!!value)
}}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => {
e.stopPropagation()
}}
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 FileType.File:
return (
<FileNameCell
file={row.original.doc}
onOpenFile={onOpenFile}
/>
)
case FileType.Directory:
return (
<DirectoryNameCell
directory={row.original.doc}
directoryUrlFn={directoryUrlFn}
/>
)
}
},
size: 1000,
},
{
header: "Size",
accessorKey: "size",
cell: ({ row }) => {
switch (row.original.kind) {
case FileType.File:
return (
<div>
{formatFileSize(row.original.doc.size)}
</div>
)
case FileType.Directory:
return <div className="font-mono">-</div>
}
},
},
{
header: "Created At",
accessorKey: "createdAt",
cell: ({ row }) => {
return (
<div>
{new Date(
row.original.doc.createdAt,
).toLocaleString()}
</div>
)
},
},
],
[onOpenFile, directoryUrlFn],
)
}
export function DirectoryContentTableContextMenu({
children,
}: {
children: React.ReactNode
}) {
const store = useStore()
const [target, setTarget] = useAtom(contextMenuTargeItemsAtom)
const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom)
const moveToTrashMutation = useContextMutation(api.filesystem.moveToTrash)
const setItemBeingRenamed = useSetAtom(itemBeingRenamedAtom)
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 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,
})
}
}
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>
<ContextMenuItem onClick={handleRename}>
<TextCursorInputIcon />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={handleDelete}>
<TrashIcon />
Move to trash
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
)
}
export function DirectoryContentTableContent() {
export function DirectoryContentTable({
hiddenItems,
directoryUrlFn,
onContextMenu,
fileDragInfoAtom,
onOpenFile,
}: DirectoryContentTableProps) {
const { directoryContent } = useContext(DirectoryPageContext)
const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom)
const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemsAtom)
const store = useStore()
const navigate = useNavigate()
const table = useReactTable({
data: directoryContent || [],
columns,
columns: useTableColumns(onOpenFile, directoryUrlFn),
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true,
enableGlobalFilter: true,
globalFilterFn: (row, _columnId, _filterValue, _addMeta) => {
return !optimisticDeletedItems.has(row.original.doc._id)
state: {
globalFilter: hiddenItems,
},
globalFilterFn: (
row,
_columnId,
filterValue: DirectoryContentTableItemIdFilter,
_addMeta,
) => !filterValue.has(row.original.doc._id),
getRowId: (row) => row.doc._id,
})
@@ -262,17 +199,10 @@ export function DirectoryContentTableContent() {
row: Row<FileSystemItem>,
_event: React.MouseEvent,
) => {
const target = store.get(contextMenuTargeItemsAtom)
if (target.length > 0) {
setContextMenuTargetItem([])
} else if (row.getIsSelected()) {
setContextMenuTargetItem(
table.getSelectedRowModel().rows.map((row) => row.original),
)
} else {
if (!row.getIsSelected()) {
selectRow(row)
setContextMenuTargetItem([row.original])
}
onContextMenu(row, table)
}
const selectRow = (row: Row<FileSystemItem>) => {
@@ -336,6 +266,7 @@ export function DirectoryContentTableContent() {
table={table}
row={row}
onClick={() => selectRow(row)}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={(e) =>
handleRowContextMenu(row, e)
}
@@ -355,8 +286,8 @@ export function DirectoryContentTableContent() {
function NoResultsRow() {
return (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
<TableRow className="hover:bg-transparent">
<TableCell colSpan={4} className="text-center">
No results.
</TableCell>
</TableRow>
@@ -369,22 +300,24 @@ function FileItemRow({
onClick,
onContextMenu,
onDoubleClick,
fileDragInfoAtom,
}: {
table: TableType<FileSystemItem>
row: Row<FileSystemItem>
onClick: () => void
onContextMenu: (e: React.MouseEvent) => void
onDoubleClick: () => void
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
}) {
const ref = useRef<HTMLTableRowElement>(null)
const setDragInfo = useSetAtom(dragInfoAtom)
const setFileDragInfo = useSetAtom(fileDragInfoAtom)
const { isDraggedOver, dropHandlers } = useFileDrop({
destItem:
row.original.kind === FileType.Directory
? newDirectoryHandle(row.original.doc._id)
: null,
dragInfoAtom,
dragInfoAtom: fileDragInfoAtom,
})
const handleDragStart = (_e: React.DragEvent) => {
@@ -411,14 +344,14 @@ function FileItemRow({
draggedItems = [source]
}
setDragInfo({
setFileDragInfo({
source,
items: draggedItems,
})
}
const handleDragEnd = () => {
setDragInfo(null)
setFileDragInfo(null)
}
return (
@@ -448,23 +381,30 @@ function FileItemRow({
)
}
function DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) {
function DirectoryNameCell({
directory,
directoryUrlFn,
}: {
directory: Doc<"directories">
directoryUrlFn: (directory: Doc<"directories">) => string
}) {
return (
<div className="flex w-full items-center gap-2">
<DirectoryIcon className="size-4" />
<Link
className="hover:underline"
to={`/directories/${directory._id}`}
>
<Link className="hover:underline" to={directoryUrlFn(directory)}>
{directory.name}
</Link>
</div>
)
}
function FileNameCell({ file }: { file: Doc<"files"> }) {
const setOpenedFile = useSetAtom(openedFileAtom)
function FileNameCell({
file,
onOpenFile,
}: {
file: Doc<"files">
onOpenFile: (file: Doc<"files">) => void
}) {
return (
<div className="flex w-full items-center gap-2">
<TextFileIcon className="size-4" />
@@ -472,7 +412,7 @@ function FileNameCell({ file }: { file: Doc<"files"> }) {
type="button"
className="hover:underline cursor-pointer"
onClick={() => {
setOpenedFile(file)
onOpenFile(file)
}}
>
{file.name}

View File

@@ -1,5 +1,5 @@
import { api } from "@fileone/convex/_generated/api"
import type { Id } from "@fileone/convex/_generated/dataModel"
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 { useId } from "react"

View File

@@ -0,0 +1,93 @@
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 { useId } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
type RenameFileDialogProps = {
item: FileSystemItem
onRenameSuccess: () => void
onClose: () => void
}
export function RenameFileDialog({
item,
onRenameSuccess,
onClose,
}: RenameFileDialogProps) {
const formId = useId()
const { mutate: renameFile, isPending: isRenaming } = useMutation({
mutationFn: useContextMutation(api.files.renameFile),
onSuccess: () => {
onRenameSuccess()
},
})
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const newName = formData.get("itemName") as string
if (newName) {
switch (item.kind) {
case FileType.File:
renameFile({
directoryId: item.doc.directoryId,
itemId: item.doc._id,
newName,
})
break
default:
break
}
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) {
onClose()
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Rename File</DialogTitle>
</DialogHeader>
<form id={formId} onSubmit={onSubmit}>
<RenameFileInput initialValue={item.doc.name} />
</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({ initialValue }: { initialValue: string }) {
return <Input defaultValue={initialValue} name="itemName" />
}

View File

@@ -26,14 +26,18 @@ export function SkeletonDemo() {
{showPageSkeleton && (
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-4">Directory Page Skeleton</h3>
<h3 className="text-lg font-semibold mb-4">
Directory Page Skeleton
</h3>
<DirectoryPageSkeleton />
</div>
)}
{showTableSkeleton && (
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-4">Directory Content Table Skeleton</h3>
<h3 className="text-lg font-semibold mb-4">
Directory Content Table Skeleton
</h3>
<DirectoryContentTableSkeleton rows={5} />
</div>
)}

View File

@@ -1,6 +1,5 @@
import type { Doc, Id } from "@fileone/convex/_generated/dataModel"
import type { DirectoryItemKind } from "@fileone/convex/model/directories"
import type { FileSystemItem, FileType } from "@fileone/convex/model/filesystem"
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"
@@ -12,8 +11,6 @@ export const optimisticDeletedItemsAtom = atom(
export const selectedFileRowsAtom = atom<RowSelectionState>({})
export const newFileTypeAtom = atom<FileType | null>(null)
export const itemBeingRenamedAtom = atom<{
originalItem: FileSystemItem
name: string

View File

@@ -0,0 +1,115 @@
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"
import { Fragment } from "react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import type { FileDragInfo } from "@/files/use-file-drop"
import { useFileDrop } from "@/files/use-file-drop"
import { cn } from "@/lib/utils"
/**
* This is a placeholder file drag info atom that always stores null and is never mutated.
*/
const nullFileDragInfoAtom = atom<FileDragInfo | null>(null)
export function DirectoryPathBreadcrumb({
directory,
rootLabel,
directoryUrlFn,
fileDragInfoAtom = nullFileDragInfoAtom,
}: {
directory: DirectoryInfo
rootLabel: string
directoryUrlFn: (directory: Id<"directories">) => string
fileDragInfoAtom?: PrimitiveAtom<FileDragInfo | null>
}) {
const breadcrumbItems: React.ReactNode[] = [
<FilePathBreadcrumbItem
key={directory.path[0].handle.id}
component={directory.path[0]}
rootLabel={rootLabel}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
/>,
]
for (let i = 1; i < directory.path.length - 1; i++) {
breadcrumbItems.push(
<Fragment key={directory.path[i]?.handle.id}>
<BreadcrumbSeparator />
<FilePathBreadcrumbItem
component={directory.path[i]!}
rootLabel={rootLabel}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
/>
</Fragment>,
)
}
return (
<Breadcrumb>
<BreadcrumbList>
{breadcrumbItems}
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{directory.name}</BreadcrumbPage>{" "}
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}
function FilePathBreadcrumbItem({
component,
rootLabel,
directoryUrlFn,
fileDragInfoAtom,
}: {
component: DirectoryPathComponent
rootLabel: string
directoryUrlFn: (directory: Id<"directories">) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
}) {
const { isDraggedOver, dropHandlers } = useFileDrop({
destItem: component.handle as DirectoryHandle,
dragInfoAtom: fileDragInfoAtom,
})
const dirName = component.name || rootLabel
return (
<Tooltip open={isDraggedOver}>
<TooltipTrigger asChild>
<BreadcrumbItem
className={cn({ "bg-muted": isDraggedOver })}
{...dropHandlers}
>
<BreadcrumbLink asChild>
<Link to={directoryUrlFn(component.handle.id)}>
{dirName}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
</TooltipTrigger>
<TooltipContent>Move to {dirName}</TooltipContent>
</Tooltip>
)
}

View File

@@ -0,0 +1,71 @@
import { useAtomValue } from "jotai"
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 { FileUploadStatusKind, fileUploadStatusAtomFamily } from "./store"
import type { PickedFile } from "./upload-file-dialog"
export function PickedFileItem({
file: pickedFile,
onRemove,
}: {
file: PickedFile
onRemove: (file: PickedFile) => void
}) {
const fileUploadAtom = fileUploadStatusAtomFamily(pickedFile.id)
const fileUpload = useAtomValue(fileUploadAtom)
console.log("fileUpload", fileUpload)
const { file, id } = pickedFile
let statusIndicator: React.ReactNode
if (!fileUpload) {
statusIndicator = (
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(pickedFile)}
>
<XIcon className="size-4" />
</Button>
)
} else {
switch (fileUpload.kind) {
case FileUploadStatusKind.InProgress:
statusIndicator = <Progress value={fileUpload.progress * 100} />
break
case FileUploadStatusKind.Error:
statusIndicator = (
<Tooltip>
<TooltipTrigger>
<CircleAlertIcon />
</TooltipTrigger>
</Tooltip>
)
}
}
return (
<li
className="pl-3 pr-1 py-0.5 h-8 hover:bg-muted flex justify-between items-center"
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>
)}
</li>
)
}

View File

@@ -0,0 +1,84 @@
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"
export type FileGridSelection = Set<Id<"files">>
export function FileGrid({
files,
selectedFiles = new Set(),
onSelectionChange,
onContextMenu,
}: {
files: Doc<"files">[]
selectedFiles?: FileGridSelection
onSelectionChange?: (selection: FileGridSelection) => void
onContextMenu?: (file: Doc<"files">, event: React.MouseEvent) => void
}) {
const onItemSelect = useCallback(
(file: Doc<"files">) => {
onSelectionChange?.(new Set([file._id]))
},
[onSelectionChange],
)
const onItemContextMenu = useCallback(
(file: Doc<"files">, event: React.MouseEvent) => {
onContextMenu?.(file, event)
onSelectionChange?.(new Set([file._id]))
},
[onContextMenu, onSelectionChange],
)
return (
<div className="grid auto-cols-max grid-flow-col gap-3">
{files.map((file) => (
<FileGridItem
selected={selectedFiles.has(file._id)}
key={file._id}
file={file}
onSelect={onItemSelect}
onContextMenu={onItemContextMenu}
/>
))}
</div>
)
}
const FileGridItem = memo(function FileGridItem({
selected,
file,
onSelect,
onContextMenu,
}: {
selected: boolean
file: Doc<"files">
onSelect?: (file: Doc<"files">) => void
onContextMenu?: (file: Doc<"files">, event: React.MouseEvent) => void
}) {
return (
<button
type="button"
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 },
)}
onClick={() => {
onSelect?.(file)
}}
onContextMenu={(event) => {
onContextMenu?.(file, event)
}}
>
<TextFileIcon className="size-10" />
<MiddleTruncatedText className="text-sm">
{file.name}
</MiddleTruncatedText>
</button>
)
})
export { FileGridItem }

View File

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

View File

@@ -0,0 +1,3 @@
export function fileShareUrl(shareToken: string) {
return `${import.meta.env.VITE_FILE_PROXY_URL}/files/${shareToken}`
}

View File

@@ -1,6 +1,6 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc } from "@fileone/convex/_generated/dataModel"
import type { DirectoryItem } from "@fileone/convex/model/directories"
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 {

View File

@@ -1,4 +1,4 @@
import { api } from "@fileone/convex/_generated/api"
import { api } from "@fileone/convex/api"
import { baseName, splitPath } from "@fileone/path"
import { useMutation } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"

View File

@@ -1,7 +1,5 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc } from "@fileone/convex/_generated/dataModel"
import type { OpenedFile } from "@fileone/convex/filesystem"
import { DialogTitle } from "@radix-ui/react-dialog"
import { useQuery as useConvexQuery } from "convex/react"
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
import {
DownloadIcon,
@@ -12,15 +10,14 @@ import {
ZoomOutIcon,
} from "lucide-react"
import { useEffect, useRef } from "react"
import { Button } from "./ui/button"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogOverlay,
} from "./ui/dialog"
import { LoadingSpinner } from "./ui/loading-spinner"
} from "@/components/ui/dialog"
import { fileShareUrl } from "./file-share"
const zoomLevelAtom = atom(
1,
@@ -35,15 +32,12 @@ const zoomLevelAtom = atom(
)
export function ImagePreviewDialog({
file,
openedFile,
onClose,
}: {
file: Doc<"files">
openedFile: OpenedFile
onClose: () => void
}) {
const fileUrl = useConvexQuery(api.files.generateFileUrl, {
storageId: file.storageId,
})
const setZoomLevel = useSetAtom(zoomLevelAtom)
useEffect(
@@ -62,23 +56,12 @@ export function ImagePreviewDialog({
}
}}
>
<DialogOverlay className="flex items-center justify-center">
{!fileUrl ? (
<LoadingSpinner className="text-neutral-200 size-10" />
) : null}
</DialogOverlay>
{fileUrl ? <PreviewContent fileUrl={fileUrl} file={file} /> : null}
<PreviewContent openedFile={openedFile} />
</Dialog>
)
}
function PreviewContent({
fileUrl,
file,
}: {
fileUrl: string
file: Doc<"files">
}) {
function PreviewContent({ openedFile }: { openedFile: OpenedFile }) {
return (
<DialogContent
showCloseButton={false}
@@ -86,10 +69,10 @@ function PreviewContent({
>
<DialogHeader className="overflow-auto border-b border-b-border p-4 flex flex-row items-center justify-between">
<DialogTitle className="truncate flex-1">
{file.name}
{openedFile.file.name}
</DialogTitle>
<div className="flex flex-row items-center space-x-2">
<Toolbar fileUrl={fileUrl} file={file} />
<Toolbar openedFile={openedFile} />
<Button variant="ghost" size="icon" asChild>
<DialogClose>
<XIcon />
@@ -99,13 +82,13 @@ function PreviewContent({
</div>
</DialogHeader>
<div className="w-full h-full flex items-center justify-center max-h-[calc(100vh-10rem)] overflow-auto">
<ImagePreview fileUrl={fileUrl} file={file} />
<ImagePreview openedFile={openedFile} />
</div>
</DialogContent>
)
}
function Toolbar({ fileUrl, file }: { fileUrl: string; file: Doc<"files"> }) {
function Toolbar({ openedFile }: { openedFile: OpenedFile }) {
const setZoomLevel = useSetAtom(zoomLevelAtom)
const zoomInterval = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -159,8 +142,8 @@ function Toolbar({ fileUrl, file }: { fileUrl: string; file: Doc<"files"> }) {
</Button>
<Button asChild>
<a
href={fileUrl}
download={file.name}
href={fileShareUrl(openedFile.shareToken)}
download={openedFile.file.name}
target="_blank"
className="flex flex-row items-center"
>
@@ -191,18 +174,12 @@ function ResetZoomButton() {
)
}
function ImagePreview({
fileUrl,
file,
}: {
fileUrl: string
file: Doc<"files">
}) {
function ImagePreview({ openedFile }: { openedFile: OpenedFile }) {
const zoomLevel = useAtomValue(zoomLevelAtom)
return (
<img
src={fileUrl}
alt={file.name}
src={fileShareUrl(openedFile.shareToken)}
alt={openedFile.file.name}
className="object-contain"
style={{ transform: `scale(${zoomLevel})` }}
/>

View File

@@ -1,4 +1,4 @@
import { api } from "@fileone/convex/_generated/api"
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"

View File

@@ -1,8 +1,5 @@
import type { Id } from "@fileone/convex/_generated/dataModel"
import type {
DirectoryItem,
DirectoryItemKind,
} from "@fileone/convex/model/directories"
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"

View File

@@ -0,0 +1,94 @@
import { atom } from "jotai"
import { atomFamily } from "jotai/utils"
export enum FileUploadStatusKind {
InProgress = "InProgress",
Error = "Error",
Success = "Success",
}
export type FileUploadInProgress = {
kind: FileUploadStatusKind.InProgress
progress: number
}
export type FileUploadError = {
kind: FileUploadStatusKind.Error
error: unknown
}
export type FileUploadSuccess = {
kind: FileUploadStatusKind.Success
}
export type FileUploadStatus =
| FileUploadInProgress
| FileUploadError
| FileUploadSuccess
export const fileUploadStatusesAtom = atom<Record<string, FileUploadStatus>>({})
export const fileUploadStatusAtomFamily = atomFamily((id: string) =>
atom(
(get) => get(fileUploadStatusesAtom)[id],
(get, set, status: FileUploadStatus) => {
const fileUploads = { ...get(fileUploadStatusesAtom) }
fileUploads[id] = status
set(fileUploadStatusesAtom, fileUploads)
},
),
)
export const clearFileUploadStatusesAtom = atom(
null,
(get, set, ids: string[]) => {
const fileUploads = { ...get(fileUploadStatusesAtom) }
for (const id of ids) {
if (fileUploads[id]) {
delete fileUploads[id]
}
fileUploadStatusAtomFamily.remove(id)
}
set(fileUploadStatusesAtom, fileUploads)
},
)
export const clearAllFileUploadStatusesAtom = atom(null, (_, set) => {
set(fileUploadStatusesAtom, {})
})
export const fileUploadCountAtom = atom(
(get) => Object.keys(get(fileUploadStatusesAtom)).length,
)
export const inProgressFileUploadCountAtom = atom((get) => {
const statuses = get(fileUploadStatusesAtom)
let count = 0
for (const status in statuses) {
if (statuses[status]?.kind === FileUploadStatusKind.InProgress) {
count += 1
}
}
return count
})
export const successfulFileUploadCountAtom = atom((get) => {
const statuses = get(fileUploadStatusesAtom)
let count = 0
for (const status in statuses) {
if (statuses[status]?.kind === FileUploadStatusKind.Success) {
count += 1
}
}
return count
})
export const hasFileUploadsErrorAtom = atom((get) => {
const statuses = get(fileUploadStatusesAtom)
for (const status in statuses) {
if (statuses[status]?.kind === FileUploadStatusKind.Error) {
return true
}
}
return false
})

View File

@@ -0,0 +1,624 @@
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"
import { atomWithMutation } from "jotai-tanstack-query"
import {
CircleAlertIcon,
CircleCheckIcon,
FilePlus2Icon,
UploadCloudIcon,
XIcon,
} from "lucide-react"
import { nanoid } from "nanoid"
import type React from "react"
import { useId, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Progress } from "@/components/ui/progress"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { formatError } from "@/lib/error"
import {
clearAllFileUploadStatusesAtom,
clearFileUploadStatusesAtom,
FileUploadStatusKind,
fileUploadCountAtom,
fileUploadStatusAtomFamily,
fileUploadStatusesAtom,
hasFileUploadsErrorAtom,
successfulFileUploadCountAtom,
} from "./store"
import useUploadFile from "./use-upload-file"
type UploadFileDialogProps = {
targetDirectory: Doc<"directories">
onClose: () => void
}
// Upload file atoms
export type PickedFile = {
id: string
file: File
}
export const pickedFilesAtom = atom<PickedFile[]>([])
function useUploadFilesAtom({
targetDirectory,
}: {
targetDirectory: Doc<"directories">
}) {
const uploadFile = useUploadFile({ targetDirectory })
const store = useStore()
const options = useMemo(
() =>
mutationOptions({
mutationFn: async (files: PickedFile[]) => {
const promises = files.map((pickedFile) =>
uploadFile({
file: pickedFile.file,
onStart: () => {
store.set(
fileUploadStatusAtomFamily(pickedFile.id),
{
kind: FileUploadStatusKind.InProgress,
progress: 0,
},
)
},
onProgress: (progress) => {
store.set(
fileUploadStatusAtomFamily(pickedFile.id),
{
kind: FileUploadStatusKind.InProgress,
progress,
},
)
},
}).catch((error) => {
store.set(
fileUploadStatusAtomFamily(pickedFile.id),
{
kind: FileUploadStatusKind.Error,
error,
},
)
throw error
}),
)
return await Promise.allSettled(promises)
},
onSuccess: (results, files) => {
const remainingPickedFiles: PickedFile[] = []
results.forEach((result, i) => {
// biome-ignore lint/style/noNonNullAssertion: results lenght must match input files array length
const pickedFile = files[i]!
const statusAtom = fileUploadStatusAtomFamily(
pickedFile.id,
)
switch (result.status) {
case "fulfilled":
store.set(statusAtom, {
kind: FileUploadStatusKind.Success,
})
break
case "rejected":
store.set(statusAtom, {
kind: FileUploadStatusKind.Error,
error: result.reason,
})
remainingPickedFiles.push(pickedFile)
break
}
})
// setPickedFiles(remainingPickedFiles)
if (remainingPickedFiles.length === 0) {
toast.success("All files uploaded successfully")
}
},
onError: (error) => {
toast.error(formatError(error))
},
}),
[uploadFile, store.set],
)
return useMemo(() => atomWithMutation(() => options), [options])
}
type UploadFilesAtom = ReturnType<typeof useUploadFilesAtom>
export function UploadFileDialog({
targetDirectory,
onClose,
}: UploadFileDialogProps) {
const formId = useId()
const fileInputRef = useRef<HTMLInputElement>(null)
const setPickedFiles = useSetAtom(pickedFilesAtom)
const clearFileUploadStatuses = useSetAtom(clearFileUploadStatusesAtom)
const store = useStore()
const updateFileInputEffect = useMemo(
() =>
atomEffect((get) => {
const dataTransfer = new DataTransfer()
const pickedFiles = get(pickedFilesAtom)
for (const { file } of pickedFiles) {
dataTransfer.items.add(file)
}
if (fileInputRef.current) {
fileInputRef.current.files = dataTransfer.files
}
}),
[],
)
useAtom(updateFileInputEffect)
const uploadFilesAtom = useUploadFilesAtom({
targetDirectory,
})
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
}
function openFilePicker() {
fileInputRef.current?.click()
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = event.target.files
if (files) {
setPickedFiles((prev) => [
...prev,
...Array.from(files).map((file) => ({ id: nanoid(), file })),
])
}
}
function onUploadButtonClick() {
const uploadStatuses = store.get(fileUploadStatusesAtom)
const fileUploadCount = store.get(fileUploadCountAtom)
const pickedFiles = store.get(pickedFilesAtom)
const { mutate: uploadFiles, reset: restUploadFilesMutation } =
store.get(uploadFilesAtom)
if (pickedFiles.length === 0) {
// no files are picked, nothing to upload
return
}
if (fileUploadCount === 0) {
// no files are being uploaded, upload all picked files
uploadFiles(pickedFiles)
return
}
const successfulUploads: PickedFile["id"][] = []
const nextPickedFiles: PickedFile[] = []
for (const file of pickedFiles) {
const uploadStatus = uploadStatuses[file.id]
if (uploadStatus) {
switch (uploadStatus.kind) {
case FileUploadStatusKind.Success:
successfulUploads.push(file.id)
continue
case FileUploadStatusKind.InProgress:
continue
case FileUploadStatusKind.Error:
nextPickedFiles.push(file)
break
}
}
}
clearFileUploadStatuses(successfulUploads)
if (successfulUploads.length === pickedFiles.length) {
// all files were successfully uploaded, close the dialog
onClose()
} else {
// some files were not successfully uploaded, set the next picked files
setPickedFiles(nextPickedFiles)
restUploadFilesMutation()
uploadFiles(nextPickedFiles)
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<DialogContent className="sm:max-w-2xl">
<UploadDialogHeader
uploadFilesAtom={uploadFilesAtom}
targetDirectory={targetDirectory}
/>
<form id={formId} onSubmit={handleSubmit}>
<input
hidden
multiple
type="file"
name="files"
ref={fileInputRef}
onChange={handleFileChange}
/>
<UploadFileDropContainer>
<UploadFileArea onClick={openFilePicker} />
</UploadFileDropContainer>
</form>
<DialogFooter>
<ContinueUploadAfterSuccessfulUploadButton
uploadFilesAtom={uploadFilesAtom}
/>
<SelectMoreFilesButton
onClick={openFilePicker}
uploadFilesAtom={uploadFilesAtom}
/>
<ClearUploadErrorsButton />
<UploadButton
uploadFilesAtom={uploadFilesAtom}
onClick={onUploadButtonClick}
/>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function UploadDialogHeader({
uploadFilesAtom,
targetDirectory,
}: {
uploadFilesAtom: UploadFilesAtom
targetDirectory: Doc<"directories">
}) {
const { data: uploadResults, isPending: isUploading } =
useAtomValue(uploadFilesAtom)
const successfulUploadCount = useAtomValue(successfulFileUploadCountAtom)
let dialogTitle: string
let dialogDescription: string
if (isUploading) {
dialogTitle = "Uploading files"
dialogDescription =
"You can close the dialog while they are being uploaded in the background."
} else if (
uploadResults &&
uploadResults.length > 0 &&
successfulUploadCount === uploadResults.length
) {
dialogTitle = "Files uploaded"
dialogDescription =
"Click 'Done' to close the dialog, or select more files to upload."
} else if (targetDirectory.name) {
dialogTitle = `Upload file to "${targetDirectory.name}"`
dialogDescription = "Drag and drop files here or click to select files"
} else {
dialogTitle = "Upload file"
dialogDescription = "Drag and drop files here or click to select files"
}
return (
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
)
}
function ContinueUploadAfterSuccessfulUploadButton({
uploadFilesAtom,
}: {
uploadFilesAtom: UploadFilesAtom
}) {
const setPickedFiles = useSetAtom(pickedFilesAtom)
const clearAllFileUploadStatuses = useSetAtom(
clearAllFileUploadStatusesAtom,
)
const {
data: uploadResults,
isPending: isUploading,
reset: resetUploadFilesMutation,
} = useAtomValue(uploadFilesAtom)
const successfulUploadCount = useAtomValue(successfulFileUploadCountAtom)
if (
!uploadResults ||
uploadResults.length === 0 ||
successfulUploadCount !== uploadResults.length
) {
return null
}
function resetUploadState() {
setPickedFiles([])
clearAllFileUploadStatuses()
resetUploadFilesMutation()
}
return (
<Button
variant="outline"
onClick={resetUploadState}
disabled={isUploading}
>
Upload more files
</Button>
)
}
/**
* allows the user to select more files after they have selected some files for upload. only visible before any upload has been started.
*/
function SelectMoreFilesButton({
onClick,
uploadFilesAtom,
}: {
onClick: () => void
uploadFilesAtom: UploadFilesAtom
}) {
const pickedFiles = useAtomValue(pickedFilesAtom)
const fileUploadCount = useAtomValue(fileUploadCountAtom)
const { isPending: isUploading } = useAtomValue(uploadFilesAtom)
if (pickedFiles.length === 0 || fileUploadCount > 0) {
return null
}
return (
<Button variant="outline" onClick={onClick} disabled={isUploading}>
Select more files
</Button>
)
}
function ClearUploadErrorsButton() {
const hasUploadErrors = useAtomValue(hasFileUploadsErrorAtom)
const clearAllFileUploadStatuses = useSetAtom(
clearAllFileUploadStatusesAtom,
)
const setPickedFiles = useSetAtom(pickedFilesAtom)
if (!hasUploadErrors) {
return null
}
function clearUploadErrors() {
setPickedFiles([])
clearAllFileUploadStatuses()
}
return (
<Button variant="outline" onClick={clearUploadErrors}>
Clear uploads
</Button>
)
}
function UploadButton({
uploadFilesAtom,
onClick,
}: {
uploadFilesAtom: UploadFilesAtom
onClick: () => void
}) {
const pickedFiles = useAtomValue(pickedFilesAtom)
const hasUploadErrors = useAtomValue(hasFileUploadsErrorAtom)
const fileUploadCount = useAtomValue(fileUploadCountAtom)
const { isPending: isUploading } = useAtomValue(uploadFilesAtom)
let label: string
if (hasUploadErrors) {
label = "Retry failed uploads"
} else if (pickedFiles.length > 0) {
if (fileUploadCount > 0) {
label = "Done"
} else {
label = `Upload ${pickedFiles.length} files`
}
} else {
label = "Upload"
}
return (
<Button onClick={onClick} disabled={isUploading} loading={isUploading}>
{label}
</Button>
)
}
function UploadFileDropContainer({ children }: React.PropsWithChildren) {
const [draggedFiles, setDraggedFiles] = useState<DataTransferItem[]>([])
const setPickedFiles = useSetAtom(pickedFilesAtom)
function handleDragOver(e: React.DragEvent) {
e.preventDefault()
const items = Array.from(e.dataTransfer.items)
const draggedFiles = []
for (const item of items) {
if (item.kind === "file") {
draggedFiles.push(item)
}
}
setDraggedFiles(draggedFiles)
}
function handleDragLeave() {
setDraggedFiles([])
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
const items = Array.from(e.dataTransfer.items)
const droppedFiles: PickedFile[] = []
for (const item of items) {
const file = item.getAsFile()
if (file) {
droppedFiles.push({
id: nanoid(),
file,
})
}
}
setPickedFiles((prev) => [...prev, ...droppedFiles])
setDraggedFiles([])
}
return (
<section
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
aria-label="File drop area"
className="relative"
>
{children}
{draggedFiles.length > 0 ? (
<div className="border border-accent bg-primary text-primary-foreground absolute inset-0 rounded flex flex-col items-center justify-center text-sm space-y-1">
<FilePlus2Icon className="animate-bounce" />
<p>Drop {draggedFiles.length} files here</p>
</div>
) : null}
</section>
)
}
// tag: uploadfilearea area fileuploadarea
function UploadFileArea({ onClick }: { onClick: () => void }) {
const [pickedFiles, setPickedFiles] = useAtom(pickedFilesAtom)
function removeSelectedFile(file: PickedFile) {
setPickedFiles((prev) => prev.filter((f) => f.id !== file.id))
}
if (pickedFiles.length > 0) {
return (
<PickedFilesList
pickedFiles={pickedFiles}
onRemoveFile={removeSelectedFile}
/>
)
}
return (
<button
type="button"
className="w-full h-48 border-2 rounded border-dashed border-border flex flex-col items-center justify-center text-muted-foreground text-sm space-y-1 hover:bg-muted transition-all hover:border-solid"
onClick={onClick}
>
<UploadCloudIcon />
<span>Click to select files or drag and drop them here</span>
</button>
)
}
function PickedFilesList({
pickedFiles,
onRemoveFile,
}: {
pickedFiles: PickedFile[]
onRemoveFile: (file: PickedFile) => void
}) {
return (
<ul className="min-h-48 border border-border rounded bg-card text-sm">
{pickedFiles.map((file: PickedFile) => (
<PickedFileItem
key={file.id}
file={file}
onRemove={onRemoveFile}
/>
))}
</ul>
)
}
function PickedFileItem({
file: pickedFile,
onRemove,
}: {
file: PickedFile
onRemove: (file: PickedFile) => void
}) {
const fileUploadAtom = fileUploadStatusAtomFamily(pickedFile.id)
const fileUpload = useAtomValue(fileUploadAtom)
const { file, id } = pickedFile
let statusIndicator: React.ReactNode
if (!fileUpload) {
statusIndicator = (
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(pickedFile)}
>
<XIcon className="size-4" />
</Button>
)
} else {
switch (fileUpload.kind) {
case FileUploadStatusKind.InProgress:
statusIndicator = (
<Progress
className="max-w-20"
value={fileUpload.progress * 100}
/>
)
break
case FileUploadStatusKind.Error:
statusIndicator = (
<Tooltip>
<TooltipTrigger>
<CircleAlertIcon className="pr-2 text-destructive" />
</TooltipTrigger>
<TooltipContent>
<p>
Failed to upload file:{" "}
{formatError(fileUpload.error)}
</p>
</TooltipContent>
</Tooltip>
)
break
case FileUploadStatusKind.Success:
statusIndicator = (
<Tooltip>
<TooltipTrigger>
<CircleCheckIcon className="pr-2 text-green-500" />
</TooltipTrigger>
<TooltipContent>
<p>File uploaded</p>
</TooltipContent>
</Tooltip>
)
break
}
}
return (
<li
className="pl-3 pr-1 py-0.5 h-8 hover:bg-muted flex justify-between items-center border-b border-border"
key={id}
>
<p>{file.name} </p>
{statusIndicator}
</li>
)
}

View File

@@ -1,11 +1,11 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc, Id } from "@fileone/convex/_generated/dataModel"
import * as Err from "@fileone/convex/model/error"
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/model/filesystem"
} from "@fileone/convex/filesystem"
import { useMutation } from "@tanstack/react-query"
import { useMutation as useContextMutation } from "convex/react"
import type { PrimitiveAtom } from "jotai"
@@ -54,7 +54,7 @@ export function useFileDrop({
errors: Err.ApplicationErrorData[]
}) => {
const conflictCount = errors.reduce((acc, error) => {
if (error.code === Err.Code.Conflict) {
if (error.code === Err.ErrorCode.Conflict) {
return acc + 1
}
return acc
@@ -71,7 +71,6 @@ export function useFileDrop({
const handleDrop = (_e: React.DragEvent) => {
const dragInfo = store.get(dragInfoAtom)
console.log("handleDrop", { dragInfo, destItem })
if (dragInfo && destItem) {
const items = dragInfo.items.filter(
(item) => !isSameHandle(item, destItem),

View File

@@ -0,0 +1,55 @@
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,21 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -1,7 +1,9 @@
import {
Code as ErrorCode,
type ApplicationErrorData,
ErrorCode,
isApplicationError,
} from "@fileone/convex/model/error"
} from "@fileone/convex/error"
import { ConvexError } from "convex/values"
import { toast } from "sonner"
const ERROR_MESSAGE = {
@@ -9,13 +11,19 @@ const ERROR_MESSAGE = {
[ErrorCode.FileExists]: "File already exists",
[ErrorCode.Internal]: "Internal application error",
[ErrorCode.Conflict]: "Conflict",
[ErrorCode.DirectoryNotFound]: "Directory not found",
[ErrorCode.FileNotFound]: "File not found",
[ErrorCode.Unauthenticated]: "Unauthenticated",
[ErrorCode.NotFound]: "Not found",
[ErrorCode.StorageQuotaExceeded]: "Storage is full",
} as const
export function isApplicationConvexError(
error: unknown,
): error is ConvexError<ApplicationErrorData> {
return error instanceof ConvexError && isApplicationError(error.data)
}
export function formatError(error: unknown): string {
if (isApplicationError(error)) {
if (isApplicationConvexError(error)) {
return ERROR_MESSAGE[error.data.code]
}
if (error instanceof Error) {
@@ -25,8 +33,12 @@ export function formatError(error: unknown): string {
}
export function defaultOnError(error: unknown) {
console.log(error)
toast.error(formatError(error))
if (isApplicationConvexError(error)) {
toast.error(formatError(error))
} else {
console.error("Catastrophic error:", error)
toast.error("An unexpected error occurred")
}
}
export function withDefaultOnError(fn: (error: unknown) => void) {

View File

@@ -9,14 +9,22 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SignUpRouteImport } from './routes/sign-up'
import { Route as LoginRouteImport } from './routes/login'
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',
path: '/sign-up',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@@ -41,6 +49,12 @@ 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',
@@ -53,60 +67,97 @@ 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
'/sign-up': typeof SignUpRoute
'/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
'/sign-up': typeof SignUpRoute
'/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
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute
'/sign-up': typeof SignUpRoute
'/_authenticated/_sidebar-layout': typeof AuthenticatedSidebarLayoutRouteWithChildren
'/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
fullPaths:
| '/login'
| '/sign-up'
| '/login/callback'
| '/'
| '/home'
| '/recent'
| '/directories/$directoryId'
| '/trash/directories/$directoryId'
fileRoutesByTo: FileRoutesByTo
to: '/login' | '/login/callback' | '/' | '/home' | '/directories/$directoryId'
to:
| '/login'
| '/sign-up'
| '/login/callback'
| '/'
| '/home'
| '/recent'
| '/directories/$directoryId'
| '/trash/directories/$directoryId'
id:
| '__root__'
| '/_authenticated'
| '/login'
| '/sign-up'
| '/_authenticated/_sidebar-layout'
| '/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 {
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
LoginRoute: typeof LoginRoute
SignUpRoute: typeof SignUpRoute
LoginCallbackRoute: typeof LoginCallbackRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/sign-up': {
id: '/sign-up'
path: '/sign-up'
fullPath: '/sign-up'
preLoaderRoute: typeof SignUpRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@@ -142,6 +193,13 @@ 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'
@@ -156,19 +214,32 @@ 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 =
@@ -193,6 +264,7 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
AuthenticatedRoute: AuthenticatedRouteWithChildren,
LoginRoute: LoginRoute,
SignUpRoute: SignUpRoute,
LoginCallbackRoute: LoginCallbackRoute,
}
export const routeTree = rootRouteImport

View File

@@ -1,18 +1,22 @@
import "@/styles/globals.css"
import { ConvexProviderWithAuthKit } from "@convex-dev/workos"
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { createRootRoute, Outlet } from "@tanstack/react-router"
import { AuthKitProvider, useAuth } from "@workos-inc/authkit-react"
import { ConvexReactClient } from "convex/react"
import { toast } from "sonner"
import { Toaster } from "@/components/ui/sonner"
import { formatError } from "@/lib/error"
import { useKeyboardModifierListener } from "@/lib/keyboard"
import { authClient } from "../auth"
export const Route = createRootRoute({
component: RootLayout,
})
const convexClient = new ConvexReactClient(process.env.BUN_PUBLIC_CONVEX_URL!)
const convexClient = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL, {
verbose: true,
expectAuth: true,
})
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
@@ -30,17 +34,13 @@ function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<AuthKitProvider
clientId={process.env.BUN_PUBLIC_WORKOS_CLIENT_ID!}
redirectUri={process.env.BUN_PUBLIC_WORKOS_REDIRECT_URI!}
<ConvexBetterAuthProvider
client={convexClient}
authClient={authClient}
>
<ConvexProviderWithAuthKit
client={convexClient}
useAuth={useAuth}
>
<Outlet />
</ConvexProviderWithAuthKit>
</AuthKitProvider>
<Outlet />
<Toaster />
</ConvexBetterAuthProvider>
</QueryClientProvider>
)
}

View File

@@ -4,7 +4,6 @@ import {
Outlet,
useLocation,
} from "@tanstack/react-router"
import { useAuth } from "@workos-inc/authkit-react"
import {
Authenticated,
AuthLoading,
@@ -12,6 +11,7 @@ import {
useConvexAuth,
} from "convex/react"
import { useEffect, useState } from "react"
import { authClient, SessionContext } from "@/auth"
import { LoadingSpinner } from "@/components/ui/loading-spinner"
export const Route = createFileRoute("/_authenticated")({
@@ -20,8 +20,8 @@ export const Route = createFileRoute("/_authenticated")({
function AuthenticatedLayout() {
const { search } = useLocation()
const { isLoading } = useConvexAuth()
const { isLoading: authKitLoading } = useAuth()
const { isLoading, isAuthenticated } = useConvexAuth()
const { data: session, isPending: sessionLoading } = authClient.useSession()
const [hasProcessedAuth, setHasProcessedAuth] = useState(false)
// Check if we're in the middle of processing an auth code
@@ -29,17 +29,17 @@ function AuthenticatedLayout() {
// Track when auth processing is complete
useEffect(() => {
if (!authKitLoading && !isLoading) {
if (!sessionLoading && !isLoading) {
// Delay to ensure auth state is fully synchronized
const timer = setTimeout(() => {
setHasProcessedAuth(true)
}, 500)
}, 0)
return () => clearTimeout(timer)
}
}, [authKitLoading, isLoading])
}, [sessionLoading, isLoading])
// Show loading during auth code processing or while auth state is syncing
if (hasAuthCode || authKitLoading || isLoading || !hasProcessedAuth) {
if (hasAuthCode || sessionLoading || isLoading || !hasProcessedAuth) {
return (
<div className="flex h-screen w-full items-center justify-center">
<LoadingSpinner className="size-10" />
@@ -50,7 +50,13 @@ function AuthenticatedLayout() {
return (
<>
<Authenticated>
<Outlet />
{session ? (
<SessionContext value={session}>
<Outlet />
</SessionContext>
) : (
<Outlet />
)}
</Authenticated>
<Unauthenticated>
<Navigate replace to="/login" />

View File

@@ -1,7 +1,5 @@
import { createFileRoute, Outlet } from "@tanstack/react-router"
import { useQuery as useConvexQuery } from "convex/react"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { Toaster } from "@/components/ui/sonner"
import { DashboardSidebar } from "@/dashboard/dashboard-sidebar"
export const Route = createFileRoute("/_authenticated/_sidebar-layout")({
@@ -17,7 +15,6 @@ function RouteComponent() {
<Outlet />
</SidebarInset>
</div>
<Toaster />
</SidebarProvider>
)
}

View File

@@ -0,0 +1,430 @@
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 { 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,
PlusIcon,
TextCursorInputIcon,
TrashIcon,
} from "lucide-react"
import { useCallback, useContext } from "react"
import { toast } from "sonner"
import { DirectoryIcon } from "@/components/icons/directory-icon"
import { TextFileIcon } from "@/components/icons/text-file-icon"
import { Button } from "@/components/ui/button"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { WithAtom } from "@/components/with-atom"
import { backgroundTaskProgressAtom } from "@/dashboard/state"
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 { NewDirectoryDialog } from "@/directories/directory-page/new-directory-dialog"
import { RenameFileDialog } from "@/directories/directory-page/rename-file-dialog"
import { DirectoryPathBreadcrumb } from "@/directories/directory-path-breadcrumb"
import { FilePreviewDialog } from "@/files/file-preview-dialog"
import { inProgressFileUploadCountAtom } from "@/files/store"
import { UploadFileDialog } from "@/files/upload-file-dialog"
import type { FileDragInfo } from "@/files/use-file-drop"
export const Route = createFileRoute(
"/_authenticated/_sidebar-layout/directories/$directoryId",
)({
component: RouteComponent,
})
enum DialogKind {
NewDirectory = "NewDirectory",
UploadFile = "UploadFile",
}
type NewDirectoryDialogData = {
kind: DialogKind.NewDirectory
}
type UploadFileDialogData = {
kind: DialogKind.UploadFile
directory: Doc<"directories">
}
type ActiveDialogData = NewDirectoryDialogData | UploadFileDialogData
// MARK: atoms
const contextMenuTargetItemsAtom = atom<FileSystemItem[]>([])
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 itemBeingRenamedAtom = atom<{
originalItem: FileSystemItem
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 directoryUrlById = useCallback(
(directoryId: Id<"directories">) => `/directories/${directoryId}`,
[],
)
if (!directory || !directoryContent || !rootDirectory) {
return <DirectoryPageSkeleton />
}
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="All Files"
directoryUrlFn={directoryUrlById}
fileDragInfoAtom={fileDragInfoAtom}
/>
<div className="ml-auto flex flex-row gap-2">
<NewDirectoryItemDropdown />
<UploadFileButton />
</div>
</header>
{/* DirectoryContentContextMenu must wrap div instead of DirectoryContentTable, otherwise radix will throw "event.preventDefault is not a function" error, idk why */}
<DirectoryContentContextMenu>
<div className="w-full">
<_DirectoryContentTable />
</div>
</DirectoryContentContextMenu>
<WithAtom atom={activeDialogDataAtom}>
{(data, setData) => (
<>
<NewDirectoryDialog
open={data?.kind === DialogKind.NewDirectory}
directoryId={directory._id}
onOpenChange={(open) => {
if (!open) {
setData(null)
}
}}
/>
{data?.kind === DialogKind.UploadFile && (
<UploadFileDialog
targetDirectory={data.directory}
onClose={() => setData(null)}
/>
)}
</>
)}
</WithAtom>
<WithAtom atom={itemBeingRenamedAtom}>
{(itemBeingRenamed, setItemBeingRenamed) => {
if (!itemBeingRenamed) return null
return (
<RenameFileDialog
item={itemBeingRenamed.originalItem}
onRenameSuccess={() => {
toast.success("File renamed successfully")
setItemBeingRenamed(null)
}}
onClose={() => setItemBeingRenamed(null)}
/>
)
}}
</WithAtom>
<WithAtom atom={openedFileAtom}>
{(openedFile, setOpenedFile) => {
if (!openedFile) return null
return (
<FilePreviewDialog
openedFile={openedFile}
onClose={() => setOpenedFile(null)}
/>
)
}}
</WithAtom>
</DirectoryPageContext>
)
}
// MARK: directory table
function _DirectoryContentTable() {
const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom)
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 directoryUrlFn = useCallback(
(directory: Doc<"directories">) => `/directories/${directory._id}`,
[],
)
const handleContextMenuRequest = (
row: Row<FileSystemItem>,
table: Table<FileSystemItem>,
) => {
if (row.getIsSelected()) {
setContextMenuTargetItems(
table.getSelectedRowModel().rows.map((row) => row.original),
)
} else {
setContextMenuTargetItems([row.original])
}
}
return (
<DirectoryContentTable
hiddenItems={optimisticDeletedItems}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={handleContextMenuRequest}
onOpenFile={onTableOpenFile}
/>
)
}
// ==================================
// MARK: ctx menu
// tags: ctxmenu contextmenu directorycontextmenu
function DirectoryContentContextMenu({
children,
}: {
children: React.ReactNode
}) {
const store = useStore()
const [target, setTarget] = useAtom(contextMenuTargetItemsAtom)
const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom)
const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom)
const moveToTrashMutation = useContextMutation(api.filesystem.moveToTrash)
const { mutate: moveToTrash } = useMutation({
mutationFn: moveToTrashMutation,
onMutate: ({ handles }) => {
setBackgroundTaskProgress({
label: "Moving items to trash…",
})
setOptimisticDeletedItems(
(prev) =>
new Set([...prev, ...handles.map((handle) => handle.id)]),
)
},
onSuccess: ({ deleted, errors }, { handles }) => {
setBackgroundTaskProgress(null)
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`,
)
}
},
onError: (_err, { handles }) => {
setOptimisticDeletedItems((prev) => {
const newSet = new Set(prev)
for (const handle of handles) {
newSet.delete(handle.id)
}
return newSet
})
},
})
const handleDelete = () => {
const selectedItems = store.get(contextMenuTargetItemsAtom)
if (selectedItems.length > 0) {
moveToTrash({
handles: selectedItems.map(newFileSystemHandle),
})
}
}
return (
<ContextMenu
onOpenChange={(open) => {
if (!open) {
setTarget([])
}
}}
>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
{target.length > 0 && (
<ContextMenuContent>
<RenameMenuItem />
<ContextMenuItem
variant="destructive"
onClick={handleDelete}
>
<TrashIcon />
Move to trash
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
)
}
function RenameMenuItem() {
const store = useStore()
const target = useAtomValue(contextMenuTargetItemsAtom)
const setItemBeingRenamed = useSetAtom(itemBeingRenamedAtom)
const handleRename = () => {
const selectedItems = store.get(contextMenuTargetItemsAtom)
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>
)
}
// ==================================
// tags: upload, uploadfile, uploadfilebutton, fileupload, fileuploadbutton
function UploadFileButton() {
const { directory } = useContext(DirectoryPageContext)
const setActiveDialogData = useSetAtom(activeDialogDataAtom)
const inProgressFileUploadCount = useAtomValue(
inProgressFileUploadCountAtom,
)
const handleClick = () => {
setActiveDialogData({
kind: DialogKind.UploadFile,
directory: directory,
})
}
if (inProgressFileUploadCount > 0) {
return (
<Button size="sm" type="button" loading onClick={handleClick}>
Uploading {inProgressFileUploadCount} files
</Button>
)
}
return (
<Button size="sm" type="button" onClick={handleClick}>
Upload files
</Button>
)
}
function NewDirectoryItemDropdown() {
const [activeDialogData, setActiveDialogData] =
useAtom(activeDialogDataAtom)
const addNewDirectory = () => {
setActiveDialogData({
kind: DialogKind.NewDirectory,
})
}
const handleCloseAutoFocus = (event: Event) => {
// If we just created a new item, prevent the dropdown from restoring focus to the trigger
if (activeDialogData) {
event.preventDefault()
}
}
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 onCloseAutoFocus={handleCloseAutoFocus}>
<DropdownMenuItem>
<TextFileIcon />
Text file
</DropdownMenuItem>
<DropdownMenuItem onClick={addNewDirectory}>
<DirectoryIcon />
Directory
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,119 @@
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

@@ -0,0 +1,398 @@
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="/home" />
return <Navigate replace to="/recent" />
}

View File

@@ -0,0 +1,184 @@
import { useMutation } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { GalleryVerticalEnd } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { type AuthErrorCode, authClient, BetterAuthError } from "../auth"
export const Route = createFileRoute("/login")({
component: RouteComponent,
})
function RouteComponent() {
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-lg flex-col gap-6">
<a
href="#"
className="flex items-center gap-2 self-center font-medium text-xl"
>
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-4" />
</div>
Drexa
</a>
<LoginFormCard />
</div>
</div>
)
}
function LoginFormCard({ className, ...props }: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Welcome back</CardTitle>
<CardDescription>
Login with your Apple or Google account
</CardDescription>
</CardHeader>
<CardContent>
<LoginForm />
</CardContent>
</Card>
<FieldDescription className="px-6 text-center">
By clicking continue, you agree to our{" "}
<a href="#">Terms of Service</a> and{" "}
<a href="#">Privacy Policy</a>.
</FieldDescription>
</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,
})
if (error) {
throw new BetterAuthError(error.code as AuthErrorCode)
}
return signInData
},
})
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
signIn({
email: formData.get("email") as string,
password: formData.get("password") as string,
})
}
return (
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<Button
disabled={isPending}
variant="outline"
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<title>Apple logo</title>
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
Login with Apple
</Button>
<Button
disabled={isPending}
variant="outline"
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<title>Google logo</title>
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
Login with Google
</Button>
</Field>
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card">
Or continue with
</FieldSeparator>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
disabled={isPending}
name="email"
type="email"
placeholder="m@example.com"
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Password</FieldLabel>
<a
href="#"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input
disabled={isPending}
name="password"
type="password"
required
/>
</Field>
<Field>
<Button disabled={isPending} type="submit">
{isPending ? "Logging in…" : "Login"}
</Button>
<FieldDescription className="text-center">
Don&apos;t have an account? <a href="#">Sign up</a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
)
}

View File

@@ -1,4 +1,4 @@
import { api } from "@fileone/convex/_generated/api"
import { api } from "@fileone/convex/api"
import { useMutation } from "@tanstack/react-query"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useConvexAuth, useMutation as useConvexMutation } from "convex/react"
@@ -19,7 +19,6 @@ function RouteComponent() {
useEffect(() => {
if (!isLoadingConvexAuth && isAuthenticated && !isSyncingUser) {
console.log({ isLoadingConvexAuth, isAuthenticated, isSyncingUser })
syncUser(undefined, {
onSuccess: () => {
navigate({

View File

@@ -0,0 +1,218 @@
import { useMutation } from "@tanstack/react-query"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { GalleryVerticalEnd } from "lucide-react"
import type React from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { type AuthErrorCode, authClient, BetterAuthError } from "../auth"
export const Route = createFileRoute("/sign-up")({
component: SignupPage,
})
type SignUpParams = {
displayName: string
email: string
password: string
confirmPassword: string
}
class PasswordMismatchError extends Error {
constructor() {
super("Passwords do not match")
}
}
function SignupPage() {
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-xl flex-col gap-6">
<a
href="#"
className="flex items-center gap-2 self-center font-medium text-xl"
>
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-4" />
</div>
Drexa
</a>
<SignUpFormContainer>
<SignupForm />
</SignUpFormContainer>
</div>
</div>
)
}
function SignUpFormContainer({ children }: React.PropsWithChildren) {
return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">
Create your account
</CardTitle>
<CardDescription>
Enter your email below to create your account
</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
</div>
)
}
function SignupForm() {
const navigate = useNavigate()
const {
mutate: signUp,
isPending,
error: signUpError,
} = useMutation({
mutationFn: async (data: SignUpParams) => {
if (data.password !== data.confirmPassword) {
throw new PasswordMismatchError()
}
const { data: signUpData, error } = await authClient.signUp.email({
name: data.displayName,
email: data.email,
password: data.password,
})
if (error) {
throw new BetterAuthError(error.code as AuthErrorCode)
}
return signUpData
},
onSuccess: () => {
navigate({
to: "/",
replace: true,
})
},
onError: (error) => {
console.error(error)
toast.error("Unable to create your account")
},
})
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
signUp({
displayName: formData.get("displayName") as string,
email: formData.get("email") as string,
password: formData.get("password") as string,
confirmPassword: formData.get("confirmPassword") as string,
})
}
let passwordFieldError = null
let emailFieldError = null
if (signUpError instanceof BetterAuthError) {
switch (signUpError.errorCode) {
case "PASSWORD_TOO_SHORT":
passwordFieldError =
"Password must be at least 8 characters long"
break
case "INVALID_EMAIL":
emailFieldError = "Invalid email address"
break
default:
passwordFieldError = null
}
} else if (signUpError instanceof PasswordMismatchError) {
passwordFieldError = "Passwords do not match"
}
return (
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="name">Your Name</FieldLabel>
<Input
disabled={isPending}
name="displayName"
type="text"
placeholder="John Doe"
required
/>
<FieldDescription>
This is how you will be referred to on Drexa. You can
change this later.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
disabled={isPending}
name="email"
type="email"
placeholder="m@example.com"
required
/>
{emailFieldError ? (
<FieldError>{emailFieldError}</FieldError>
) : null}
</Field>
<Field>
<Field className="grid grid-rows-2 md:grid-rows-1 md:grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input
disabled={isPending}
name="password"
type="password"
required
/>
</Field>
<Field>
<FieldLabel htmlFor="confirm-password">
Confirm Password
</FieldLabel>
<Input
disabled={isPending}
name="confirmPassword"
type="password"
required
/>
</Field>
</Field>
{passwordFieldError ? (
<FieldError>{passwordFieldError}</FieldError>
) : (
<FieldDescription>
Must be at least 8 characters long.
</FieldDescription>
)}
</Field>
<Field>
<Button
type="submit"
loading={isPending}
disabled={isPending}
>
{isPending ? "Creating account…" : "Create Account"}
</Button>
<FieldDescription className="text-center">
Already have an account? <a href="/sign-in">Sign in</a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
)
}

10
apps/drive-web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
declare global {
interface ImportMetaEnv {
readonly VITE_CONVEX_URL: string
readonly VITE_CONVEX_SITE_URL: string
}
}
export {}

View File

@@ -0,0 +1,39 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"target": "ES2020",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"include": ["src"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,30 @@
import path from "node:path"
import tailwindcss from "@tailwindcss/vite"
import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [TanStackRouterVite(), react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 3000,
host: true,
fs: {
allow: [".."],
},
},
optimizeDeps: {
include: ["convex/react", "convex/values", "convex-helpers"],
// Workaround for better-auth bug: https://github.com/better-auth/better-auth/issues/4457
// Vite's esbuild incorrectly transpiles better-call dependency causing 'super' keyword errors
exclude: ["better-auth", "@convex-dev/better-auth"],
esbuildOptions: {
target: "esnext",
},
},
})

View File

@@ -0,0 +1,4 @@
CONVEX_URL=
# api key used to auth with the convex backend
# use the drexa cli to generate an api key, then add the api key to the api key table via the convex dashboard
API_KEY=

34
apps/file-proxy/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
apps/file-proxy/README.md Normal file
View File

@@ -0,0 +1,15 @@
# drive-file-proxy
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.0. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

14
apps/file-proxy/auth.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createMiddleware } from "hono/factory"
export type ApiKeyContextVariable = {
apiKey: string
}
const apiKeyMiddleware = createMiddleware<{ Variables: ApiKeyContextVariable }>(
async (c, next) => {
c.set("apiKey", process.env.API_KEY)
await next()
},
)
export { apiKeyMiddleware }

16
apps/file-proxy/convex.ts Normal file
View File

@@ -0,0 +1,16 @@
import { ConvexHttpClient } from "convex/browser"
import { createMiddleware } from "hono/factory"
const _client = new ConvexHttpClient(process.env.CONVEX_URL)
export type ConvexContextVariables = {
convex: ConvexHttpClient
}
export const convexMiddleware = createMiddleware<{
Variables: ConvexContextVariables
}>(async (c, next) => {
c.var
c.set("convex", _client)
await next()
})

6
apps/file-proxy/env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module "bun" {
interface Env {
CONVEX_URL: string
API_KEY: string
}
}

39
apps/file-proxy/files.ts Normal file
View File

@@ -0,0 +1,39 @@
import { api } from "@fileone/convex/api"
import { newRouter } from "./router"
const r = newRouter().basePath("/files")
r.get(":shareToken", async (c) => {
const shareToken = c.req.param("shareToken")
if (!shareToken) {
return c.json({ error: "not found" }, 404)
}
const fileShare = await c.var.convex.query(api.fileshare.findFileShare, {
apiKey: c.var.apiKey,
shareToken,
})
if (!fileShare) {
return c.json({ error: "not found" }, 404)
}
const fileUrl = await c.var.convex.query(api.filesystem.getStorageUrl, {
apiKey: c.var.apiKey,
storageId: fileShare.storageId,
})
if (!fileUrl) {
return c.json({ error: "not found" }, 404)
}
const fileResponse = await fetch(fileUrl)
if (!fileResponse.ok) {
return c.json({ error: "not found" }, 404)
}
return new Response(fileResponse.body, {
status: fileResponse.status,
headers: fileResponse.headers,
})
})
export { r as files }

16
apps/file-proxy/index.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Hono } from "hono"
import { apiKeyMiddleware } from "./auth"
import { convexMiddleware } from "./convex"
import { files } from "./files"
const app = new Hono()
app.use(convexMiddleware)
app.use(apiKeyMiddleware)
app.route("/", files)
export default {
port: 8081,
fetch: app.fetch,
}

View File

@@ -0,0 +1,21 @@
{
"name": "@drexa/file-proxy",
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"dev": "bun --hot run index.ts"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@fileone/convex": "workspace:*",
"arktype": "^2.1.23",
"convex": "^1.28.0",
"hono": "^4.10.1"
}
}

11
apps/file-proxy/router.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Hono } from "hono"
import type { ApiKeyContextVariable } from "./auth"
import type { ConvexContextVariables } from "./convex"
type ContextVariables = ConvexContextVariables & ApiKeyContextVariable
export function newRouter() {
return new Hono<{
Variables: ContextVariables
}>()
}

View File

@@ -1,7 +1,7 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
@@ -21,16 +21,9 @@
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["dist", "node_modules"]
}
}

Some files were not shown because too many files have changed in this diff Show More