Compare commits

...

70 Commits

Author SHA1 Message Date
834517f3c0 fix: check for correct user exist err in register 2025-11-26 01:45:28 +00:00
06c3951293 fix: ret invalid cred if usr not found in login 2025-11-26 01:42:52 +00:00
389fe35a0a feat: impl bearer auth middleware 2025-11-26 01:09:42 +00:00
81e3f7af75 build: add vscode ext install script 2025-11-26 00:08:22 +00:00
1feac70f7f feat: initial backend scaffolding
migrating away from convex

Co-authored-by: Ona <no-reply@ona.com>
2025-11-10 00:19:30 +00:00
5cc13a34b2 chore: remove console log 2025-11-08 23:17:45 +00:00
879287f8bf feat: implement file cutting 2025-11-08 23:17:36 +00:00
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
192 changed files with 10925 additions and 2008 deletions

View File

@@ -7,26 +7,22 @@
"features": { "features": {
"ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": false
},
"ghcr.io/tailscale/codespace/tailscale": { "ghcr.io/tailscale/codespace/tailscale": {
"version": "latest" "version": "latest"
},
"ghcr.io/devcontainers/features/go:1": {
"version": "1.25.4",
"golangciLintVersion": "2.6.1"
} }
}, },
"postCreateCommand": "./scripts/setup-git.sh", "postCreateCommand": "./scripts/setup-git.sh && ./scripts/install-vscode-extensions.sh",
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": [
"biomejs.biome", "biomejs.biome",
"bradlc.vscode-tailwindcss", "bradlc.vscode-tailwindcss",
"ms-vscode.vscode-typescript-next",
"esbenp.prettier-vscode",
"ms-vscode.vscode-json",
"formulahendry.auto-rename-tag",
"christian-kohler.path-intellisense", "christian-kohler.path-intellisense",
"ms-vscode.vscode-eslint", "golang.go"
"convex.convex-vscode"
], ],
"settings": { "settings": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",

View File

@@ -1,10 +1,13 @@
# this is the url to the convex instance (NOT THE DASHBOARD)
CONVEX_SELF_HOSTED_URL= CONVEX_SELF_HOSTED_URL=
CONVEX_SELF_HOSTED_ADMIN_KEY= 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_CONVEX_URL=
BUN_PUBLIC_WORKOS_CLIENT_ID= # this is the convex url for invoking http actions
BUN_PUBLIC_WORKOS_REDIRECT_URI= BUN_PUBLIC_CONVEX_SITE_URL=

View File

@@ -5,7 +5,8 @@ backend: convex
# Project structure # Project structure
This project uses npm workspaces. This project uses npm workspaces.
- `packages/convex` - convex functions and models - `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 - `packages/path` - path utils
# General Guidelines # General Guidelines

View File

@@ -0,0 +1,22 @@
package main
import (
"fmt"
"log"
"github.com/get-drexa/drexa/internal/drexa"
"github.com/joho/godotenv"
)
func main() {
_ = godotenv.Load()
config, err := drexa.ServerConfigFromEnv()
if err != nil {
log.Fatal(err)
}
server := drexa.NewServer(*config)
log.Fatal(server.Listen(fmt.Sprintf(":%d", config.Port)))
}

View File

@@ -0,0 +1,13 @@
package main
import (
"log"
"github.com/get-drexa/drexa/internal/database"
)
func main() {
if err := database.RunMigrations(); err != nil {
log.Fatalf("Failed to run migrations: %v", err)
}
}

39
apps/backend/go.mod Normal file
View File

@@ -0,0 +1,39 @@
module github.com/get-drexa/drexa
go 1.25.4
require (
github.com/gofiber/fiber/v2 v2.52.9
github.com/google/uuid v1.6.0
github.com/uptrace/bun v1.2.15
golang.org/x/crypto v0.40.0
)
require (
github.com/joho/godotenv v1.5.1 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
mellium.im/sasl v0.3.2 // indirect
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/uptrace/bun/dialect/pgdialect v1.2.15
github.com/uptrace/bun/driver/pgdriver v1.2.15
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
golang.org/x/sys v0.34.0 // indirect
)

61
apps/backend/go.sum Normal file
View File

@@ -0,0 +1,61 @@
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/uptrace/bun v1.2.15 h1:Ut68XRBLDgp9qG9QBMa9ELWaZOmzHNdczHQdrOZbEFE=
github.com/uptrace/bun v1.2.15/go.mod h1:Eghz7NonZMiTX/Z6oKYytJ0oaMEJ/eq3kEV4vSqG038=
github.com/uptrace/bun/dialect/pgdialect v1.2.15 h1:er+/3giAIqpfrXJw+KP9B7ujyQIi5XkPnFmgjAVL6bA=
github.com/uptrace/bun/dialect/pgdialect v1.2.15/go.mod h1:QSiz6Qpy9wlGFsfpf7UMSL6mXAL1jDJhFwuOVacCnOQ=
github.com/uptrace/bun/driver/pgdriver v1.2.15 h1:eZZ60ZtUUE6jjv6VAI1pCMaTgtx3sxmChQzwbvchOOo=
github.com/uptrace/bun/driver/pgdriver v1.2.15/go.mod h1:s2zz/BAeScal4KLFDI8PURwATN8s9RDBsElEbnPAjv4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=

View File

@@ -0,0 +1,24 @@
package auth
import (
"errors"
"fmt"
)
var ErrUnauthenticatedRequest = errors.New("unauthenticated request")
type InvalidAccessTokenError struct {
err error
}
func newInvalidAccessTokenError(err error) *InvalidAccessTokenError {
return &InvalidAccessTokenError{err}
}
func (e *InvalidAccessTokenError) Error() string {
return fmt.Sprintf("invalid access token: %v", e.err)
}
func (e *InvalidAccessTokenError) Unwrap() error {
return e.err
}

View File

@@ -0,0 +1,92 @@
package auth
import (
"errors"
"github.com/get-drexa/drexa/internal/user"
"github.com/gofiber/fiber/v2"
)
const authServiceKey = "authService"
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type registerRequest struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
}
type loginResponse struct {
User user.User `json:"user"`
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
}
func RegisterAPIRoutes(api fiber.Router, s *Service) {
auth := api.Group("/auth", func(c *fiber.Ctx) error {
c.Locals(authServiceKey, s)
return c.Next()
})
auth.Post("/login", login)
auth.Post("/register", register)
}
func mustAuthService(c *fiber.Ctx) *Service {
return c.Locals(authServiceKey).(*Service)
}
func login(c *fiber.Ctx) error {
s := mustAuthService(c)
req := new(loginRequest)
if err := c.BodyParser(req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"})
}
result, err := s.LoginWithEmailAndPassword(c.Context(), req.Email, req.Password)
if err != nil {
if errors.Is(err, ErrInvalidCredentials) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal server error"})
}
return c.JSON(loginResponse{
User: *result.User,
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
})
}
func register(c *fiber.Ctx) error {
s := mustAuthService(c)
req := new(registerRequest)
if err := c.BodyParser(req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"})
}
result, err := s.Register(c.Context(), registerOptions{
email: req.Email,
password: req.Password,
displayName: req.DisplayName,
})
if err != nil {
var ae *user.AlreadyExistsError
if errors.As(err, &ae) {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal server error"})
}
return c.JSON(loginResponse{
User: *result.User,
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
})
}

View File

@@ -0,0 +1,56 @@
package auth
import (
"errors"
"strings"
"github.com/get-drexa/drexa/internal/user"
"github.com/gofiber/fiber/v2"
)
const authenticatedUserKey = "authenticatedUser"
// NewBearerAuthMiddleware is a middleware that authenticates a request using a bearer token.
// To obtain the authenticated user in subsequent handlers, see AuthenticatedUser.
func NewBearerAuthMiddleware(s *Service) fiber.Handler {
return func(c *fiber.Ctx) error {
authHeader := c.Get("Authorization")
if authHeader == "" {
return c.SendStatus(fiber.StatusUnauthorized)
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
return c.SendStatus(fiber.StatusUnauthorized)
}
token := parts[1]
u, err := s.AuthenticateWithAccessToken(c.Context(), token)
if err != nil {
var e *InvalidAccessTokenError
if errors.As(err, &e) {
return c.SendStatus(fiber.StatusUnauthorized)
}
var nf *user.NotFoundError
if errors.As(err, &nf) {
return c.SendStatus(fiber.StatusUnauthorized)
}
return c.SendStatus(fiber.StatusInternalServerError)
}
c.Locals(authenticatedUserKey, u)
return c.Next()
}
}
// AuthenticatedUser returns the authenticated user from the given fiber context.
// Returns ErrUnauthenticatedRequest if not authenticated.
func AuthenticatedUser(c *fiber.Ctx) (*user.User, error) {
if u, ok := c.Locals(authenticatedUserKey).(*user.User); ok {
return u, nil
}
return nil, ErrUnauthenticatedRequest
}

View File

@@ -0,0 +1,128 @@
package auth
import (
"context"
"encoding/hex"
"errors"
"github.com/get-drexa/drexa/internal/password"
"github.com/get-drexa/drexa/internal/user"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type LoginResult struct {
User *user.User
AccessToken string
RefreshToken string
}
var ErrInvalidCredentials = errors.New("invalid credentials")
type Service struct {
db *bun.DB
userService *user.Service
tokenConfig TokenConfig
}
type registerOptions struct {
displayName string
email string
password string
}
func NewService(db *bun.DB, userService *user.Service, tokenConfig TokenConfig) *Service {
return &Service{
db: db,
userService: userService,
tokenConfig: tokenConfig,
}
}
func (s *Service) LoginWithEmailAndPassword(ctx context.Context, email, plain string) (*LoginResult, error) {
u, err := s.userService.UserByEmail(ctx, email)
if err != nil {
var nf *user.NotFoundError
if errors.As(err, &nf) {
return nil, ErrInvalidCredentials
}
return nil, err
}
ok, err := password.Verify(plain, u.Password)
if err != nil || !ok {
return nil, ErrInvalidCredentials
}
at, err := GenerateAccessToken(u, &s.tokenConfig)
if err != nil {
return nil, err
}
rt, err := GenerateRefreshToken(u, &s.tokenConfig)
if err != nil {
return nil, err
}
_, err = s.db.NewInsert().Model(rt).Exec(ctx)
if err != nil {
return nil, err
}
return &LoginResult{
User: u,
AccessToken: at,
RefreshToken: hex.EncodeToString(rt.Token),
}, nil
}
func (s *Service) Register(ctx context.Context, opts registerOptions) (*LoginResult, error) {
hashed, err := password.Hash(opts.password)
if err != nil {
return nil, err
}
u, err := s.userService.RegisterUser(ctx, user.UserRegistrationOptions{
Email: opts.email,
DisplayName: opts.displayName,
Password: hashed,
})
if err != nil {
return nil, err
}
at, err := GenerateAccessToken(u, &s.tokenConfig)
if err != nil {
return nil, err
}
rt, err := GenerateRefreshToken(u, &s.tokenConfig)
if err != nil {
return nil, err
}
_, err = s.db.NewInsert().Model(rt).Exec(ctx)
if err != nil {
return nil, err
}
return &LoginResult{
User: u,
AccessToken: at,
RefreshToken: hex.EncodeToString(rt.Token),
}, nil
}
func (s *Service) AuthenticateWithAccessToken(ctx context.Context, token string) (*user.User, error) {
claims, err := ParseAccessToken(token, &s.tokenConfig)
if err != nil {
return nil, err
}
id, err := uuid.Parse(claims.Subject)
if err != nil {
return nil, newInvalidAccessTokenError(err)
}
return s.userService.UserByID(ctx, id)
}

View File

@@ -0,0 +1,94 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/get-drexa/drexa/internal/user"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
const (
accessTokenValidFor = time.Minute * 15
refreshTokenByteLength = 32
refreshTokenValidFor = time.Hour * 24 * 30
)
type TokenConfig struct {
Issuer string
Audience string
SecretKey []byte
}
type RefreshToken struct {
bun.BaseModel `bun:"refresh_tokens"`
ID uuid.UUID `bun:",pk,type:uuid"`
UserID uuid.UUID `bun:"user_id,notnull"`
Token []byte `bun:"-"`
TokenHash string `bun:"token_hash,notnull"`
ExpiresAt time.Time `bun:"expires_at,notnull"`
CreatedAt time.Time `bun:"created_at,notnull"`
}
func GenerateAccessToken(user *user.User, c *TokenConfig) (string, error) {
now := time.Now()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
Issuer: c.Issuer,
Audience: jwt.ClaimStrings{c.Audience},
Subject: user.ID.String(),
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenValidFor)),
IssuedAt: jwt.NewNumericDate(now),
})
signed, err := token.SignedString(c.SecretKey)
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return signed, nil
}
func GenerateRefreshToken(user *user.User, c *TokenConfig) (*RefreshToken, error) {
now := time.Now()
buf := make([]byte, refreshTokenByteLength)
if _, err := rand.Read(buf); err != nil {
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
id, err := uuid.NewV7()
if err != nil {
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
h := sha256.Sum256(buf)
hex := hex.EncodeToString(h[:])
return &RefreshToken{
ID: id,
UserID: user.ID,
Token: buf,
TokenHash: hex,
ExpiresAt: now.Add(refreshTokenValidFor),
CreatedAt: now,
}, nil
}
// ParseAccessToken parses a JWT access token and returns the claims.
// Returns an InvalidAccessTokenError if the token is invalid.
func ParseAccessToken(token string, c *TokenConfig) (*jwt.RegisteredClaims, error) {
parsed, err := jwt.ParseWithClaims(token, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) {
return c.SecretKey, nil
}, jwt.WithIssuer(c.Issuer), jwt.WithExpirationRequired(), jwt.WithAudience(c.Audience))
if err != nil {
return nil, newInvalidAccessTokenError(err)
}
return parsed.Claims.(*jwt.RegisteredClaims), nil
}

View File

@@ -0,0 +1,61 @@
package database
import (
"errors"
"github.com/uptrace/bun/driver/pgdriver"
)
// PostgreSQL SQLSTATE error codes.
// See: https://www.postgresql.org/docs/current/errcodes-appendix.html
const (
PgUniqueViolation = "23505"
PgForeignKeyViolation = "23503"
PgNotNullViolation = "23502"
)
// PostgreSQL protocol error field identifiers used with pgdriver.Error.Field().
// See: https://www.postgresql.org/docs/current/protocol-error-fields.html
//
// Common fields:
// - 'C' - SQLSTATE code (e.g., "23505")
// - 'M' - Primary error message
// - 'D' - Detail message
// - 'H' - Hint
// - 's' - Schema name
// - 't' - Table name
// - 'c' - Column name
// - 'n' - Constraint name
const (
pgFieldCode = 'C'
pgFieldConstraint = 'n'
)
// IsUniqueViolation checks if the error is a PostgreSQL unique constraint violation.
func IsUniqueViolation(err error) bool {
return hasPgCode(err, PgUniqueViolation)
}
// IsForeignKeyViolation checks if the error is a PostgreSQL foreign key violation.
func IsForeignKeyViolation(err error) bool {
return hasPgCode(err, PgForeignKeyViolation)
}
// IsNotNullViolation checks if the error is a PostgreSQL not-null constraint violation.
func IsNotNullViolation(err error) bool {
return hasPgCode(err, PgNotNullViolation)
}
// ConstraintName returns the constraint name from a PostgreSQL error, or empty string if not applicable.
func ConstraintName(err error) string {
var pgErr pgdriver.Error
if errors.As(err, &pgErr) {
return pgErr.Field(pgFieldConstraint)
}
return ""
}
func hasPgCode(err error, code string) bool {
var pgErr pgdriver.Error
return errors.As(err, &pgErr) && pgErr.Field(pgFieldCode) == code
}

View File

@@ -0,0 +1,17 @@
package database
import (
"embed"
"github.com/uptrace/bun/migrate"
)
//go:embed migrations/*.sql
var sqlMigrations embed.FS
// RunMigrations discovers and runs all migrations in the migrations directory.
// Currently, the migrations directory is in internal/db/migrations.
func RunMigrations() error {
m := migrate.NewMigrations()
return m.Discover(sqlMigrations)
}

View File

@@ -0,0 +1,122 @@
-- Enable UUID extension for UUIDv7 support
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- UUIDv7 generation function (timestamp-ordered UUIDs)
-- Based on the draft RFC: https://datatracker.ietf.org/doc/html/draft-peabody-dispatch-new-uuid-format
CREATE OR REPLACE FUNCTION uuid_generate_v7()
RETURNS UUID
AS $$
DECLARE
unix_ts_ms BIGINT;
uuid_bytes BYTEA;
BEGIN
unix_ts_ms = (EXTRACT(EPOCH FROM CLOCK_TIMESTAMP()) * 1000)::BIGINT;
uuid_bytes = OVERLAY(gen_random_bytes(16) PLACING
SUBSTRING(INT8SEND(unix_ts_ms) FROM 3) FROM 1 FOR 6
);
-- Set version (7) and variant bits
uuid_bytes = SET_BYTE(uuid_bytes, 6, (GET_BYTE(uuid_bytes, 6) & 15) | 112);
uuid_bytes = SET_BYTE(uuid_bytes, 8, (GET_BYTE(uuid_bytes, 8) & 63) | 128);
RETURN ENCODE(uuid_bytes, 'hex')::UUID;
END;
$$ LANGUAGE plpgsql VOLATILE;
-- ============================================================================
-- Application Tables
-- ============================================================================
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
display_name TEXT,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
storage_usage_bytes BIGINT NOT NULL,
storage_quota_bytes BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
CREATE TABLE IF NOT EXISTS directories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
parent_id UUID REFERENCES directories(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT unique_directory_path UNIQUE NULLS NOT DISTINCT (user_id, parent_id, name, deleted_at)
);
CREATE INDEX idx_directories_user_id ON directories(user_id, deleted_at);
CREATE INDEX idx_directories_path ON directories(user_id, path, deleted_at);
CREATE TABLE IF NOT EXISTS files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
directory_id UUID REFERENCES directories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
size BIGINT NOT NULL,
mime_type TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
last_accessed_at TIMESTAMPTZ,
CONSTRAINT unique_file_in_directory UNIQUE NULLS NOT DISTINCT (user_id, directory_id, name, deleted_at)
);
CREATE INDEX idx_files_user_id ON files(user_id, deleted_at);
CREATE INDEX idx_files_directory_id ON files(directory_id) WHERE directory_id IS NOT NULL;
CREATE INDEX idx_files_path ON files(user_id, path, deleted_at);
CREATE INDEX idx_files_deleted_at ON files(deleted_at) WHERE deleted_at IS NOT NULL;
CREATE INDEX idx_files_last_accessed_at ON files(user_id, deleted_at, last_accessed_at);
CREATE TABLE IF NOT EXISTS file_shares (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
share_token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_file_shares_share_token ON file_shares(share_token);
CREATE INDEX idx_file_shares_file_id ON file_shares(file_id);
CREATE INDEX idx_file_shares_expires_at ON file_shares(expires_at) WHERE expires_at IS NOT NULL;
-- ============================================================================
-- Triggers for updated_at timestamps
-- ============================================================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_directories_updated_at BEFORE UPDATE ON directories
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_files_updated_at BEFORE UPDATE ON files
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_file_shares_updated_at BEFORE UPDATE ON file_shares
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,15 @@
package database
import (
"database/sql"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/driver/pgdriver"
)
func NewFromPostgres(url string) *bun.DB {
sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(url)))
db := bun.NewDB(sqldb, pgdialect.New())
return db
}

View File

@@ -0,0 +1,23 @@
package drexa
import (
"fmt"
"strings"
)
type ServerConfigError struct {
Errors []error
}
func NewServerConfigError(errs ...error) *ServerConfigError {
return &ServerConfigError{Errors: errs}
}
func (e *ServerConfigError) Error() string {
sb := strings.Builder{}
sb.WriteString("invalid server config:\n")
for _, err := range e.Errors {
sb.WriteString(fmt.Sprintf(" - %s\n", err.Error()))
}
return sb.String()
}

View File

@@ -0,0 +1,86 @@
package drexa
import (
"encoding/hex"
"errors"
"fmt"
"os"
"strconv"
"github.com/get-drexa/drexa/internal/auth"
"github.com/get-drexa/drexa/internal/database"
"github.com/get-drexa/drexa/internal/user"
"github.com/gofiber/fiber/v2"
)
type ServerConfig struct {
Port int
PostgresURL string
JWTIssuer string
JWTAudience string
JWTSecretKey []byte
}
func NewServer(c ServerConfig) *fiber.App {
app := fiber.New()
db := database.NewFromPostgres(c.PostgresURL)
userService := user.NewService(db)
authService := auth.NewService(db, userService, auth.TokenConfig{
Issuer: c.JWTIssuer,
Audience: c.JWTAudience,
SecretKey: c.JWTSecretKey,
})
api := app.Group("/api")
auth.RegisterAPIRoutes(api, authService)
return app
}
// ServerConfigFromEnv creates a ServerConfig from environment variables.
func ServerConfigFromEnv() (*ServerConfig, error) {
c := ServerConfig{
PostgresURL: os.Getenv("POSTGRES_URL"),
JWTIssuer: os.Getenv("JWT_ISSUER"),
JWTAudience: os.Getenv("JWT_AUDIENCE"),
}
errs := []error{}
keyHex := os.Getenv("JWT_SECRET_KEY")
if keyHex == "" {
errs = append(errs, errors.New("JWT_SECRET_KEY is required"))
} else {
k, err := hex.DecodeString(keyHex)
if err != nil {
errs = append(errs, fmt.Errorf("failed to decode JWT_SECRET_KEY: %w", err))
}
c.JWTSecretKey = k
}
p, err := strconv.Atoi(os.Getenv("PORT"))
if err != nil {
errs = append(errs, fmt.Errorf("failed to parse PORT: %w", err))
}
c.Port = p
if c.PostgresURL == "" {
errs = append(errs, errors.New("POSTGRES_URL is required"))
}
if c.JWTIssuer == "" {
errs = append(errs, errors.New("JWT_ISSUER is required"))
}
if c.JWTAudience == "" {
errs = append(errs, errors.New("JWT_AUDIENCE is required"))
}
if len(c.JWTSecretKey) == 0 {
errs = append(errs, errors.New("JWT_SECRET_KEY is required"))
}
if len(errs) > 0 {
return nil, NewServerConfigError(errs...)
}
return &c, nil
}

View File

@@ -0,0 +1,138 @@
package password
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Hashed represents a securely hashed password.
// This type ensures plaintext passwords cannot be accidentally stored.
type Hashed string
// argon2id parameters
const (
memory = 64 * 1024
iterations = 3
parallelism = 2
saltLength = 16
keyLength = 32
)
var (
ErrInvalidHash = errors.New("invalid hash format")
ErrIncompatibleHash = errors.New("incompatible hash algorithm")
ErrIncompatibleVersion = errors.New("incompatible argon2 version")
)
type argon2Hash struct {
memory uint32
iterations uint32
parallelism uint8
salt []byte
hash []byte
}
// Hash securely hashes a plaintext password using argon2id.
func Hash(plain string) (Hashed, error) {
salt := make([]byte, saltLength)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("failed to generate salt: %w", err)
}
hash := argon2.IDKey(
[]byte(plain),
salt,
iterations,
memory,
parallelism,
keyLength,
)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encoded := fmt.Sprintf(
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
memory,
iterations,
parallelism,
b64Salt,
b64Hash,
)
return Hashed(encoded), nil
}
// Verify checks if a plaintext password matches a hashed password.
func Verify(plain string, hashed Hashed) (bool, error) {
h, err := decodeHash(string(hashed))
if err != nil {
return false, err
}
otherHash := argon2.IDKey(
[]byte(plain),
h.salt,
h.iterations,
h.memory,
h.parallelism,
uint32(len(h.hash)),
)
if subtle.ConstantTimeCompare(h.hash, otherHash) == 1 {
return true, nil
}
return false, nil
}
func decodeHash(encodedHash string) (*argon2Hash, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 {
return nil, ErrInvalidHash
}
if parts[1] != "argon2id" {
return nil, ErrIncompatibleHash
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return nil, fmt.Errorf("failed to parse version: %w", err)
}
if version != argon2.Version {
return nil, ErrIncompatibleVersion
}
h := &argon2Hash{}
if _, err := fmt.Sscanf(
parts[3],
"m=%d,t=%d,p=%d",
&h.memory,
&h.iterations,
&h.parallelism,
); err != nil {
return nil, fmt.Errorf("failed to parse parameters: %w", err)
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return nil, fmt.Errorf("failed to decode salt: %w", err)
}
h.salt = salt
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return nil, fmt.Errorf("failed to decode hash: %w", err)
}
h.hash = hash
return h, nil
}

View File

@@ -0,0 +1,36 @@
package user
import (
"fmt"
"github.com/google/uuid"
)
type NotFoundError struct {
// ID is the ID that was used to try to find the user.
// Not set if not tried.
id uuid.UUID
// Email is the email that was used to try to find the user.
// Not set if not tried.
email string
}
func newNotFoundError(id uuid.UUID, email string) *NotFoundError {
return &NotFoundError{id, email}
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("user not found: %v", e.id)
}
type AlreadyExistsError struct {
// Email is the email that was used to try to create the user.
Email string
}
func newAlreadyExistsError(email string) *AlreadyExistsError {
return &AlreadyExistsError{email}
}
func (e *AlreadyExistsError) Error() string {
return fmt.Sprintf("user with email %s already exists", e.Email)
}

View File

@@ -0,0 +1,74 @@
package user
import (
"context"
"database/sql"
"errors"
"github.com/get-drexa/drexa/internal/database"
"github.com/get-drexa/drexa/internal/password"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type Service struct {
db *bun.DB
}
type UserRegistrationOptions struct {
Email string
DisplayName string
Password password.Hashed
}
func NewService(db *bun.DB) *Service {
return &Service{
db: db,
}
}
func (s *Service) RegisterUser(ctx context.Context, opts UserRegistrationOptions) (*User, error) {
u := User{
Email: opts.Email,
DisplayName: opts.DisplayName,
Password: opts.Password,
}
_, err := s.db.NewInsert().Model(&u).Returning("*").Exec(ctx)
if err != nil {
if database.IsUniqueViolation(err) {
return nil, newAlreadyExistsError(u.Email)
}
return nil, err
}
return &u, nil
}
func (s *Service) UserByID(ctx context.Context, id uuid.UUID) (*User, error) {
var user User
err := s.db.NewSelect().Model(&user).Where("id = ?", id).Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, newNotFoundError(id, "")
}
return nil, err
}
return &user, nil
}
func (s *Service) UserByEmail(ctx context.Context, email string) (*User, error) {
var user User
err := s.db.NewSelect().Model(&user).Where("email = ?", email).Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, newNotFoundError(uuid.Nil, email)
}
return nil, err
}
return &user, nil
}
func (s *Service) UserExistsByEmail(ctx context.Context, email string) (bool, error) {
return s.db.NewSelect().Model(&User{}).Where("email = ?", email).Exists(ctx)
}

View File

@@ -0,0 +1,18 @@
package user
import (
"github.com/get-drexa/drexa/internal/password"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type User struct {
bun.BaseModel `bun:"users"`
ID uuid.UUID `bun:",pk,type:uuid" json:"id"`
DisplayName string `bun:"display_name,notnull" json:"displayName"`
Email string `bun:"email,unique,notnull" json:"email"`
Password password.Hashed `bun:"password,notnull" json:"-"`
StorageUsageBytes int64 `bun:"storage_usage_bytes,notnull" json:"storageUsageBytes"`
StorageQuotaBytes int64 `bun:"storage_quota_bytes,notnull" json:"storageQuotaBytes"`
}

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

View File

@@ -4,18 +4,20 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun --hot src/server.tsx", "dev": "vite",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'", "build": "vite build",
"start": "NODE_ENV=production bun src/index.tsx", "preview": "vite preview",
"format": "biome format --write" "format": "biome format --write"
}, },
"dependencies": { "dependencies": {
"@convex-dev/workos": "^0.0.1", "@convex-dev/better-auth": "^0.8.9",
"@fileone/convex": "workspace:*", "@fileone/convex": "workspace:*",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@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-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
@@ -23,15 +25,18 @@
"@tanstack/react-router": "^1.131.41", "@tanstack/react-router": "^1.131.41",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/router-devtools": "^1.131.42", "@tanstack/router-devtools": "^1.131.42",
"@workos-inc/authkit-react": "^0.12.0", "better-auth": "1.3.8",
"bun-plugin-tailwind": "latest",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"convex": "^1.27.0", "convex": "^1.27.0",
"convex-helpers": "^0.1.104", "convex-helpers": "^0.1.104",
"jotai": "^2.14.0", "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", "lucide-react": "^0.544.0",
"motion": "^12.23.16", "motion": "^12.23.16",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",
@@ -42,7 +47,11 @@
}, },
"devDependencies": { "devDependencies": {
"@tanstack/router-cli": "^1.131.41", "@tanstack/router-cli": "^1.131.41",
"@tanstack/router-plugin": "^1.133.13",
"@types/node": "^22.10.5",
"@types/react": "^19", "@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 <Comp
data-slot="button" data-slot="button"
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
disabled={loading || props.disabled} disabled={props.disabled}
{...props} {...props}
> >
{asChild ? ( {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 <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( 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, className,
)} )}
{...props} {...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 return isMobile
? setOpenMobile((open) => !open) ? setOpenMobile((open) => !open)
: setOpen((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile]) }, [isMobile, setOpen])
// Adds a keyboard shortcut to toggle the sidebar. // Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => { React.useEffect(() => {
@@ -124,15 +124,7 @@ function SidebarProvider({
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}), }),
[ [state, open, setOpen, isMobile, openMobile, toggleSidebar],
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
],
) )
return ( 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,270 @@
import { api } from "@fileone/convex/api"
import { newDirectoryHandle } from "@fileone/convex/filesystem"
import { useMutation } from "@tanstack/react-query"
import { Link, useLocation, useParams } from "@tanstack/react-router"
import {
useMutation as useConvexMutation,
useQuery as useConvexQuery,
} from "convex/react"
import { useAtomValue, useSetAtom, useStore } from "jotai"
import {
CircleXIcon,
ClockIcon,
FilesIcon,
FolderInputIcon,
LogOutIcon,
ScissorsIcon,
SettingsIcon,
TrashIcon,
User2Icon,
} from "lucide-react"
import { toast } from "sonner"
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { formatError } from "@/lib/error"
import { Button } from "../components/ui/button"
import { LoadingSpinner } from "../components/ui/loading-spinner"
import { clearCutItemsAtom, cutHandlesAtom } from "../files/store"
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>
<CutItemsCard />
<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>
)
}
/**
* Displays the number of cut items and allows the user to perform actions on them, such as moving them to a target directory.
* Visible when there are cut items.
*/
function CutItemsCard() {
const { directoryId } = useParams({ strict: false })
const cutHandles = useAtomValue(cutHandlesAtom)
const clearCutItems = useSetAtom(clearCutItemsAtom)
const setCutHandles = useSetAtom(cutHandlesAtom)
const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom)
const store = useStore()
const _moveItems = useConvexMutation(api.filesystem.moveItems)
const { mutate: moveItems } = useMutation({
mutationFn: _moveItems,
onMutate: () => {
setBackgroundTaskProgress({
label: "Moving items…",
})
const cutHandles = store.get(cutHandlesAtom)
clearCutItems()
return { cutHandles }
},
onError: (error, _variables, context) => {
if (context?.cutHandles) {
setCutHandles(context.cutHandles)
}
toast.error("Failed to move items", {
description: formatError(error),
})
},
onSuccess: () => {
toast.success("Items moved")
},
onSettled: () => {
setBackgroundTaskProgress(null)
},
})
if (cutHandles.length === 0) return null
const moveCutItems = () => {
if (directoryId) {
moveItems({
targetDirectory: newDirectoryHandle(directoryId),
items: cutHandles,
})
}
}
return (
<SidebarMenuItem>
<Card className="p-0 gap-0 rounded-md overflow-clip">
<CardHeader className="px-3.5 py-1.5! gap-0 border-b border-b-primary-foreground/10 bg-primary text-primary-foreground">
<CardTitle className="p-0 m-0 text-xs uppercase">
<div className="flex items-center gap-1.5">
<ScissorsIcon size={16} /> {cutHandles.length} Cut
Items
</div>
</CardTitle>
</CardHeader>
<CardFooter className="p-1 flex flex-col">
<Button
size="sm"
variant="ghost"
className="w-full justify-start transition-none"
disabled={!directoryId}
onClick={moveCutItems}
>
<FolderInputIcon size={16} />
Move items here
</Button>
<Button
size="sm"
variant="ghost"
className="w-full justify-start transition-none"
onClick={() => clearCutItems()}
>
<CircleXIcon size={16} />
Clear
</Button>
</CardFooter>
</Card>
</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 { Doc } from "@fileone/convex/dataModel"
import type { DirectoryInfo } from "@fileone/convex/model/directories" import type { FileSystemItem } from "@fileone/convex/filesystem"
import type { FileSystemItem } from "@fileone/convex/model/filesystem" import type { DirectoryInfo } from "@fileone/convex/types"
import { createContext } from "react" import { createContext } from "react"
type DirectoryPageContextType = { 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/dataModel"
import type { Doc } from "@fileone/convex/_generated/dataModel"
import { import {
type DirectoryHandle, type DirectoryHandle,
type FileHandle, type FileHandle,
@@ -10,30 +9,21 @@ import {
newDirectoryHandle, newDirectoryHandle,
newFileHandle, newFileHandle,
newFileSystemHandle, newFileSystemHandle,
} from "@fileone/convex/model/filesystem" } from "@fileone/convex/filesystem"
import { useMutation } from "@tanstack/react-query"
import { Link, useNavigate } from "@tanstack/react-router" import { Link, useNavigate } from "@tanstack/react-router"
import { import {
type ColumnDef, type ColumnDef,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel,
type Row, type Row,
type Table as TableType, type Table as TableType,
useReactTable, useReactTable,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { useMutation as useContextMutation } from "convex/react" import { type PrimitiveAtom, useSetAtom, useStore } from "jotai"
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai" import { useContext, useEffect, useMemo, useRef } from "react"
import { TextCursorInputIcon, TrashIcon } from "lucide-react"
import { useContext, useEffect, useRef } from "react"
import { toast } from "sonner"
import { DirectoryIcon } from "@/components/icons/directory-icon" import { DirectoryIcon } from "@/components/icons/directory-icon"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { import {
Table, Table,
TableBody, TableBody,
@@ -47,16 +37,22 @@ import {
keyboardModifierAtom, keyboardModifierAtom,
} from "@/lib/keyboard" } from "@/lib/keyboard"
import { TextFileIcon } from "../../components/icons/text-file-icon" 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 { cn } from "../../lib/utils"
import { DirectoryPageContext } from "./context" import { DirectoryPageContext } from "./context"
import {
contextMenuTargeItemsAtom, type DirectoryContentTableItemIdFilter = Set<FileSystemItem["doc"]["_id"]>
dragInfoAtom,
itemBeingRenamedAtom, type DirectoryContentTableProps = {
openedFileAtom, hiddenItems: DirectoryContentTableItemIdFilter
optimisticDeletedItemsAtom, directoryUrlFn: (directory: Doc<"directories">) => string
} from "./state" fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
onContextMenu: (
row: Row<FileSystemItem>,
table: TableType<FileSystemItem>,
) => void
onOpenFile: (file: Doc<"files">) => void
}
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B" if (bytes === 0) return "0 B"
@@ -68,180 +64,121 @@ function formatFileSize(bytes: number): string {
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}` return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
} }
const columns: ColumnDef<FileSystemItem>[] = [ function useTableColumns(
{ onOpenFile: (file: Doc<"files">) => void,
id: "select", directoryUrlFn: (directory: Doc<"directories">) => string,
header: ({ table }) => ( ): ColumnDef<FileSystemItem>[] {
<Checkbox return useMemo(
checked={table.getIsAllPageRowsSelected()} () => [
onCheckedChange={(value) => { {
table.toggleAllPageRowsSelected(!!value) id: "select",
}} header: ({ table }) => (
aria-label="Select all" <Checkbox
/> checked={table.getIsAllPageRowsSelected()}
), onCheckedChange={(value) => {
cell: ({ row }) => ( table.toggleAllPageRowsSelected(!!value)
<Checkbox }}
checked={row.getIsSelected()} aria-label="Select all"
onClick={(e) => { />
e.stopPropagation() ),
}} cell: ({ row }) => (
onCheckedChange={row.getToggleSelectedHandler()} <Checkbox
aria-label="Select row" checked={row.getIsSelected()}
/> onClick={(e) => {
), e.stopPropagation()
enableSorting: false, }}
enableHiding: false, onCheckedChange={row.getToggleSelectedHandler()}
size: 24, aria-label="Select row"
}, />
{ ),
header: "Name", enableSorting: false,
accessorKey: "doc.name", enableHiding: false,
cell: ({ row }) => { size: 24,
switch (row.original.kind) { },
case FileType.File: {
return <FileNameCell file={row.original.doc} /> header: "Name",
case FileType.Directory: accessorKey: "doc.name",
return <DirectoryNameCell directory={row.original.doc} /> cell: ({ row }) => {
} switch (row.original.kind) {
}, case FileType.File:
size: 1000, return (
}, <FileNameCell
{ file={row.original.doc}
header: "Size", onOpenFile={onOpenFile}
accessorKey: "size", />
cell: ({ row }) => { )
switch (row.original.kind) { case FileType.Directory:
case FileType.File: return (
return <div>{formatFileSize(row.original.doc.size)}</div> <DirectoryNameCell
case FileType.Directory: directory={row.original.doc}
return <div className="font-mono">-</div> directoryUrlFn={directoryUrlFn}
} />
}, )
}, }
{ },
header: "Created At", size: 1000,
accessorKey: "createdAt", },
cell: ({ row }) => { {
return ( header: "Size",
<div> accessorKey: "size",
{new Date(row.original.doc.createdAt).toLocaleString()} cell: ({ row }) => {
</div> switch (row.original.kind) {
) case FileType.File:
}, return (
}, <div>
] {formatFileSize(row.original.doc.size)}
</div>
export function DirectoryContentTable() { )
return ( case FileType.Directory:
<DirectoryContentTableContextMenu> return <div className="font-mono">-</div>
<div className="w-full"> }
<DirectoryContentTableContent /> },
</div> },
</DirectoryContentTableContextMenu> {
header: "Created At",
accessorKey: "createdAt",
cell: ({ row }) => {
return (
<div>
{new Date(
row.original.doc.createdAt,
).toLocaleString()}
</div>
)
},
},
],
[onOpenFile, directoryUrlFn],
) )
} }
export function DirectoryContentTableContextMenu({ export function DirectoryContentTable({
children, hiddenItems,
}: { directoryUrlFn,
children: React.ReactNode onContextMenu,
}) { fileDragInfoAtom,
const store = useStore() onOpenFile,
const [target, setTarget] = useAtom(contextMenuTargeItemsAtom) }: DirectoryContentTableProps) {
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() {
const { directoryContent } = useContext(DirectoryPageContext) const { directoryContent } = useContext(DirectoryPageContext)
const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom)
const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemsAtom)
const store = useStore() const store = useStore()
const navigate = useNavigate() const navigate = useNavigate()
const table = useReactTable({ const table = useReactTable({
data: directoryContent || [], data: directoryContent || [],
columns, columns: useTableColumns(onOpenFile, directoryUrlFn),
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true, enableRowSelection: true,
enableGlobalFilter: true, enableGlobalFilter: true,
globalFilterFn: (row, _columnId, _filterValue, _addMeta) => { state: {
return !optimisticDeletedItems.has(row.original.doc._id) globalFilter: hiddenItems,
}, },
globalFilterFn: (
row,
_columnId,
filterValue: DirectoryContentTableItemIdFilter,
_addMeta,
) => !filterValue.has(row.original.doc._id),
getRowId: (row) => row.doc._id, getRowId: (row) => row.doc._id,
}) })
@@ -262,17 +199,10 @@ export function DirectoryContentTableContent() {
row: Row<FileSystemItem>, row: Row<FileSystemItem>,
_event: React.MouseEvent, _event: React.MouseEvent,
) => { ) => {
const target = store.get(contextMenuTargeItemsAtom) if (!row.getIsSelected()) {
if (target.length > 0) {
setContextMenuTargetItem([])
} else if (row.getIsSelected()) {
setContextMenuTargetItem(
table.getSelectedRowModel().rows.map((row) => row.original),
)
} else {
selectRow(row) selectRow(row)
setContextMenuTargetItem([row.original])
} }
onContextMenu(row, table)
} }
const selectRow = (row: Row<FileSystemItem>) => { const selectRow = (row: Row<FileSystemItem>) => {
@@ -336,6 +266,7 @@ export function DirectoryContentTableContent() {
table={table} table={table}
row={row} row={row}
onClick={() => selectRow(row)} onClick={() => selectRow(row)}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={(e) => onContextMenu={(e) =>
handleRowContextMenu(row, e) handleRowContextMenu(row, e)
} }
@@ -355,8 +286,8 @@ export function DirectoryContentTableContent() {
function NoResultsRow() { function NoResultsRow() {
return ( return (
<TableRow> <TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="text-center"> <TableCell colSpan={4} className="text-center">
No results. No results.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -369,22 +300,24 @@ function FileItemRow({
onClick, onClick,
onContextMenu, onContextMenu,
onDoubleClick, onDoubleClick,
fileDragInfoAtom,
}: { }: {
table: TableType<FileSystemItem> table: TableType<FileSystemItem>
row: Row<FileSystemItem> row: Row<FileSystemItem>
onClick: () => void onClick: () => void
onContextMenu: (e: React.MouseEvent) => void onContextMenu: (e: React.MouseEvent) => void
onDoubleClick: () => void onDoubleClick: () => void
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
}) { }) {
const ref = useRef<HTMLTableRowElement>(null) const ref = useRef<HTMLTableRowElement>(null)
const setDragInfo = useSetAtom(dragInfoAtom) const setFileDragInfo = useSetAtom(fileDragInfoAtom)
const { isDraggedOver, dropHandlers } = useFileDrop({ const { isDraggedOver, dropHandlers } = useFileDrop({
destItem: destItem:
row.original.kind === FileType.Directory row.original.kind === FileType.Directory
? newDirectoryHandle(row.original.doc._id) ? newDirectoryHandle(row.original.doc._id)
: null, : null,
dragInfoAtom, dragInfoAtom: fileDragInfoAtom,
}) })
const handleDragStart = (_e: React.DragEvent) => { const handleDragStart = (_e: React.DragEvent) => {
@@ -411,14 +344,14 @@ function FileItemRow({
draggedItems = [source] draggedItems = [source]
} }
setDragInfo({ setFileDragInfo({
source, source,
items: draggedItems, items: draggedItems,
}) })
} }
const handleDragEnd = () => { const handleDragEnd = () => {
setDragInfo(null) setFileDragInfo(null)
} }
return ( 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 ( return (
<div className="flex w-full items-center gap-2"> <div className="flex w-full items-center gap-2">
<DirectoryIcon className="size-4" /> <DirectoryIcon className="size-4" />
<Link <Link className="hover:underline" to={directoryUrlFn(directory)}>
className="hover:underline"
to={`/directories/${directory._id}`}
>
{directory.name} {directory.name}
</Link> </Link>
</div> </div>
) )
} }
function FileNameCell({ file }: { file: Doc<"files"> }) { function FileNameCell({
const setOpenedFile = useSetAtom(openedFileAtom) file,
onOpenFile,
}: {
file: Doc<"files">
onOpenFile: (file: Doc<"files">) => void
}) {
return ( return (
<div className="flex w-full items-center gap-2"> <div className="flex w-full items-center gap-2">
<TextFileIcon className="size-4" /> <TextFileIcon className="size-4" />
@@ -472,7 +412,7 @@ function FileNameCell({ file }: { file: Doc<"files"> }) {
type="button" type="button"
className="hover:underline cursor-pointer" className="hover:underline cursor-pointer"
onClick={() => { onClick={() => {
setOpenedFile(file) onOpenFile(file)
}} }}
> >
{file.name} {file.name}

View File

@@ -1,5 +1,5 @@
import { api } from "@fileone/convex/_generated/api" import { api } from "@fileone/convex/api"
import type { Id } from "@fileone/convex/_generated/dataModel" import type { Id } from "@fileone/convex/dataModel"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { useMutation as useContextMutation } from "convex/react" import { useMutation as useContextMutation } from "convex/react"
import { useId } from "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 && ( {showPageSkeleton && (
<div className="border rounded-lg p-4"> <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 /> <DirectoryPageSkeleton />
</div> </div>
)} )}
{showTableSkeleton && ( {showTableSkeleton && (
<div className="border rounded-lg p-4"> <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} /> <DirectoryContentTableSkeleton rows={5} />
</div> </div>
)} )}

View File

@@ -1,6 +1,5 @@
import type { Doc, Id } from "@fileone/convex/_generated/dataModel" import type { Doc, Id } from "@fileone/convex/dataModel"
import type { DirectoryItemKind } from "@fileone/convex/model/directories" import type { FileSystemItem } from "@fileone/convex/filesystem"
import type { FileSystemItem, FileType } from "@fileone/convex/model/filesystem"
import type { RowSelectionState } from "@tanstack/react-table" import type { RowSelectionState } from "@tanstack/react-table"
import { atom } from "jotai" import { atom } from "jotai"
import type { FileDragInfo } from "../../files/use-file-drop" import type { FileDragInfo } from "../../files/use-file-drop"
@@ -12,8 +11,6 @@ export const optimisticDeletedItemsAtom = atom(
export const selectedFileRowsAtom = atom<RowSelectionState>({}) export const selectedFileRowsAtom = atom<RowSelectionState>({})
export const newFileTypeAtom = atom<FileType | null>(null)
export const itemBeingRenamedAtom = atom<{ export const itemBeingRenamedAtom = atom<{
originalItem: FileSystemItem originalItem: FileSystemItem
name: string 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 { api } from "@fileone/convex/api"
import type { Doc } from "@fileone/convex/_generated/dataModel" import type { Doc } from "@fileone/convex/dataModel"
import type { DirectoryItem } from "@fileone/convex/model/directories" import type { DirectoryItem } from "@fileone/convex/types"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { 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 { baseName, splitPath } from "@fileone/path"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"

View File

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

View File

@@ -1,8 +1,5 @@
import type { Id } from "@fileone/convex/_generated/dataModel" import type { Id } from "@fileone/convex/dataModel"
import type { import type { DirectoryItem, DirectoryItemKind } from "@fileone/convex/types"
DirectoryItem,
DirectoryItemKind,
} from "@fileone/convex/model/directories"
import type { RowSelectionState } from "@tanstack/react-table" import type { RowSelectionState } from "@tanstack/react-table"
import { atom } from "jotai" import { atom } from "jotai"

View File

@@ -0,0 +1,100 @@
import type { FileSystemHandle } from "@fileone/convex/filesystem"
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
})
export const cutHandlesAtom = atom<FileSystemHandle[]>([])
export const clearCutItemsAtom = atom(null, (_, set) => {
set(cutHandlesAtom, [])
})

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 { api } from "@fileone/convex/api"
import type { Doc, Id } from "@fileone/convex/_generated/dataModel" import type { Doc, Id } from "@fileone/convex/dataModel"
import * as Err from "@fileone/convex/model/error" import * as Err from "@fileone/convex/error"
import { import {
type DirectoryHandle, type DirectoryHandle,
type FileSystemHandle, type FileSystemHandle,
isSameHandle, isSameHandle,
} from "@fileone/convex/model/filesystem" } from "@fileone/convex/filesystem"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { useMutation as useContextMutation } from "convex/react" import { useMutation as useContextMutation } from "convex/react"
import type { PrimitiveAtom } from "jotai" import type { PrimitiveAtom } from "jotai"
@@ -54,7 +54,7 @@ export function useFileDrop({
errors: Err.ApplicationErrorData[] errors: Err.ApplicationErrorData[]
}) => { }) => {
const conflictCount = errors.reduce((acc, error) => { const conflictCount = errors.reduce((acc, error) => {
if (error.code === Err.Code.Conflict) { if (error.code === Err.ErrorCode.Conflict) {
return acc + 1 return acc + 1
} }
return acc return acc
@@ -71,7 +71,6 @@ export function useFileDrop({
const handleDrop = (_e: React.DragEvent) => { const handleDrop = (_e: React.DragEvent) => {
const dragInfo = store.get(dragInfoAtom) const dragInfo = store.get(dragInfoAtom)
console.log("handleDrop", { dragInfo, destItem })
if (dragInfo && destItem) { if (dragInfo && destItem) {
const items = dragInfo.items.filter( const items = dragInfo.items.filter(
(item) => !isSameHandle(item, destItem), (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 { import {
Code as ErrorCode, type ApplicationErrorData,
ErrorCode,
isApplicationError, isApplicationError,
} from "@fileone/convex/model/error" } from "@fileone/convex/error"
import { ConvexError } from "convex/values"
import { toast } from "sonner" import { toast } from "sonner"
const ERROR_MESSAGE = { const ERROR_MESSAGE = {
@@ -9,13 +11,19 @@ const ERROR_MESSAGE = {
[ErrorCode.FileExists]: "File already exists", [ErrorCode.FileExists]: "File already exists",
[ErrorCode.Internal]: "Internal application error", [ErrorCode.Internal]: "Internal application error",
[ErrorCode.Conflict]: "Conflict", [ErrorCode.Conflict]: "Conflict",
[ErrorCode.DirectoryNotFound]: "Directory not found",
[ErrorCode.FileNotFound]: "File not found",
[ErrorCode.Unauthenticated]: "Unauthenticated", [ErrorCode.Unauthenticated]: "Unauthenticated",
[ErrorCode.NotFound]: "Not found",
[ErrorCode.StorageQuotaExceeded]: "Storage is full",
} as const } as const
export function isApplicationConvexError(
error: unknown,
): error is ConvexError<ApplicationErrorData> {
return error instanceof ConvexError && isApplicationError(error.data)
}
export function formatError(error: unknown): string { export function formatError(error: unknown): string {
if (isApplicationError(error)) { if (isApplicationConvexError(error)) {
return ERROR_MESSAGE[error.data.code] return ERROR_MESSAGE[error.data.code]
} }
if (error instanceof Error) { if (error instanceof Error) {
@@ -25,8 +33,12 @@ export function formatError(error: unknown): string {
} }
export function defaultOnError(error: unknown) { export function defaultOnError(error: unknown) {
console.log(error) if (isApplicationConvexError(error)) {
toast.error(formatError(error)) toast.error(formatError(error))
} else {
console.error("Catastrophic error:", error)
toast.error("An unexpected error occurred")
}
} }
export function withDefaultOnError(fn: (error: unknown) => void) { 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. // 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 rootRouteImport } from './routes/__root'
import { Route as SignUpRouteImport } from './routes/sign-up'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
import { Route as LoginCallbackRouteImport } from './routes/login_.callback' import { Route as LoginCallbackRouteImport } from './routes/login_.callback'
import { Route as AuthenticatedSidebarLayoutRouteImport } from './routes/_authenticated/_sidebar-layout' 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 AuthenticatedSidebarLayoutHomeRouteImport } from './routes/_authenticated/_sidebar-layout/home'
import { Route as AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/directories.$directoryId' 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({ const LoginRoute = LoginRouteImport.update({
id: '/login', id: '/login',
path: '/login', path: '/login',
@@ -41,6 +49,12 @@ const AuthenticatedSidebarLayoutRoute =
id: '/_sidebar-layout', id: '/_sidebar-layout',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedSidebarLayoutRecentRoute =
AuthenticatedSidebarLayoutRecentRouteImport.update({
id: '/recent',
path: '/recent',
getParentRoute: () => AuthenticatedSidebarLayoutRoute,
} as any)
const AuthenticatedSidebarLayoutHomeRoute = const AuthenticatedSidebarLayoutHomeRoute =
AuthenticatedSidebarLayoutHomeRouteImport.update({ AuthenticatedSidebarLayoutHomeRouteImport.update({
id: '/home', id: '/home',
@@ -53,60 +67,97 @@ const AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute =
path: '/directories/$directoryId', path: '/directories/$directoryId',
getParentRoute: () => AuthenticatedSidebarLayoutRoute, getParentRoute: () => AuthenticatedSidebarLayoutRoute,
} as any) } as any)
const AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute =
AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRouteImport.update({
id: '/trash/directories/$directoryId',
path: '/trash/directories/$directoryId',
getParentRoute: () => AuthenticatedSidebarLayoutRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/sign-up': typeof SignUpRoute
'/login/callback': typeof LoginCallbackRoute '/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute '/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/recent': typeof AuthenticatedSidebarLayoutRecentRoute
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute '/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/sign-up': typeof SignUpRoute
'/login/callback': typeof LoginCallbackRoute '/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute '/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/recent': typeof AuthenticatedSidebarLayoutRecentRoute
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute '/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/_authenticated': typeof AuthenticatedRouteWithChildren '/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/sign-up': typeof SignUpRoute
'/_authenticated/_sidebar-layout': typeof AuthenticatedSidebarLayoutRouteWithChildren '/_authenticated/_sidebar-layout': typeof AuthenticatedSidebarLayoutRouteWithChildren
'/login_/callback': typeof LoginCallbackRoute '/login_/callback': typeof LoginCallbackRoute
'/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/_sidebar-layout/home': typeof AuthenticatedSidebarLayoutHomeRoute '/_authenticated/_sidebar-layout/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/_authenticated/_sidebar-layout/recent': typeof AuthenticatedSidebarLayoutRecentRoute
'/_authenticated/_sidebar-layout/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute '/_authenticated/_sidebar-layout/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/_authenticated/_sidebar-layout/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/login' | '/login'
| '/sign-up'
| '/login/callback' | '/login/callback'
| '/' | '/'
| '/home' | '/home'
| '/recent'
| '/directories/$directoryId' | '/directories/$directoryId'
| '/trash/directories/$directoryId'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/login' | '/login/callback' | '/' | '/home' | '/directories/$directoryId' to:
| '/login'
| '/sign-up'
| '/login/callback'
| '/'
| '/home'
| '/recent'
| '/directories/$directoryId'
| '/trash/directories/$directoryId'
id: id:
| '__root__' | '__root__'
| '/_authenticated' | '/_authenticated'
| '/login' | '/login'
| '/sign-up'
| '/_authenticated/_sidebar-layout' | '/_authenticated/_sidebar-layout'
| '/login_/callback' | '/login_/callback'
| '/_authenticated/' | '/_authenticated/'
| '/_authenticated/_sidebar-layout/home' | '/_authenticated/_sidebar-layout/home'
| '/_authenticated/_sidebar-layout/recent'
| '/_authenticated/_sidebar-layout/directories/$directoryId' | '/_authenticated/_sidebar-layout/directories/$directoryId'
| '/_authenticated/_sidebar-layout/trash/directories/$directoryId'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
SignUpRoute: typeof SignUpRoute
LoginCallbackRoute: typeof LoginCallbackRoute LoginCallbackRoute: typeof LoginCallbackRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/sign-up': {
id: '/sign-up'
path: '/sign-up'
fullPath: '/sign-up'
preLoaderRoute: typeof SignUpRouteImport
parentRoute: typeof rootRouteImport
}
'/login': { '/login': {
id: '/login' id: '/login'
path: '/login' path: '/login'
@@ -142,6 +193,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSidebarLayoutRouteImport preLoaderRoute: typeof AuthenticatedSidebarLayoutRouteImport
parentRoute: typeof AuthenticatedRoute 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': { '/_authenticated/_sidebar-layout/home': {
id: '/_authenticated/_sidebar-layout/home' id: '/_authenticated/_sidebar-layout/home'
path: '/home' path: '/home'
@@ -156,19 +214,32 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport preLoaderRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport
parentRoute: typeof AuthenticatedSidebarLayoutRoute 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 { interface AuthenticatedSidebarLayoutRouteChildren {
AuthenticatedSidebarLayoutHomeRoute: typeof AuthenticatedSidebarLayoutHomeRoute AuthenticatedSidebarLayoutHomeRoute: typeof AuthenticatedSidebarLayoutHomeRoute
AuthenticatedSidebarLayoutRecentRoute: typeof AuthenticatedSidebarLayoutRecentRoute
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute
} }
const AuthenticatedSidebarLayoutRouteChildren: AuthenticatedSidebarLayoutRouteChildren = const AuthenticatedSidebarLayoutRouteChildren: AuthenticatedSidebarLayoutRouteChildren =
{ {
AuthenticatedSidebarLayoutHomeRoute: AuthenticatedSidebarLayoutHomeRoute, AuthenticatedSidebarLayoutHomeRoute: AuthenticatedSidebarLayoutHomeRoute,
AuthenticatedSidebarLayoutRecentRoute:
AuthenticatedSidebarLayoutRecentRoute,
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute: AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute:
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute, AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute,
AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute:
AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute,
} }
const AuthenticatedSidebarLayoutRouteWithChildren = const AuthenticatedSidebarLayoutRouteWithChildren =
@@ -193,6 +264,7 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
AuthenticatedRoute: AuthenticatedRouteWithChildren, AuthenticatedRoute: AuthenticatedRouteWithChildren,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
SignUpRoute: SignUpRoute,
LoginCallbackRoute: LoginCallbackRoute, LoginCallbackRoute: LoginCallbackRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,443 @@
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,
ScissorsIcon,
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 { cutHandlesAtom, 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 setCutHandles = useSetAtom(cutHandlesAtom)
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 handleCut = () => {
const selectedItems = store.get(contextMenuTargetItemsAtom)
if (selectedItems.length > 0) {
setCutHandles(selectedItems.map(newFileSystemHandle))
}
}
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 onClick={handleCut}>
<ScissorsIcon />
Cut
</ContextMenuItem>
<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>
)
}

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