131 Commits

Author SHA1 Message Date
3c6e767954 Merge pull request #51 from lordmathis/chore/readme-update
Chore/readme update
2025-05-25 16:40:55 +02:00
44c3271e1d Update frontend builder to use Node.js 24-slim 2025-05-25 16:34:57 +02:00
3eaf79f2a7 Add TypeScript check badge to README 2025-05-25 16:25:19 +02:00
fbfab22666 Merge pull request #50 from lordmathis/feat/typescript
Migrate frontend to typescript
2025-05-25 16:11:34 +02:00
47b88cb93a Potential fix for code scanning alert no. 7: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-25 16:07:48 +02:00
63f3679e1f Merge branch 'main' into feat/typescript 2025-05-25 16:03:18 +02:00
694f842178 Merge pull request #49 from lordmathis/workspace_context
Workspace context
2025-05-25 15:58:16 +02:00
3926a2726e Fix cache dependency path in TypeScript workflow 2025-05-25 15:54:13 +02:00
ca602bd0bd Refactor TypeScript workflow to set working directory in defaults 2025-05-25 15:51:31 +02:00
f3f2deea35 Update default DBURL to use sqlite:// format in DefaultConfig 2025-05-25 15:39:47 +02:00
fe2a466a4f Add URL decoding for workspace and file paths in handlers 2025-05-25 15:33:07 +02:00
d2c4a84c32 Refactor API call to include CSRF token handling for non-GET requests 2025-05-25 12:46:51 +02:00
ecc1fe9989 Fix logout button 2025-05-25 12:34:36 +02:00
a724bc44e9 Refactor workspace context usage to improve structure and introduce ThemeContext 2025-05-25 12:11:03 +02:00
6cf118280a Update Workspace interface for optional properties 2025-05-24 20:43:10 +02:00
34ac76b87d Fix async handling for API response in updateProfile and workspace functions 2025-05-24 14:10:35 +02:00
07e0647174 Refactor type guards for LoginResponse and UserRole to improve validation logic 2025-05-24 14:10:13 +02:00
ab3cb56aa1 Add IF NOT EXISTS clause to index creation for sessions and workspaces tables 2025-05-24 14:05:58 +02:00
f511dafad2 Refactor ESLint config 2025-05-24 00:15:10 +02:00
15538b243d Refactor theme styles for hover, accordion, and workspace components 2025-05-24 00:12:31 +02:00
e43efc736a Refactor type definitions in AuthenticatedContent and App components 2025-05-23 23:48:45 +02:00
623f619f88 Fix more lint issues 2025-05-23 23:29:29 +02:00
2519d46061 Refactor Node component to destructure props and improve onNodeClick handling 2025-05-23 23:21:58 +02:00
78de42d195 Fix various eslint issues 2025-05-23 23:03:05 +02:00
ad2334c414 Refactor imports and handle async operations in components 2025-05-23 22:13:25 +02:00
646a897b93 Fix some lint issues 2025-05-22 22:00:37 +02:00
32218e5595 Refactor types 2025-05-22 21:24:39 +02:00
2f181d0f7f Run npm lint:fix 2025-05-18 16:57:48 +02:00
16fbbec992 Configure eslint 2025-05-18 16:56:09 +02:00
60ab01b0c8 Fix type-check issues 2025-05-18 16:36:20 +02:00
3619cf4ed4 Update ts configuration 2025-05-18 15:51:44 +02:00
bfc5cc2d29 Migrate edito components 2025-05-18 15:19:58 +02:00
db75bdcc89 Migrate file components 2025-05-18 14:39:17 +02:00
834a7b1e7e Migrate layout 2025-05-18 13:08:03 +02:00
a8a525531e Migrate navigation to ts 2025-05-18 12:33:42 +02:00
9125cbdad3 Migrate workspace settings to ts 2025-05-18 12:21:18 +02:00
7044e42e94 Migrate admin dashboard to ts 2025-05-18 11:53:17 +02:00
c478e8e8a1 Migrate account settings 2025-05-16 22:57:47 +02:00
924d710b2f Migrate all modals to ts 2025-05-12 21:26:07 +02:00
f3993accdc Merge pull request #48 from lordmathis/alert-autofix-6
Fix for code scanning alert no. 6: Workflow does not contain permissions
2025-05-12 15:56:38 +02:00
00affe3456 Potential fix for code scanning alert no. 6: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-12 15:47:58 +02:00
b7be5a46a2 Migrate useUserAdmin 2025-05-11 15:44:27 +02:00
5fcd24db3e Migrate useProfileSettings hook 2025-05-11 15:36:30 +02:00
c6d46df7a0 Migrate useLastOpenedFile hook 2025-05-11 15:09:11 +02:00
32cb89d329 Migrate useGitOperations 2025-05-11 13:01:43 +02:00
f3691d4dbf Migrate useFileOperations 2025-05-11 12:55:18 +02:00
5dc427ce00 Migrate useFileNavigation hook 2025-05-11 12:37:28 +02:00
a7c83d0c24 Migrate useFileList 2025-05-11 12:25:59 +02:00
1c477f1022 Migrate useFileContent hook 2025-05-11 12:24:12 +02:00
bc60cb3451 Migrate useAdminData to TypeScript with improved type safety and error handling 2025-05-08 22:04:49 +02:00
14b1a46508 Migrate ModalContext and WorkspaceContext 2025-05-08 21:42:13 +02:00
1a06c31705 Migrate AuthContext 2025-05-08 21:01:20 +02:00
1e350bb0cf Migrate git api to ts 2025-05-06 21:02:09 +02:00
02c8100f0b Migrate workspace api to ts 2025-05-06 20:42:12 +02:00
66fe5e485b Migrate user api to ts 2025-05-06 20:16:22 +02:00
905df9f6dd Migrate file api ops to ts 2025-05-05 22:20:11 +02:00
8849deec21 Migrate admin API to typescript 2025-05-05 21:28:29 +02:00
043eab423f Migrating from services to dedicated API files 2025-05-03 21:28:41 +02:00
65db2d2030 Merge pull request #47 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-de653eece3
Bump vite from 6.2.6 to 6.3.4 in /app in the npm_and_yarn group across 1 directory
2025-05-03 14:31:35 +02:00
dependabot[bot]
1fd8d4af22 Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.2.6 to 6.3.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.4/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.4
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-30 19:45:45 +00:00
f1b7fa56b6 Merge pull request #46 from lordmathis/dependabot/go_modules/server/go_modules-bbb8b02913
Bump golang.org/x/net from 0.36.0 to 0.38.0 in /server in the go_modules group across 1 directory
2025-04-19 19:04:14 +02:00
689483d490 Merge pull request #45 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-b7c6efa8f1
Bump vite from 6.2.5 to 6.2.6 in /app in the npm_and_yarn group across 1 directory
2025-04-19 19:03:35 +02:00
e789025cd1 Refactor authentication API service to TypeScript 2025-04-18 19:37:39 +02:00
0769aa2bac Fix default database URL and type in Config 2025-04-18 19:21:06 +02:00
dependabot[bot]
46f4df155f Bump golang.org/x/net
Bumps the go_modules group with 1 update in the /server directory: [golang.org/x/net](https://github.com/golang/net).


Updates `golang.org/x/net` from 0.36.0 to 0.38.0
- [Commits](https://github.com/golang/net/compare/v0.36.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.38.0
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-16 23:18:51 +00:00
46e4897881 Add TypeScript types for App component 2025-04-15 20:00:20 +02:00
dependabot[bot]
fdf7c1738b Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.2.5 to 6.2.6
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.2.6
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-15 17:07:53 +00:00
e6a2fdc0d5 Merge pull request #44 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-2bd33993d4
Bump vite from 6.2.4 to 6.2.5 in /app in the npm_and_yarn group across 1 directory
2025-04-15 19:06:48 +02:00
e4fb276cf7 Migrate utils to ts 2025-04-04 19:23:31 +02:00
dependabot[bot]
c2ceb296fd Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.2.4 to 6.2.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.5/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.2.5
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-04 16:20:16 +00:00
49ecaac720 Initial typescript setup 2025-04-03 20:31:00 +02:00
f5cf0131cd Merge pull request #43 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-8ec3883370
Bump vite from 6.2.3 to 6.2.4 in /app in the npm_and_yarn group across 1 directory
2025-04-02 16:59:21 +02:00
dependabot[bot]
a2d0f5fc36 Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.2.3 to 6.2.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.4/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.4/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-31 18:47:09 +00:00
5273062e59 Merge pull request #41 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-14f44f5325
Bump vite from 6.2.1 to 6.2.3 in /app in the npm_and_yarn group across 1 directory
2025-03-25 19:11:27 +01:00
9883b1d122 Merge pull request #42 from lordmathis/lordmathis-patch-1
Create codeql.yml
2025-03-25 19:10:45 +01:00
928ddcc758 Create codeql.yml 2025-03-25 19:07:09 +01:00
dependabot[bot]
c1e13ce02b Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.2.1 to 6.2.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.3/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-25 15:12:43 +00:00
be3d4ee949 Merge pull request #40 from lordmathis/dependabot/go_modules/server/go_modules-036d30de33
Bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 in /server in the go_modules group across 1 directory
2025-03-22 00:54:14 +01:00
dependabot[bot]
c44381bb86 Bump github.com/golang-jwt/jwt/v5
Bumps the go_modules group with 1 update in the /server directory: [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt).


Updates `github.com/golang-jwt/jwt/v5` from 5.2.1 to 5.2.2
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.1...v5.2.2)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-21 22:18:32 +00:00
7c0a4166c6 Merge pull request #39 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-2c631a4876
Bump @babel/runtime from 7.25.7 to 7.26.10 in /app in the npm_and_yarn group across 1 directory
2025-03-18 16:33:22 +01:00
dependabot[bot]
191d9f8cdb Bump @babel/runtime in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime).


Updates `@babel/runtime` from 7.25.7 to 7.26.10
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-18 14:04:38 +00:00
89d99be10b Merge pull request #38 from lordmathis/dependabot/go_modules/server/go_modules-c153b83258
Bump golang.org/x/net from 0.33.0 to 0.36.0 in /server in the go_modules group across 1 directory
2025-03-18 15:03:35 +01:00
dependabot[bot]
09f1ee6a57 Bump golang.org/x/net
Bumps the go_modules group with 1 update in the /server directory: [golang.org/x/net](https://github.com/golang/net).


Updates `golang.org/x/net` from 0.33.0 to 0.36.0
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-13 01:34:56 +00:00
374c910267 Merge pull request #37 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-5db6d3c188
Bump the npm_and_yarn group across 1 directory with 3 updates
2025-03-07 23:08:23 +01:00
dependabot[bot]
a1bc82d820 Bump the npm_and_yarn group across 1 directory with 3 updates
Bumps the npm_and_yarn group with 3 updates in the /app directory: [esbuild](https://github.com/evanw/esbuild), [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `esbuild` from 0.21.5 to 0.25.0
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.25.0)

Updates `@vitejs/plugin-react` from 4.3.3 to 4.3.4
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/v4.3.4/packages/plugin-react)

Updates `vite` from 5.4.14 to 6.2.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@6.2.1/packages/vite)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: "@vitejs/plugin-react"
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-07 21:43:21 +00:00
bc10ad5c25 Merge pull request #36 from lordmathis/feat/postgres
Add support for Postgres
2025-03-07 22:42:08 +01:00
0be1bbf9a7 Update README with postgres url info 2025-03-07 22:37:20 +01:00
d0f6f27526 Add PostgreSQL service to GitHub Actions workflow for integration tests 2025-03-07 22:31:00 +01:00
d97f5a0178 Use time since 2025-03-07 19:53:19 +01:00
94ea2d0d78 Fix time since 2025-03-07 19:53:03 +01:00
3eb4424e86 Implement time since query 2025-03-07 19:48:36 +01:00
f7825e5a67 Use search path in connection string 2025-03-07 19:48:17 +01:00
72b0ac08ce Add test env var to settings.json 2025-03-07 19:47:44 +01:00
32628abf09 Add pgadmin to test compose 2025-03-07 19:47:15 +01:00
d8de67ae6c Add test postgres docker compose 2025-03-06 21:40:16 +01:00
f55d2644c3 Run integration tests with both dbs 2025-03-06 21:39:56 +01:00
629baa9952 Add test postgres db connection 2025-03-06 19:23:24 +01:00
4766a166df Add struct_query tests 2025-03-05 22:36:27 +01:00
7e9aab01cb Fix ommit empty db tag 2025-03-05 22:03:32 +01:00
904d4ce106 Update documentation 2025-03-05 21:31:58 +01:00
52aa406c6d Add docs comments to struct query 2025-03-05 21:30:40 +01:00
3aa8c838e8 Use struct queries in users 2025-03-05 21:20:57 +01:00
976425d660 Use ScanStruct in sessions 2025-03-05 21:07:05 +01:00
0f97927219 Rework UpdateStruct 2025-03-03 22:04:38 +01:00
829b359e82 Implement select struct and scan struct 2025-03-03 21:36:04 +01:00
5fd9755f12 Implement scan struct 2025-03-02 21:54:10 +01:00
ccac439465 Implement update struct 2025-03-02 18:40:12 +01:00
204dacd15e Encrypt field in query 2025-03-01 22:26:50 +01:00
3ce92322f4 Encrypt git token in insertstruct 2025-03-01 21:59:04 +01:00
e89b4a0e14 Use InsertStruct 2025-02-27 21:44:32 +01:00
c0de3538dc Implement insert struct 2025-02-27 21:16:43 +01:00
a80b48956a Add returning tests 2025-02-25 22:36:22 +01:00
96fc490c1d Update migrations for postgres 2025-02-25 22:29:06 +01:00
3b7deaa107 Update workspaces to query builder 2025-02-25 22:11:59 +01:00
9da51aeb5e Update users to query builder 2025-02-25 21:37:06 +01:00
802f192dc0 Implement returning clause 2025-02-25 21:36:55 +01:00
7d05c8aacc Update system to query builder 2025-02-25 21:36:42 +01:00
27b81ef433 Update sessions to query builder 2025-02-25 21:36:26 +01:00
d3ffcfbb53 Replace interface{} with any 2025-02-24 21:42:39 +01:00
96284c3dbd Add query and scanner tests 2025-02-24 21:38:52 +01:00
7cbe6fd272 Add postgres init 2025-02-23 15:19:34 +01:00
a946b8ae76 Implement db struct scanner 2025-02-23 14:59:00 +01:00
c76057d605 Implement sql query builder 2025-02-23 14:58:30 +01:00
25defa5b65 Fix tests for db type 2025-02-22 22:32:38 +01:00
d47f7d7fb0 Use golang migrate for migrations 2025-02-22 21:53:12 +01:00
aef42ff33c Merge pull request #35 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-be4b90d81e
Bump vite from 5.4.10 to 5.4.14 in /app in the npm_and_yarn group across 1 directory
2025-02-02 14:15:46 +01:00
dependabot[bot]
562f84536d Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 5.4.10 to 5.4.14
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.14/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-22 04:19:07 +00:00
43b5f22793 Merge pull request #34 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-7064c9a8ac
Bump katex from 0.16.11 to 0.16.21 in /app in the npm_and_yarn group across 1 directory
2025-01-21 18:15:27 +01:00
dependabot[bot]
9d0c9dbac6 Bump katex in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [katex](https://github.com/KaTeX/KaTeX).


Updates `katex` from 0.16.11 to 0.16.21
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.11...v0.16.21)

---
updated-dependencies:
- dependency-name: katex
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-17 21:39:40 +00:00
f6d2c3d407 Merge pull request #33 from lordmathis/dependabot/go_modules/server/go_modules-a688e28eb2
Bump the go_modules group across 1 directory with 2 updates
2025-01-06 18:58:00 +01:00
dependabot[bot]
e580a2d402 Bump the go_modules group across 1 directory with 2 updates
Bumps the go_modules group with 1 update in the /server directory: [github.com/go-git/go-git/v5](https://github.com/go-git/go-git).


Updates `github.com/go-git/go-git/v5` from 5.12.0 to 5.13.1
- [Release notes](https://github.com/go-git/go-git/releases)
- [Commits](https://github.com/go-git/go-git/compare/v5.12.0...v5.13.1)

Updates `golang.org/x/net` from 0.25.0 to 0.33.0
- [Commits](https://github.com/golang/net/compare/v0.25.0...v0.33.0)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-git/v5
  dependency-type: direct:production
  dependency-group: go_modules
- dependency-name: golang.org/x/net
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-06 16:26:29 +00:00
144 changed files with 10741 additions and 3050 deletions

102
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,102 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '40 19 * * 0'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@@ -8,11 +8,29 @@ on:
branches:
- main
permissions:
contents: read
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: lemma_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
defaults:
run:
working-directory: ./server
@@ -29,6 +47,8 @@ jobs:
- name: Run Tests
run: go test -tags=test,integration ./... -v
env:
LEMMA_TEST_POSTGRES_URL: "postgres://postgres:postgres@localhost:5432/lemma_test?sslmode=disable"
- name: Run Tests with Race Detector
run: go test -tags=test,integration -race ./... -v

41
.github/workflows/typescript.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: TypeScript Type Check
permissions:
contents: read
on:
push:
branches:
- "*"
pull_request:
branches:
- main
jobs:
type-check:
name: TypeScript Type Check
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./app
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: "./app/package-lock.json"
- name: Install dependencies
run: npm ci
- name: Run TypeScript type check
run: npm run type-check
- name: Run ESLint
run: npm run lint
continue-on-error: true

View File

@@ -15,6 +15,9 @@
"go.lintOnSave": "package",
"go.formatTool": "goimports",
"go.testFlags": ["-tags=test,integration"],
"go.testEnvVars": {
"LEMMA_TEST_POSTGRES_URL": "postgres://postgres:postgres@localhost:5432/lemma_test?sslmode=disable"
},
"[go]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {

View File

@@ -1,5 +1,5 @@
# Stage 1: Build the frontend
FROM node:20 AS frontend-builder
FROM node:24-slim AS frontend-builder
WORKDIR /app
COPY app/package*.json ./
RUN npm ci

View File

@@ -1,6 +1,6 @@
# Lemma
![Build](https://github.com/LordMathis/Lemma/actions/workflows/build-and-release.yml/badge.svg) ![Go Tests](https://github.com/LordMathis/Lemma/actions/workflows/go-test.yml/badge.svg)
![Build](https://github.com/lordmathis/lemma/actions/workflows/build-and-release.yml/badge.svg) ![Go Tests](https://github.com/lordmathis/lemma/actions/workflows/go-test.yml/badge.svg) ![Typescript Check](https://github.com/lordmathis/lemma/actions/workflows/typescript.yml/badge.svg)
Yet another markdown editor. Work in progress
@@ -33,8 +33,8 @@ Lemma can be configured using environment variables. Here are the available conf
### Optional Environment Variables
- `LEMMA_ENV`: Set to "development" to enable development mode
- `LEMMA_DB_PATH`: Path to the SQLite database file (default: "./lemma.db")
- `LEMMA_WORKDIR`: Working directory for application data (default: "./data")
- `LEMMA_DB_URL`: URL (Connection string) to the database. Supported databases are sqlite and postgres a (default: "./lemma.db")
- `LEMMA_WORKDIR`: Working directory for application data (default: "sqlite://lemma.db")
- `LEMMA_STATIC_PATH`: Path to static files (default: "../app/dist")
- `LEMMA_PORT`: Port to run the server on (default: "8080")
- `LEMMA_DOMAIN`: Domain name where the application is hosted for cookie authentication

View File

@@ -1,31 +0,0 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"react"
],
"rules": {
"react/prop-types": "off",
"no-unused-vars": "warn"
},
"settings": {
"react": {
"version": "detect"
}
}
}

107
app/eslint.config.mjs Normal file
View File

@@ -0,0 +1,107 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import { fixupConfigRules, fixupPluginRules } from '@eslint/compat';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import js from '@eslint/js';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default defineConfig([
globalIgnores([
'**/node_modules',
'**/dist',
'**/build',
'**/coverage',
'**/public',
'**/*.js',
'**/vite.config.ts',
'**/eslint.config.mjs',
]),
{
extends: fixupConfigRules(
compat.extends(
'eslint:recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking'
)
),
plugins: {
react: fixupPluginRules(react),
'react-hooks': fixupPluginRules(reactHooks),
'@typescript-eslint': fixupPluginRules(typescriptEslint),
},
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
project: './tsconfig.json',
},
},
settings: {
react: {
version: 'detect',
},
},
rules: {
'no-console': [
'warn',
{
allow: ['warn', 'error', 'debug'],
},
],
'no-duplicate-imports': 'error',
'no-unused-vars': 'off',
'react/prop-types': 'off',
'react/react-in-jsx-scope': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{
prefer: 'type-imports',
},
],
'@typescript-eslint/no-misused-promises': 'warn',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-non-null-assertion': 'warn',
},
},
]);

4137
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,11 @@
"type": "module",
"scripts": {
"start": "vite",
"build": "vite build",
"preview": "vite preview"
"build": "tsc && vite build",
"preview": "vite preview",
"type-check": "tsc --noEmit",
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix"
},
"repository": {
"type": "git",
@@ -50,14 +53,24 @@
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",
"@eslint/compat": "^1.2.9",
"@types/babel__core": "^7.20.5",
"@types/node": "^22.14.0",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6",
"@types/react-syntax-highlighter": "^15.5.13",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.27.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"postcss": "^8.4.47",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"sass": "^1.80.4",
"vite": "^5.4.10",
"typescript": "^5.8.2",
"vite": "^6.2.4",
"vite-plugin-compression2": "^1.3.0"
},
"browserslist": {

View File

@@ -11,7 +11,9 @@ import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import './App.scss';
function AuthenticatedContent() {
type AuthenticatedContentProps = object;
const AuthenticatedContent: React.FC<AuthenticatedContentProps> = () => {
const { user, loading, initialized } = useAuth();
if (!initialized) {
@@ -33,9 +35,11 @@ function AuthenticatedContent() {
</ModalProvider>
</WorkspaceProvider>
);
}
};
function App() {
type AppProps = object;
const App: React.FC<AppProps> = () => {
return (
<>
<ColorSchemeScript defaultColorScheme="light" />
@@ -49,6 +53,6 @@ function App() {
</MantineProvider>
</>
);
}
};
export default App;

135
app/src/api/admin.ts Normal file
View File

@@ -0,0 +1,135 @@
import {
API_BASE_URL,
type CreateUserRequest,
type UpdateUserRequest,
} from '@/types/api';
import { apiCall } from './api';
import {
isSystemStats,
isUser,
isWorkspace,
type SystemStats,
type User,
type Workspace,
} from '@/types/models';
const ADMIN_BASE_URL = `${API_BASE_URL}/admin`;
// User Management
/**
* Fetches all users from the API
* @returns {Promise<User[]>} A promise that resolves to an array of users
* @throws {Error} If the API call fails or returns an invalid response
* */
export const getUsers = async (): Promise<User[]> => {
const response = await apiCall(`${ADMIN_BASE_URL}/users`);
const data: unknown = await response.json();
if (!Array.isArray(data)) {
throw new Error('Invalid users response received from API');
}
return data.map((user) => {
if (!isUser(user)) {
throw new Error('Invalid user object received from API');
}
return user;
});
};
/**
* Creates a new user in the system
* @param {CreateUserRequest} userData The data for the new user
* @returns {Promise<User>} A promise that resolves to the created user
* @throws {Error} If the API call fails or returns an invalid response
* */
export const createUser = async (
userData: CreateUserRequest
): Promise<User> => {
const response = await apiCall(`${ADMIN_BASE_URL}/users`, {
method: 'POST',
body: JSON.stringify(userData),
});
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user object received from API');
}
return data;
};
/**
* Deletes a user from the system
* @param {number} userId The ID of the user to delete
* @throws {Error} If the API call fails or returns an invalid response
* */
export const deleteUser = async (userId: number) => {
const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, {
method: 'DELETE',
});
if (response.status === 204) {
return;
} else {
throw new Error('Failed to delete user with status: ' + response.status);
}
};
/**
* Updates an existing user in the system
* @param {number} userId The ID of the user to update
* @param {UpdateUserRequest} userData The data to update the user with
* @returns {Promise<User>} A promise that resolves to the updated user
* @throws {Error} If the API call fails or returns an invalid response
* */
export const updateUser = async (
userId: number,
userData: UpdateUserRequest
): Promise<User> => {
const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(userData),
});
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user object received from API');
}
return data;
};
// Workspace Management
/**
* Fetches all workspaces from the API
* @returns {Promise<Workspace[]>} A promise that resolves to an array of workspaces
* @throws {Error} If the API call fails or returns an invalid response
* */
export const getWorkspaces = async (): Promise<Workspace[]> => {
const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`);
const data: unknown = await response.json();
if (!Array.isArray(data)) {
throw new Error('Invalid workspaces response received from API');
}
return data.map((workspace) => {
if (!isWorkspace(workspace)) {
throw new Error('Invalid workspace object received from API');
}
return workspace;
});
};
// System Statistics
/**
* Fetches system-wide statistics from the API
* @returns {Promise<SystemStats>} A promise that resolves to the system statistics
* @throws {Error} If the API call fails or returns an invalid response
* */
export const getSystemStats = async (): Promise<SystemStats> => {
const response = await apiCall(`${ADMIN_BASE_URL}/stats`);
const data: unknown = await response.json();
if (!isSystemStats(data)) {
throw new Error('Invalid system stats response received from API');
}
return data;
};

78
app/src/api/api.ts Normal file
View File

@@ -0,0 +1,78 @@
import { refreshToken } from './auth';
/**
* Gets the CSRF token from cookies
* @returns {string} The CSRF token or an empty string if not found
*/
const getCsrfToken = (): string => {
const cookies = document.cookie.split(';');
let csrfToken = '';
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'csrf_token' && value) {
csrfToken = decodeURIComponent(value);
break;
}
}
return csrfToken;
};
/**
* Makes an API call with proper cookie handling and error handling
*/
export const apiCall = async (
url: string,
options: RequestInit = {}
): Promise<Response> => {
console.debug(`Making API call to: ${url}`);
try {
// Set up headers with CSRF token for non-GET requests
const method = options.method || 'GET';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
// Add CSRF token for non-GET methods
if (method !== 'GET') {
const csrfToken = getCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
}
const response = await fetch(url, {
...options,
// Include credentials to send/receive cookies
credentials: 'include',
headers,
});
console.debug(`Response status: ${response.status} for URL: ${url}`);
// Handle 401 responses
if (response.status === 401) {
const isRefreshEndpoint = url.endsWith('/auth/refresh');
if (!isRefreshEndpoint) {
// Attempt token refresh and retry the request
const refreshSuccess = await refreshToken();
if (refreshSuccess) {
// Retry the original request
return apiCall(url, options);
}
}
throw new Error('Authentication failed');
}
if (!response.ok && response.status !== 204) {
const errorData = (await response.json()) as { message: string };
throw new Error(
errorData?.message || `HTTP error! status: ${response.status}`
);
}
return response;
} catch (error) {
console.error(`API call failed: ${(error as Error).message}`);
throw error;
}
};

75
app/src/api/auth.ts Normal file
View File

@@ -0,0 +1,75 @@
import { API_BASE_URL, isLoginResponse, type LoginRequest } from '@/types/api';
import { apiCall } from './api';
import { isUser, type User } from '@/types/models';
/**
* Logs in a user with email and password
* @param {string} email - The user's email
* @param {string} password - The user's password
* @returns {Promise<User>} A promise that resolves to the user
* @throws {Error} If the API call fails or returns an invalid response
* @throws {Error} If the login fails
*/
export const login = async (email: string, password: string): Promise<User> => {
const loginData: LoginRequest = { email, password };
const response = await apiCall(`${API_BASE_URL}/auth/login`, {
method: 'POST',
body: JSON.stringify(loginData),
});
const data: unknown = await response.json();
if (!isLoginResponse(data)) {
throw new Error('Invalid login response from API');
}
return data.user;
};
/**
* Logs out the current user
* @returns {Promise<void>} A promise that resolves when the logout is successful
* @throws {Error} If the API call fails or returns an invalid response
* @throws {Error} If the logout fails
*/
export const logout = async (): Promise<void> => {
const response = await apiCall(`${API_BASE_URL}/auth/logout`, {
method: 'POST',
});
if (response.status !== 204) {
throw new Error('Failed to log out');
}
};
/**
* Refreshes the auth token
* @returns true if refresh was successful, false otherwise
*/
export const refreshToken = async (): Promise<boolean> => {
try {
await apiCall(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
});
return true;
} catch (_error) {
return false;
}
};
/**
* Gets the currently authenticated user
* @returns {Promise<User>} A promise that resolves to the current user
* @throws {Error} If the API call fails or returns an invalid response
* @throws {Error} If the user data is invalid
*/
export const getCurrentUser = async (): Promise<User> => {
const response = await apiCall(`${API_BASE_URL}/auth/me`);
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user data received from API');
}
return data;
};

169
app/src/api/file.ts Normal file
View File

@@ -0,0 +1,169 @@
import { isFileNode, type FileNode } from '@/types/models';
import { apiCall } from './api';
import {
API_BASE_URL,
isLookupResponse,
isSaveFileResponse,
type SaveFileResponse,
} from '@/types/api';
/**
* listFiles fetches the list of files in a workspace
* @param workspaceName - The name of the workspace
* @returns {Promise<FileNode[]>} A promise that resolves to an array of FileNode objects
* @throws {Error} If the API call fails or returns an invalid response
*/
export const listFiles = async (workspaceName: string): Promise<FileNode[]> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/files`
);
const data: unknown = await response.json();
if (!Array.isArray(data)) {
throw new Error('Invalid files response received from API');
}
return data.map((file) => {
if (!isFileNode(file)) {
throw new Error('Invalid file object received from API');
}
return file;
});
};
/**
* lookupFileByName fetches the file paths that match the given filename in a workspace
* @param workspaceName - The name of the workspace
* @param filename - The name of the file to look up
* @returns {Promise<string[]>} A promise that resolves to an array of file paths
* @throws {Error} If the API call fails or returns an invalid response
*/
export const lookupFileByName = async (
workspaceName: string,
filename: string
): Promise<string[]> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName
)}/files/lookup?filename=${encodeURIComponent(filename)}`
);
const data: unknown = await response.json();
if (!isLookupResponse(data)) {
throw new Error('Invalid lookup response received from API');
}
const lookupResponse = data;
return lookupResponse.paths;
};
/**
* getFileContent fetches the content of a file in a workspace
* @param workspaceName - The name of the workspace
* @param filePath - The path of the file to fetch
* @returns {Promise<string>} A promise that resolves to the file content
* @throws {Error} If the API call fails or returns an invalid response
*/
export const getFileContent = async (
workspaceName: string,
filePath: string
): Promise<string> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName
)}/files/${encodeURIComponent(filePath)}`
);
return response.text();
};
/**
* saveFile saves the content to a file in a workspace
* @param workspaceName - The name of the workspace
* @param filePath - The path of the file to save
* @param content - The content to save in the file
* @returns {Promise<SaveFileResponse>} A promise that resolves to the save file response
* @throws {Error} If the API call fails or returns an invalid response
*/
export const saveFile = async (
workspaceName: string,
filePath: string,
content: string
): Promise<SaveFileResponse> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName
)}/files/${encodeURIComponent(filePath)}`,
{
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
body: content,
}
);
const data: unknown = await response.json();
if (!isSaveFileResponse(data)) {
throw new Error('Invalid save file response received from API');
}
return data;
};
/**
* deleteFile deletes a file in a workspace
* @param workspaceName - The name of the workspace
* @param filePath - The path of the file to delete
* @throws {Error} If the API call fails or returns an invalid response
*/
export const deleteFile = async (workspaceName: string, filePath: string) => {
await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName
)}/files/${encodeURIComponent(filePath)}`,
{
method: 'DELETE',
}
);
};
/**
* getLastOpenedFile fetches the last opened file in a workspace
* @param workspaceName - The name of the workspace
* @returns {Promise<string>} A promise that resolves to the last opened file path
* @throws {Error} If the API call fails or returns an invalid response
*/
export const getLastOpenedFile = async (
workspaceName: string
): Promise<string> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/files/last`
);
const data: unknown = await response.json();
if (
typeof data !== 'object' ||
data === null ||
!('lastOpenedFilePath' in data)
) {
throw new Error('Invalid last opened file response received from API');
}
return data.lastOpenedFilePath as string;
};
/**
* updateLastOpenedFile updates the last opened file in a workspace
* @param workspaceName - The name of the workspace
* @param filePath - The path of the file to set as last opened
* @throws {Error} If the API call fails or returns an invalid response
*/
export const updateLastOpenedFile = async (
workspaceName: string,
filePath: string
) => {
await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName
)}/files/last`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filePath }),
}
);
};

50
app/src/api/git.ts Normal file
View File

@@ -0,0 +1,50 @@
import { API_BASE_URL } from '@/types/api';
import { apiCall } from './api';
import type { CommitHash } from '@/types/models';
/**
* pullChanges fetches the latest changes from the remote repository
* @param workspaceName - The name of the workspace
* @returns {Promise<string>} A promise that resolves to a message indicating the result of the pull operation
*/
export const pullChanges = async (workspaceName: string): Promise<string> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/git/pull`,
{
method: 'POST',
}
);
const data: unknown = await response.json();
if (typeof data !== 'object' || data === null || !('message' in data)) {
throw new Error('Invalid pull response received from API');
}
return data.message as string;
};
/**
* pushChanges pushes the local changes to the remote repository
* @param workspaceName - The name of the workspace
* @returns {Promise<CommitHash>} A promise that resolves to the commit hash of the pushed changes
*/
export const commitAndPush = async (
workspaceName: string,
message: string
): Promise<CommitHash> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName
)}/git/commit`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message }),
}
);
const data: unknown = await response.json();
if (typeof data !== 'object' || data === null || !('commitHash' in data)) {
throw new Error('Invalid commit response received from API');
}
return data.commitHash as CommitHash;
};

41
app/src/api/user.ts Normal file
View File

@@ -0,0 +1,41 @@
import { API_BASE_URL, type UpdateProfileRequest } from '@/types/api';
import { isUser, type User } from '@/types/models';
import { apiCall } from './api';
/**
* updateProfile updates the user's profile information.
* @param updateRequest - The request object containing the updated profile information.
* @returns A promise that resolves to the updated user object.
* @throws An error if the response is not valid user data.
*/
export const updateProfile = async (
updateRequest: UpdateProfileRequest
): Promise<User> => {
const response = await apiCall(`${API_BASE_URL}/profile`, {
method: 'PUT',
body: JSON.stringify(updateRequest),
});
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user data');
}
return data;
};
/**
* deleteProfile deletes the user's profile.
* @param password - The password of the user.
* @throws An error if the response status is not 204 (No Content).
*/
export const deleteUser = async (password: string) => {
const response = await apiCall(`${API_BASE_URL}/profile`, {
method: 'DELETE',
body: JSON.stringify({ password }),
});
if (response.status !== 204) {
throw new Error('Failed to delete profile');
}
return;
};

153
app/src/api/workspace.ts Normal file
View File

@@ -0,0 +1,153 @@
import { type Workspace, isWorkspace } from '@/types/models';
import { apiCall } from './api';
import { API_BASE_URL } from '@/types/api';
/**
* listWorkspaces fetches the list of workspaces
* @returns {Promise<Workspace[]>} A promise that resolves to an array of Workspace objects
* @throws {Error} If the API call fails or returns an invalid response
*/
export const listWorkspaces = async (): Promise<Workspace[]> => {
const response = await apiCall(`${API_BASE_URL}/workspaces`);
const data: unknown = await response.json();
if (!Array.isArray(data)) {
throw new Error('Invalid workspaces response received from API');
}
return data.map((workspace) => {
if (!isWorkspace(workspace)) {
throw new Error('Invalid workspace object received from API');
}
return workspace;
});
};
/**
* createWorkspace creates a new workspace with the given name
* @param name - The name of the workspace to create
* @returns {Promise<Workspace>} A promise that resolves to the created Workspace object
* @throws {Error} If the API call fails or returns an invalid response
*/
export const createWorkspace = async (name: string): Promise<Workspace> => {
const response = await apiCall(`${API_BASE_URL}/workspaces`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
});
const data: unknown = await response.json();
if (!isWorkspace(data)) {
throw new Error('Invalid workspace object received from API');
}
return data;
};
/**
* getWorkspace fetches the workspace with the given name
* @param workspaceName - The name of the workspace to fetch
* @returns {Promise<Workspace>} A promise that resolves to the Workspace object
* @throws {Error} If the API call fails or returns an invalid response
*/
export const getWorkspace = async (
workspaceName: string
): Promise<Workspace> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}`
);
const data: unknown = await response.json();
if (!isWorkspace(data)) {
throw new Error('Invalid workspace object received from API');
}
return data;
};
/**
* updateWorkspace updates the workspace with the given name
* @param workspaceName - The name of the workspace to update
* @param workspaceData - The updated Workspace object
* @returns {Promise<Workspace>} A promise that resolves to the updated Workspace object
* @throws {Error} If the API call fails or returns an invalid response
*/
export const updateWorkspace = async (
workspaceName: string,
workspaceData: Workspace
): Promise<Workspace> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(workspaceData),
}
);
const data: unknown = await response.json();
if (!isWorkspace(data)) {
throw new Error('Invalid workspace object received from API');
}
return data;
};
/**
* deleteWorkspace deletes the workspace with the given name
* @param workspaceName - The name of the workspace to delete
* @returns {Promise<string>} A promise that resolves to the next workspace name to switch to
* @throws {Error} If the API call fails or returns an invalid response
*/
export const deleteWorkspace = async (
workspaceName: string
): Promise<string> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}`,
{
method: 'DELETE',
}
);
const data: unknown = await response.json();
if (
typeof data !== 'object' ||
data === null ||
!('nextWorkspaceName' in data)
) {
throw new Error('Invalid delete workspace response received from API');
}
return data.nextWorkspaceName as string;
};
/**
* getLastWorkspaceName fetches the last workspace name
* @returns {Promise<string>} A promise that resolves to the last workspace name
* @throws {Error} If the API call fails or returns an invalid response
*/
export const getLastWorkspaceName = async (): Promise<string> => {
const response = await apiCall(`${API_BASE_URL}/workspaces/last`);
const data: unknown = await response.json();
if (
typeof data !== 'object' ||
data === null ||
!('lastWorkspaceName' in data)
) {
throw new Error('Invalid last workspace name response received from API');
}
return data.lastWorkspaceName as string;
};
/**
* updateLastWorkspaceName updates the last workspace name
* @param workspaceName - The name of the workspace to set as last
* @throws {Error} If the API call fails or returns an invalid response
*/
export const updateLastWorkspaceName = async (workspaceName: string) => {
const response = await apiCall(`${API_BASE_URL}/workspaces/last`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ workspaceName }),
});
if (response.status !== 204) {
throw new Error('Failed to update last workspace name');
}
return;
};

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, type FormEvent } from 'react';
import {
TextInput,
PasswordInput,
@@ -11,20 +11,22 @@ import {
} from '@mantine/core';
import { useAuth } from '../../contexts/AuthContext';
const LoginPage = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const LoginPage: React.FC = () => {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const { login } = useAuth();
const handleSubmit = async (e) => {
const handleSubmit = (e: FormEvent<HTMLElement>): void => {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
} finally {
setLoading(false);
}
login(email, password)
.catch((error) => {
console.error('Login failed:', error);
})
.finally(() => {
setLoading(false);
});
};
return (

View File

@@ -2,10 +2,21 @@ import React from 'react';
import { Text, Center } from '@mantine/core';
import Editor from './Editor';
import MarkdownPreview from './MarkdownPreview';
import { getFileUrl } from '../../services/api';
import { isImageFile } from '../../utils/fileHelpers';
import { getFileUrl, isImageFile } from '../../utils/fileHelpers';
import { useWorkspace } from '@/contexts/WorkspaceContext';
const ContentView = ({
type ViewTab = 'source' | 'preview';
interface ContentViewProps {
activeTab: ViewTab;
selectedFile: string | null;
content: string;
handleContentChange: (content: string) => void;
handleSave: (filePath: string, content: string) => Promise<boolean>;
handleFileSelect: (filePath: string | null) => Promise<void>;
}
const ContentView: React.FC<ContentViewProps> = ({
activeTab,
selectedFile,
content,
@@ -13,10 +24,21 @@ const ContentView = ({
handleSave,
handleFileSelect,
}) => {
const { currentWorkspace } = useWorkspace();
if (!currentWorkspace) {
return (
<Center style={{ height: '100%' }}>
<Text size="xl" fw={500}>
No workspace selected.
</Text>
</Center>
);
}
if (!selectedFile) {
return (
<Center style={{ height: '100%' }}>
<Text size="xl" weight={500}>
<Text size="xl" fw={500}>
No file selected.
</Text>
</Center>
@@ -27,7 +49,7 @@ const ContentView = ({
return (
<Center className="image-preview">
<img
src={getFileUrl(selectedFile)}
src={getFileUrl(currentWorkspace.name, selectedFile)}
alt={selectedFile}
style={{
maxWidth: '100%',

View File

@@ -5,16 +5,28 @@ import { EditorView, keymap } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown';
import { defaultKeymap } from '@codemirror/commands';
import { oneDark } from '@codemirror/theme-one-dark';
import { useWorkspace } from '../../contexts/WorkspaceContext';
import { useWorkspace } from '../../hooks/useWorkspace';
const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
interface EditorProps {
content: string;
handleContentChange: (content: string) => void;
handleSave: (filePath: string, content: string) => Promise<boolean>;
selectedFile: string;
}
const Editor: React.FC<EditorProps> = ({
content,
handleContentChange,
handleSave,
selectedFile,
}) => {
const { colorScheme } = useWorkspace();
const editorRef = useRef();
const viewRef = useRef();
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
useEffect(() => {
const handleEditorSave = (view) => {
handleSave(selectedFile, view.state.doc.toString());
const handleEditorSave = (view: EditorView): boolean => {
void handleSave(selectedFile, view.state.doc.toString());
return true;
};
@@ -36,6 +48,8 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
},
});
if (!editorRef.current) return;
const state = EditorState.create({
doc: content,
extensions: [
@@ -69,8 +83,11 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
return () => {
view.destroy();
viewRef.current = null;
};
}, [colorScheme, handleContentChange]);
// TODO: Refactor
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [colorScheme, handleContentChange, handleSave, selectedFile]);
useEffect(() => {
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {

View File

@@ -1,111 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkMath from 'remark-math';
import remarkRehype from 'remark-rehype';
import rehypeMathjax from 'rehype-mathjax';
import rehypeReact from 'rehype-react';
import rehypePrism from 'rehype-prism';
import * as prod from 'react/jsx-runtime';
import { notifications } from '@mantine/notifications';
import { remarkWikiLinks } from '../../utils/remarkWikiLinks';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const MarkdownPreview = ({ content, handleFileSelect }) => {
const [processedContent, setProcessedContent] = useState(null);
const baseUrl = window.API_BASE_URL;
const { currentWorkspace } = useWorkspace();
const handleLinkClick = (e, href) => {
e.preventDefault();
if (href.startsWith(`${baseUrl}/internal/`)) {
// For existing files, extract the path and directly select it
const [filePath] = decodeURIComponent(
href.replace(`${baseUrl}/internal/`, '')
).split('#');
handleFileSelect(filePath);
} else if (href.startsWith(`${baseUrl}/notfound/`)) {
// For non-existent files, show a notification
const fileName = decodeURIComponent(
href.replace(`${baseUrl}/notfound/`, '')
);
notifications.show({
title: 'File Not Found',
message: `The file "${fileName}" does not exist.`,
color: 'red',
});
}
};
const processor = useMemo(
() =>
unified()
.use(remarkParse)
.use(remarkWikiLinks, currentWorkspace?.name)
.use(remarkMath)
.use(remarkRehype)
.use(rehypeMathjax)
.use(rehypePrism)
.use(rehypeReact, {
production: true,
jsx: prod.jsx,
jsxs: prod.jsxs,
Fragment: prod.Fragment,
components: {
img: ({ src, alt, ...props }) => (
<img
src={src}
alt={alt}
onError={(event) => {
console.error('Failed to load image:', event.target.src);
event.target.alt = 'Failed to load image';
}}
{...props}
/>
),
a: ({ href, children, ...props }) => (
<a
href={href}
onClick={(e) => handleLinkClick(e, href)}
{...props}
>
{children}
</a>
),
code: ({ children, className, ...props }) => {
const language = className
? className.replace('language-', '')
: null;
return (
<pre className={className}>
<code {...props}>{children}</code>
</pre>
);
},
},
}),
[baseUrl, handleFileSelect, currentWorkspace?.name]
);
useEffect(() => {
const processContent = async () => {
if (!currentWorkspace) {
return;
}
try {
const result = await processor.process(content);
setProcessedContent(result.result);
} catch (error) {
console.error('Error processing markdown:', error);
}
};
processContent();
}, [content, processor, currentWorkspace]);
return <div className="markdown-preview">{processedContent}</div>;
};
export default MarkdownPreview;

View File

@@ -0,0 +1,141 @@
import React, { useState, useEffect, useMemo, type ReactNode } from 'react';
import { unified, type Preset } from 'unified';
import remarkParse from 'remark-parse';
import remarkMath from 'remark-math';
import remarkRehype from 'remark-rehype';
import rehypeMathjax from 'rehype-mathjax';
import rehypeReact, { type Options } from 'rehype-react';
import rehypePrism from 'rehype-prism';
import * as prod from 'react/jsx-runtime';
import { notifications } from '@mantine/notifications';
import { remarkWikiLinks } from '../../utils/remarkWikiLinks';
import { useWorkspace } from '../../hooks/useWorkspace';
interface MarkdownPreviewProps {
content: string;
handleFileSelect: (filePath: string | null) => Promise<void>;
}
interface MarkdownImageProps {
src: string;
alt?: string;
[key: string]: unknown;
}
interface MarkdownLinkProps {
href: string;
children: ReactNode;
[key: string]: unknown;
}
interface MarkdownCodeProps {
children: ReactNode;
className?: string;
[key: string]: unknown;
}
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
content,
handleFileSelect,
}) => {
const [processedContent, setProcessedContent] = useState<ReactNode | null>(
null
);
const baseUrl = window.API_BASE_URL;
const { currentWorkspace } = useWorkspace();
const processor = useMemo(() => {
const handleLinkClick = (
e: React.MouseEvent<HTMLAnchorElement>,
href: string
): void => {
e.preventDefault();
if (href.startsWith(`${baseUrl}/internal/`)) {
// For existing files, extract the path and directly select it
const [filePath] = decodeURIComponent(
href.replace(`${baseUrl}/internal/`, '')
).split('#');
if (filePath) {
void handleFileSelect(filePath);
}
} else if (href.startsWith(`${baseUrl}/notfound/`)) {
// For non-existent files, show a notification
const fileName = decodeURIComponent(
href.replace(`${baseUrl}/notfound/`, '')
);
notifications.show({
title: 'File Not Found',
message: `The file "${fileName}" does not exist.`,
color: 'red',
});
}
};
// Only create the processor if we have a workspace name
if (!currentWorkspace?.name) {
return unified();
}
return unified()
.use(remarkParse)
.use(remarkWikiLinks, currentWorkspace.name)
.use(remarkMath)
.use(remarkRehype)
.use(rehypeMathjax)
.use(rehypePrism as Preset)
.use(rehypeReact, {
jsx: prod.jsx,
jsxs: prod.jsxs,
Fragment: prod.Fragment,
development: false,
elementAttributeNameCase: 'react',
stylePropertyNameCase: 'dom',
components: {
img: ({ src, alt, ...props }: MarkdownImageProps) => (
<img
src={src}
alt={alt || ''}
onError={(event) => {
console.error('Failed to load image:', event.currentTarget.src);
event.currentTarget.alt = 'Failed to load image';
}}
{...props}
/>
),
a: ({ href, children, ...props }: MarkdownLinkProps) => (
<a href={href} onClick={(e) => handleLinkClick(e, href)} {...props}>
{children}
</a>
),
code: ({ children, className, ...props }: MarkdownCodeProps) => {
return (
<pre className={className}>
<code {...props}>{children}</code>
</pre>
);
},
},
} as Options);
}, [currentWorkspace?.name, baseUrl, handleFileSelect]);
useEffect(() => {
const processContent = async (): Promise<void> => {
if (!currentWorkspace) {
return;
}
try {
const result = await processor.process(content);
setProcessedContent(result.result as ReactNode);
} catch (error) {
console.error('Error processing markdown:', error);
}
};
void processContent();
}, [content, processor, currentWorkspace]);
return <div className="markdown-preview">{processedContent}</div>;
};
export default MarkdownPreview;

View File

@@ -7,9 +7,17 @@ import {
IconGitCommit,
} from '@tabler/icons-react';
import { useModalContext } from '../../contexts/ModalContext';
import { useWorkspace } from '../../contexts/WorkspaceContext';
import { useWorkspace } from '../../hooks/useWorkspace';
const FileActions = ({ handlePullChanges, selectedFile }) => {
interface FileActionsProps {
handlePullChanges: () => Promise<boolean>;
selectedFile: string | null;
}
const FileActions: React.FC<FileActionsProps> = ({
handlePullChanges,
selectedFile,
}) => {
const { settings } = useWorkspace();
const {
setNewFileModalVisible,
@@ -17,9 +25,9 @@ const FileActions = ({ handlePullChanges, selectedFile }) => {
setCommitMessageModalVisible,
} = useModalContext();
const handleCreateFile = () => setNewFileModalVisible(true);
const handleDeleteFile = () => setDeleteFileModalVisible(true);
const handleCommitAndPush = () => setCommitMessageModalVisible(true);
const handleCreateFile = (): void => setNewFileModalVisible(true);
const handleDeleteFile = (): void => setDeleteFileModalVisible(true);
const handleCommitAndPush = (): void => setCommitMessageModalVisible(true);
return (
<Group gap="xs">
@@ -53,7 +61,11 @@ const FileActions = ({ handlePullChanges, selectedFile }) => {
<ActionIcon
variant="default"
size="md"
onClick={handlePullChanges}
onClick={() => {
handlePullChanges().catch((error) => {
console.error('Error pulling changes:', error);
});
}}
disabled={!settings.gitEnabled}
>
<IconGitPullRequest size={16} />

View File

@@ -1,21 +1,35 @@
import React, { useRef, useState, useLayoutEffect } from 'react';
import { Tree } from 'react-arborist';
import { Tree, type NodeApi } from 'react-arborist';
import { IconFile, IconFolder, IconFolderOpen } from '@tabler/icons-react';
import { Tooltip } from '@mantine/core';
import useResizeObserver from '@react-hook/resize-observer';
import type { FileNode } from '@/types/models';
const useSize = (target) => {
const [size, setSize] = useState();
interface Size {
width: number;
height: number;
}
interface FileTreeProps {
files: FileNode[];
handleFileSelect: (filePath: string | null) => Promise<void>;
showHiddenFiles: boolean;
}
const useSize = (target: React.RefObject<HTMLElement>): Size | undefined => {
const [size, setSize] = useState<Size>();
useLayoutEffect(() => {
setSize(target.current.getBoundingClientRect());
if (target.current) {
setSize(target.current.getBoundingClientRect());
}
}, [target]);
useResizeObserver(target, (entry) => setSize(entry.contentRect));
return size;
};
const FileIcon = ({ node }) => {
const FileIcon = ({ node }: { node: NodeApi<FileNode> }) => {
if (node.isLeaf) {
return <IconFile size={16} />;
}
@@ -26,7 +40,28 @@ const FileIcon = ({ node }) => {
);
};
const Node = ({ node, style, dragHandle }) => {
// Define a Node component that matches what React-Arborist expects
function Node({
node,
style,
dragHandle,
onNodeClick,
...rest
}: {
node: NodeApi<FileNode>;
style: React.CSSProperties;
dragHandle?: React.Ref<HTMLDivElement>;
onNodeClick?: (node: NodeApi<FileNode>) => void;
// Accept any extra props from Arborist, but do not use an index signature
} & Record<string, unknown>) {
const handleClick = () => {
if (node.isInternal) {
node.toggle();
} else if (typeof onNodeClick === 'function') {
onNodeClick(node);
}
};
return (
<Tooltip label={node.data.name} openDelay={500}>
<div
@@ -40,13 +75,8 @@ const Node = ({ node, style, dragHandle }) => {
whiteSpace: 'nowrap',
overflow: 'hidden',
}}
onClick={() => {
if (node.isInternal) {
node.toggle();
} else {
node.tree.props.onNodeClick(node);
}
}}
onClick={handleClick}
{...rest}
>
<FileIcon node={node} />
<span
@@ -63,19 +93,31 @@ const Node = ({ node, style, dragHandle }) => {
</div>
</Tooltip>
);
};
}
const FileTree = ({ files, handleFileSelect, showHiddenFiles }) => {
const target = useRef(null);
const FileTree: React.FC<FileTreeProps> = ({
files,
handleFileSelect,
showHiddenFiles,
}) => {
const target = useRef<HTMLDivElement>(null);
const size = useSize(target);
files = files.filter((file) => {
const filteredFiles = files.filter((file) => {
if (file.name.startsWith('.') && !showHiddenFiles) {
return false;
}
return true;
});
// Handler for node click
const onNodeClick = (node: NodeApi<FileNode>) => {
const fileNode = node.data;
if (!node.isInternal) {
void handleFileSelect(fileNode.path);
}
};
return (
<div
ref={target}
@@ -83,24 +125,20 @@ const FileTree = ({ files, handleFileSelect, showHiddenFiles }) => {
>
{size && (
<Tree
data={files}
data={filteredFiles}
openByDefault={false}
width={size.width}
height={size.height}
indent={24}
rowHeight={28}
onActivate={(node) => {
const fileNode = node.data;
if (!node.isInternal) {
handleFileSelect(node.data.path);
}
}}
onNodeClick={(node) => {
if (!node.isInternal) {
handleFileSelect(node.data.path);
void handleFileSelect(fileNode.path);
}
}}
>
{Node}
{(props) => <Node {...props} onNodeClick={onNodeClick} />}
</Tree>
)}
</div>

View File

@@ -4,7 +4,7 @@ import UserMenu from '../navigation/UserMenu';
import WorkspaceSwitcher from '../navigation/WorkspaceSwitcher';
import WorkspaceSettings from '../settings/workspace/WorkspaceSettings';
const Header = () => {
const Header: React.FC = () => {
return (
<Group justify="space-between" h={60} px="md">
<Text fw={700} size="lg">

View File

@@ -5,9 +5,9 @@ import Sidebar from './Sidebar';
import MainContent from './MainContent';
import { useFileNavigation } from '../../hooks/useFileNavigation';
import { useFileList } from '../../hooks/useFileList';
import { useWorkspace } from '../../contexts/WorkspaceContext';
import { useWorkspace } from '../../hooks/useWorkspace';
const Layout = () => {
const Layout: React.FC = () => {
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
const { selectedFile, handleFileSelect } = useFileNavigation();
const { files, loadFileList } = useFileList();

View File

@@ -10,11 +10,21 @@ import CommitMessageModal from '../modals/git/CommitMessageModal';
import { useFileContent } from '../../hooks/useFileContent';
import { useFileOperations } from '../../hooks/useFileOperations';
import { useGitOperations } from '../../hooks/useGitOperations';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
const [activeTab, setActiveTab] = useState('source');
const { settings } = useWorkspace();
type ViewTab = 'source' | 'preview';
interface MainContentProps {
selectedFile: string | null;
handleFileSelect: (filePath: string | null) => Promise<void>;
loadFileList: () => Promise<void>;
}
const MainContent: React.FC<MainContentProps> = ({
selectedFile,
handleFileSelect,
loadFileList,
}) => {
const [activeTab, setActiveTab] = useState<ViewTab>('source');
const {
content,
hasUnsavedChanges,
@@ -22,15 +32,17 @@ const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
handleContentChange,
} = useFileContent(selectedFile);
const { handleSave, handleCreate, handleDelete } = useFileOperations();
const { handleCommitAndPush } = useGitOperations(settings.gitEnabled);
const { handleCommitAndPush } = useGitOperations();
const handleTabChange = useCallback((value) => {
setActiveTab(value);
const handleTabChange = useCallback((value: string | null): void => {
if (value) {
setActiveTab(value as ViewTab);
}
}, []);
const handleSaveFile = useCallback(
async (filePath, content) => {
let success = await handleSave(filePath, content);
async (filePath: string, fileContent: string): Promise<boolean> => {
const success = await handleSave(filePath, fileContent);
if (success) {
setHasUnsavedChanges(false);
}
@@ -40,22 +52,22 @@ const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
);
const handleCreateFile = useCallback(
async (fileName) => {
async (fileName: string): Promise<void> => {
const success = await handleCreate(fileName);
if (success) {
loadFileList();
handleFileSelect(fileName);
await loadFileList();
await handleFileSelect(fileName);
}
},
[handleCreate, handleFileSelect, loadFileList]
);
const handleDeleteFile = useCallback(
async (filePath) => {
async (filePath: string): Promise<void> => {
const success = await handleDelete(filePath);
if (success) {
loadFileList();
handleFileSelect(null);
await loadFileList();
await handleFileSelect(null);
}
},
[handleDelete, handleFileSelect, loadFileList]

View File

@@ -3,14 +3,27 @@ import { Box } from '@mantine/core';
import FileActions from '../files/FileActions';
import FileTree from '../files/FileTree';
import { useGitOperations } from '../../hooks/useGitOperations';
import { useWorkspace } from '../../contexts/WorkspaceContext';
import { useWorkspace } from '../../hooks/useWorkspace';
import type { FileNode } from '@/types/models';
const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
interface SidebarProps {
selectedFile: string | null;
handleFileSelect: (filePath: string | null) => Promise<void>;
files: FileNode[];
loadFileList: () => Promise<void>;
}
const Sidebar: React.FC<SidebarProps> = ({
selectedFile,
handleFileSelect,
files,
loadFileList,
}) => {
const { settings } = useWorkspace();
const { handlePull } = useGitOperations(settings.gitEnabled);
const { handlePull } = useGitOperations();
useEffect(() => {
loadFileList();
void loadFileList();
}, [loadFileList]);
return (
@@ -28,7 +41,7 @@ const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
<FileTree
files={files}
handleFileSelect={handleFileSelect}
showHiddenFiles={settings.showHiddenFiles}
showHiddenFiles={settings.showHiddenFiles || false}
/>
</Box>
);

View File

@@ -8,8 +8,18 @@ import {
Button,
} from '@mantine/core';
const DeleteAccountModal = ({ opened, onClose, onConfirm }) => {
const [password, setPassword] = useState('');
interface DeleteAccountModalProps {
opened: boolean;
onClose: () => void;
onConfirm: (password: string) => Promise<void>;
}
const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({
opened,
onClose,
onConfirm,
}) => {
const [password, setPassword] = useState<string>('');
return (
<Modal
@@ -40,7 +50,7 @@ const DeleteAccountModal = ({ opened, onClose, onConfirm }) => {
<Button
color="red"
onClick={() => {
onConfirm(password);
void onConfirm(password);
setPassword('');
}}
>

View File

@@ -8,8 +8,20 @@ import {
PasswordInput,
} from '@mantine/core';
const EmailPasswordModal = ({ opened, onClose, onConfirm, email }) => {
const [password, setPassword] = useState('');
interface EmailPasswordModalProps {
opened: boolean;
onClose: () => void;
onConfirm: (password: string) => Promise<void>;
email: string;
}
const EmailPasswordModal: React.FC<EmailPasswordModalProps> = ({
opened,
onClose,
onConfirm,
email,
}) => {
const [password, setPassword] = useState<string>('');
return (
<Modal
@@ -36,7 +48,7 @@ const EmailPasswordModal = ({ opened, onClose, onConfirm, email }) => {
</Button>
<Button
onClick={() => {
onConfirm(password);
void onConfirm(password);
setPassword('');
}}
>

View File

@@ -2,11 +2,15 @@ import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../../contexts/ModalContext';
const CreateFileModal = ({ onCreateFile }) => {
const [fileName, setFileName] = useState('');
interface CreateFileModalProps {
onCreateFile: (fileName: string) => Promise<void>;
}
const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
const [fileName, setFileName] = useState<string>('');
const { newFileModalVisible, setNewFileModalVisible } = useModalContext();
const handleSubmit = async () => {
const handleSubmit = async (): Promise<void> => {
if (fileName) {
await onCreateFile(fileName);
setFileName('');
@@ -38,7 +42,7 @@ const CreateFileModal = ({ onCreateFile }) => {
>
Cancel
</Button>
<Button onClick={handleSubmit}>Create</Button>
<Button onClick={() => void handleSubmit()}>Create</Button>
</Group>
</Box>
</Modal>

View File

@@ -2,11 +2,21 @@ import React from 'react';
import { Modal, Text, Button, Group } from '@mantine/core';
import { useModalContext } from '../../../contexts/ModalContext';
const DeleteFileModal = ({ onDeleteFile, selectedFile }) => {
interface DeleteFileModalProps {
onDeleteFile: (fileName: string) => Promise<void>;
selectedFile: string | null;
}
const DeleteFileModal: React.FC<DeleteFileModalProps> = ({
onDeleteFile,
selectedFile,
}) => {
const { deleteFileModalVisible, setDeleteFileModalVisible } =
useModalContext();
const handleConfirm = async () => {
const handleConfirm = async (): Promise<void> => {
if (!selectedFile) return;
await onDeleteFile(selectedFile);
setDeleteFileModalVisible(false);
};
@@ -18,7 +28,7 @@ const DeleteFileModal = ({ onDeleteFile, selectedFile }) => {
title="Delete File"
centered
>
<Text>Are you sure you want to delete "{selectedFile}"?</Text>
<Text>Are you sure you want to delete &quot;{selectedFile}&quot;?</Text>
<Group justify="flex-end" mt="xl">
<Button
variant="default"
@@ -26,7 +36,7 @@ const DeleteFileModal = ({ onDeleteFile, selectedFile }) => {
>
Cancel
</Button>
<Button color="red" onClick={handleConfirm}>
<Button color="red" onClick={() => void handleConfirm()}>
Delete
</Button>
</Group>

View File

@@ -2,12 +2,18 @@ import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../../contexts/ModalContext';
const CommitMessageModal = ({ onCommitAndPush }) => {
interface CommitMessageModalProps {
onCommitAndPush: (message: string) => Promise<void>;
}
const CommitMessageModal: React.FC<CommitMessageModalProps> = ({
onCommitAndPush,
}) => {
const [message, setMessage] = useState('');
const { commitMessageModalVisible, setCommitMessageModalVisible } =
useModalContext();
const handleSubmit = async () => {
const handleSubmit = async (): Promise<void> => {
if (message) {
await onCommitAndPush(message);
setMessage('');
@@ -39,7 +45,7 @@ const CommitMessageModal = ({ onCommitAndPush }) => {
>
Cancel
</Button>
<Button onClick={handleSubmit}>Commit</Button>
<Button onClick={() => void handleSubmit()}>Commit</Button>
</Group>
</Box>
</Modal>

View File

@@ -8,20 +8,41 @@ import {
Button,
Group,
} from '@mantine/core';
import type { CreateUserRequest } from '@/types/api';
import { UserRole } from '@/types/models';
const CreateUserModal = ({ opened, onClose, onCreateUser, loading }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [role, setRole] = useState('viewer');
interface CreateUserModalProps {
opened: boolean;
onClose: () => void;
onCreateUser: (userData: CreateUserRequest) => Promise<boolean>;
loading: boolean;
}
const handleSubmit = async () => {
const result = await onCreateUser({ email, password, displayName, role });
if (result.success) {
const CreateUserModal: React.FC<CreateUserModalProps> = ({
opened,
onClose,
onCreateUser,
loading,
}) => {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [displayName, setDisplayName] = useState<string>('');
const [role, setRole] = useState<UserRole>(UserRole.Viewer);
const handleSubmit = async (): Promise<void> => {
const userData: CreateUserRequest = {
email,
password,
displayName,
role,
};
const success = await onCreateUser(userData);
if (success) {
setEmail('');
setPassword('');
setDisplayName('');
setRole('viewer');
setRole(UserRole.Viewer);
onClose();
}
};
@@ -53,18 +74,18 @@ const CreateUserModal = ({ opened, onClose, onCreateUser, loading }) => {
label="Role"
required
value={role}
onChange={setRole}
onChange={(value) => value && setRole(value as UserRole)}
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'editor', label: 'Editor' },
{ value: 'viewer', label: 'Viewer' },
{ value: UserRole.Admin, label: 'Admin' },
{ value: UserRole.Editor, label: 'Editor' },
{ value: UserRole.Viewer, label: 'Viewer' },
]}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={loading}>
<Button onClick={() => void handleSubmit} loading={loading}>
Create User
</Button>
</Group>

View File

@@ -1,29 +0,0 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
const DeleteUserModal = ({ opened, onClose, onConfirm, user, loading }) => (
<Modal
opened={opened}
onClose={onClose}
title="Delete User"
centered
size="sm"
>
<Stack>
<Text>
Are you sure you want to delete user "{user?.email}"? This action cannot
be undone and all associated data will be permanently deleted.
</Text>
<Group justify="flex-end" mt="xl">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button color="red" onClick={onConfirm} loading={loading}>
Delete User
</Button>
</Group>
</Stack>
</Modal>
);
export default DeleteUserModal;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import type { User } from '@/types/models';
interface DeleteUserModalProps {
opened: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
user: User | null;
loading: boolean;
}
const DeleteUserModal: React.FC<DeleteUserModalProps> = ({
opened,
onClose,
onConfirm,
user,
loading,
}) => (
<Modal
opened={opened}
onClose={onClose}
title="Delete User"
centered
size="sm"
>
<Stack>
<Text>
Are you sure you want to delete user &quot;{user?.email}&quot;? This
action cannot be undone and all associated data will be permanently
deleted.
</Text>
<Group justify="flex-end" mt="xl">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button color="red" onClick={() => void onConfirm()} loading={loading}>
Delete User
</Button>
</Group>
</Stack>
</Modal>
);
export default DeleteUserModal;

View File

@@ -9,12 +9,28 @@ import {
PasswordInput,
Text,
} from '@mantine/core';
import type { UpdateUserRequest } from '@/types/api';
import { type User, UserRole } from '@/types/models';
const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
const [formData, setFormData] = useState({
interface EditUserModalProps {
opened: boolean;
onClose: () => void;
onEditUser: (userId: number, userData: UpdateUserRequest) => Promise<boolean>;
loading: boolean;
user: User | null;
}
const EditUserModal: React.FC<EditUserModalProps> = ({
opened,
onClose,
onEditUser,
loading,
user,
}) => {
const [formData, setFormData] = useState<UpdateUserRequest>({
email: '',
displayName: '',
role: '',
role: UserRole.Editor,
password: '',
});
@@ -29,18 +45,20 @@ const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
}
}, [user]);
const handleSubmit = async () => {
const handleSubmit = async (): Promise<void> => {
if (!user) return;
const updateData = {
...formData,
...(formData.password ? { password: formData.password } : {}),
};
const result = await onEditUser(user.id, updateData);
if (result.success) {
const success = await onEditUser(user.id, updateData);
if (success) {
setFormData({
email: '',
displayName: '',
role: '',
role: UserRole.Editor,
password: '',
});
onClose();
@@ -70,12 +88,14 @@ const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
<Select
label="Role"
required
value={formData.role}
onChange={(value) => setFormData({ ...formData, role: value })}
value={formData.role ? formData.role.toString() : null}
onChange={(value) =>
setFormData({ ...formData, role: value as UserRole })
}
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'editor', label: 'Editor' },
{ value: 'viewer', label: 'Viewer' },
{ value: UserRole.Admin, label: 'Admin' },
{ value: UserRole.Editor, label: 'Editor' },
{ value: UserRole.Viewer, label: 'Viewer' },
]}
/>
<PasswordInput
@@ -93,7 +113,7 @@ const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={loading}>
<Button onClick={() => void handleSubmit} loading={loading}>
Save Changes
</Button>
</Group>

View File

@@ -1,16 +1,23 @@
import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../../contexts/ModalContext';
import { createWorkspace } from '../../../services/api';
import { notifications } from '@mantine/notifications';
import type { Workspace } from '@/types/models';
import { createWorkspace } from '@/api/workspace';
const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
interface CreateWorkspaceModalProps {
onWorkspaceCreated?: (workspace: Workspace) => Promise<void>;
}
const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
onWorkspaceCreated,
}) => {
const [name, setName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const { createWorkspaceModalVisible, setCreateWorkspaceModalVisible } =
useModalContext();
const handleSubmit = async () => {
const handleSubmit = async (): Promise<void> => {
if (!name.trim()) {
notifications.show({
title: 'Error',
@@ -31,9 +38,9 @@ const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
setName('');
setCreateWorkspaceModalVisible(false);
if (onWorkspaceCreated) {
onWorkspaceCreated(workspace);
await onWorkspaceCreated(workspace);
}
} catch (error) {
} catch (_error) {
notifications.show({
title: 'Error',
message: 'Failed to create workspace',
@@ -70,7 +77,7 @@ const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
>
Cancel
</Button>
<Button onClick={handleSubmit} loading={loading}>
<Button onClick={() => void handleSubmit} loading={loading}>
Create
</Button>
</Group>

View File

@@ -1,7 +1,14 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
const DeleteWorkspaceModal = ({
interface DeleteUserModalProps {
opened: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
workspaceName: string | undefined;
}
const DeleteWorkspaceModal: React.FC<DeleteUserModalProps> = ({
opened,
onClose,
onConfirm,
@@ -16,15 +23,15 @@ const DeleteWorkspaceModal = ({
>
<Stack>
<Text>
Are you sure you want to delete workspace "{workspaceName}"? This action
cannot be undone and all files in this workspace will be permanently
deleted.
Are you sure you want to delete workspace &quot;{workspaceName}&quot;?
This action cannot be undone and all files in this workspace will be
permanently deleted.
</Text>
<Group justify="flex-end" mt="xl">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button color="red" onClick={onConfirm}>
<Button color="red" onClick={() => void onConfirm}>
Delete Workspace
</Button>
</Group>

View File

@@ -17,15 +17,19 @@ import {
import { useAuth } from '../../contexts/AuthContext';
import AccountSettings from '../settings/account/AccountSettings';
import AdminDashboard from '../settings/admin/AdminDashboard';
import { UserRole } from '@/types/models';
import { getHoverStyle } from '@/utils/themeStyles';
const UserMenu = () => {
const [accountSettingsOpened, setAccountSettingsOpened] = useState(false);
const [adminDashboardOpened, setAdminDashboardOpened] = useState(false);
const [opened, setOpened] = useState(false);
const UserMenu: React.FC = () => {
const [accountSettingsOpened, setAccountSettingsOpened] =
useState<boolean>(false);
const [adminDashboardOpened, setAdminDashboardOpened] =
useState<boolean>(false);
const [opened, setOpened] = useState<boolean>(false);
const { user, logout } = useAuth();
const handleLogout = () => {
logout();
const handleLogout = async (): Promise<void> => {
await logout();
};
return (
@@ -57,7 +61,7 @@ const UserMenu = () => {
</Avatar>
<div>
<Text size="sm" fw={500}>
{user.displayName || user.email}
{user?.displayName || user?.email}
</Text>
</div>
</Group>
@@ -72,15 +76,7 @@ const UserMenu = () => {
}}
px="sm"
py="xs"
style={(theme) => ({
borderRadius: theme.radius.sm,
'&:hover': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0],
},
})}
style={(theme) => getHoverStyle(theme)}
>
<Group>
<IconSettings size={16} />
@@ -88,7 +84,7 @@ const UserMenu = () => {
</Group>
</UnstyledButton>
{user.role === 'admin' && (
{user?.role === UserRole.Admin && (
<UnstyledButton
onClick={() => {
setAdminDashboardOpened(true);
@@ -96,15 +92,7 @@ const UserMenu = () => {
}}
px="sm"
py="xs"
style={(theme) => ({
borderRadius: theme.radius.sm,
'&:hover': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0],
},
})}
style={(theme) => getHoverStyle(theme)}
>
<Group>
<IconUsers size={16} />
@@ -114,19 +102,14 @@ const UserMenu = () => {
)}
<UnstyledButton
onClick={handleLogout}
onClick={() => {
void handleLogout();
setOpened(false);
}}
px="sm"
py="xs"
color="red"
style={(theme) => ({
borderRadius: theme.radius.sm,
'&:hover': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0],
},
})}
style={(theme) => getHoverStyle(theme)}
>
<Group>
<IconLogout size={16} color="red" />

View File

@@ -15,21 +15,26 @@ import {
useMantineTheme,
} from '@mantine/core';
import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react';
import { useWorkspace } from '../../contexts/WorkspaceContext';
import { useWorkspace } from '../../hooks/useWorkspace';
import { useModalContext } from '../../contexts/ModalContext';
import { listWorkspaces } from '../../services/api';
import { listWorkspaces } from '../../api/workspace';
import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal';
import type { Workspace } from '@/types/models';
import {
getConditionalColor,
getWorkspacePaperStyle,
} from '@/utils/themeStyles';
const WorkspaceSwitcher = () => {
const WorkspaceSwitcher: React.FC = () => {
const { currentWorkspace, switchWorkspace } = useWorkspace();
const { setSettingsModalVisible, setCreateWorkspaceModalVisible } =
useModalContext();
const [workspaces, setWorkspaces] = useState([]);
const [loading, setLoading] = useState(false);
const [popoverOpened, setPopoverOpened] = useState(false);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [popoverOpened, setPopoverOpened] = useState<boolean>(false);
const theme = useMantineTheme();
const loadWorkspaces = async () => {
const loadWorkspaces = async (): Promise<void> => {
setLoading(true);
try {
const list = await listWorkspaces();
@@ -40,14 +45,16 @@ const WorkspaceSwitcher = () => {
setLoading(false);
};
const handleCreateWorkspace = () => {
const handleCreateWorkspace = (): void => {
setPopoverOpened(false);
setCreateWorkspaceModalVisible(true);
};
const handleWorkspaceCreated = async (newWorkspace) => {
const handleWorkspaceCreated = async (
newWorkspace: Workspace
): Promise<void> => {
await loadWorkspaces();
switchWorkspace(newWorkspace.name);
await switchWorkspace(newWorkspace.name);
};
return (
@@ -64,7 +71,7 @@ const WorkspaceSwitcher = () => {
onClick={() => {
setPopoverOpened((o) => !o);
if (!popoverOpened) {
loadWorkspaces();
void loadWorkspaces();
}
}}
>
@@ -108,24 +115,15 @@ const WorkspaceSwitcher = () => {
key={workspace.name}
p="xs"
withBorder
style={{
backgroundColor: isSelected
? theme.colors.blue[
theme.colorScheme === 'dark' ? 8 : 1
]
: undefined,
borderColor: isSelected
? theme.colors.blue[
theme.colorScheme === 'dark' ? 7 : 5
]
: undefined,
}}
style={(theme) =>
getWorkspacePaperStyle(theme, isSelected)
}
>
<Group justify="space-between" wrap="nowrap">
<UnstyledButton
style={{ flex: 1 }}
onClick={() => {
switchWorkspace(workspace.name);
void switchWorkspace(workspace.name);
setPopoverOpened(false);
}}
>
@@ -134,25 +132,13 @@ const WorkspaceSwitcher = () => {
size="sm"
fw={500}
truncate
c={
isSelected
? theme.colors.blue[
theme.colorScheme === 'dark' ? 0 : 9
]
: undefined
}
c={isSelected ? 'blue' : 'inherit'}
>
{workspace.name}
</Text>
<Text
size="xs"
c={
isSelected
? theme.colorScheme === 'dark'
? theme.colors.blue[2]
: theme.colors.blue[7]
: 'dimmed'
}
c={getConditionalColor(theme, isSelected)}
>
{new Date(
workspace.createdAt
@@ -165,11 +151,7 @@ const WorkspaceSwitcher = () => {
<ActionIcon
variant="subtle"
size="lg"
color={
theme.colorScheme === 'dark'
? 'blue.2'
: 'blue.7'
}
color={getConditionalColor(theme, true)}
onClick={(e) => {
e.stopPropagation();
setSettingsModalVisible(true);

View File

@@ -1,7 +1,11 @@
import React from 'react';
import { Accordion, Title } from '@mantine/core';
const AccordionControl = ({ children }) => (
interface AccordionControlProps {
children: React.ReactNode;
}
const AccordionControl: React.FC<AccordionControlProps> = ({ children }) => (
<Accordion.Control>
<Title order={4}>{children}</Title>
</Accordion.Control>

View File

@@ -16,24 +16,39 @@ import SecuritySettings from './SecuritySettings';
import ProfileSettings from './ProfileSettings';
import DangerZoneSettings from './DangerZoneSettings';
import AccordionControl from '../AccordionControl';
import {
type UserProfileSettings,
type ProfileSettingsState,
type SettingsAction,
SettingsActionType,
} from '@/types/models';
import { getAccordionStyles } from '@/utils/themeStyles';
interface AccountSettingsProps {
opened: boolean;
onClose: () => void;
}
// Reducer for managing settings state
const initialState = {
const initialState: ProfileSettingsState = {
localSettings: {},
initialSettings: {},
hasUnsavedChanges: false,
};
function settingsReducer(state, action) {
function settingsReducer(
state: ProfileSettingsState,
action: SettingsAction<UserProfileSettings>
): ProfileSettingsState {
switch (action.type) {
case 'INIT_SETTINGS':
case SettingsActionType.INIT_SETTINGS:
return {
...state,
localSettings: action.payload,
initialSettings: action.payload,
localSettings: action.payload || {},
initialSettings: action.payload || {},
hasUnsavedChanges: false,
};
case 'UPDATE_LOCAL_SETTINGS':
case SettingsActionType.UPDATE_LOCAL_SETTINGS: {
const newLocalSettings = { ...state.localSettings, ...action.payload };
const hasChanges =
JSON.stringify(newLocalSettings) !==
@@ -43,7 +58,8 @@ function settingsReducer(state, action) {
localSettings: newLocalSettings,
hasUnsavedChanges: hasChanges,
};
case 'MARK_SAVED':
}
case SettingsActionType.MARK_SAVED:
return {
...state,
initialSettings: state.localSettings,
@@ -54,39 +70,51 @@ function settingsReducer(state, action) {
}
}
const AccountSettings = ({ opened, onClose }) => {
const AccountSettings: React.FC<AccountSettingsProps> = ({
opened,
onClose,
}) => {
const { user, refreshUser } = useAuth();
const { loading, updateProfile } = useProfileSettings();
const [state, dispatch] = useReducer(settingsReducer, initialState);
const isInitialMount = useRef(true);
const [emailModalOpened, setEmailModalOpened] = useState(false);
const isInitialMount = useRef<boolean>(true);
const [emailModalOpened, setEmailModalOpened] = useState<boolean>(false);
// Initialize settings on mount
useEffect(() => {
if (isInitialMount.current && user) {
isInitialMount.current = false;
const settings = {
displayName: user.displayName,
const settings: UserProfileSettings = {
displayName: user.displayName || '',
email: user.email,
currentPassword: '',
newPassword: '',
};
dispatch({ type: 'INIT_SETTINGS', payload: settings });
dispatch({
type: SettingsActionType.INIT_SETTINGS,
payload: settings,
});
}
}, [user]);
const handleInputChange = (key, value) => {
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
const handleInputChange = (
key: keyof UserProfileSettings,
value: string
): void => {
dispatch({
type: SettingsActionType.UPDATE_LOCAL_SETTINGS,
payload: { [key]: value } as UserProfileSettings,
});
};
const handleSubmit = async () => {
const updates = {};
const handleSubmit = async (): Promise<void> => {
const updates: UserProfileSettings = {};
const needsPasswordConfirmation =
state.localSettings.email !== state.initialSettings.email;
// Add display name if changed
if (state.localSettings.displayName !== state.initialSettings.displayName) {
updates.displayName = state.localSettings.displayName;
updates.displayName = state.localSettings.displayName || '';
}
// Handle password change
@@ -106,17 +134,17 @@ const AccountSettings = ({ opened, onClose }) => {
// If we're only changing display name or have password already provided, proceed directly
if (!needsPasswordConfirmation || state.localSettings.currentPassword) {
if (needsPasswordConfirmation) {
updates.email = state.localSettings.email;
updates.email = state.localSettings.email || '';
// If we don't have a password change, we still need to include the current password for email change
if (!updates.currentPassword) {
updates.currentPassword = state.localSettings.currentPassword;
updates.currentPassword = state.localSettings.currentPassword || '';
}
}
const result = await updateProfile(updates);
if (result.success) {
const updatedUser = await updateProfile(updates);
if (updatedUser) {
await refreshUser();
dispatch({ type: 'MARK_SAVED' });
dispatch({ type: SettingsActionType.MARK_SAVED });
onClose();
}
} else {
@@ -125,17 +153,20 @@ const AccountSettings = ({ opened, onClose }) => {
}
};
const handleEmailConfirm = async (password) => {
const updates = {
const handleEmailConfirm = async (password: string): Promise<void> => {
const updates: UserProfileSettings = {
...state.localSettings,
currentPassword: password,
};
// Remove any undefined/empty values
Object.keys(updates).forEach((key) => {
if (updates[key] === undefined || updates[key] === '') {
delete updates[key];
const typedKey = key as keyof UserProfileSettings;
if (updates[typedKey] === undefined || updates[typedKey] === '') {
delete updates[typedKey];
}
});
// Remove keys that haven't changed
if (updates.displayName === state.initialSettings.displayName) {
delete updates.displayName;
@@ -144,10 +175,10 @@ const AccountSettings = ({ opened, onClose }) => {
delete updates.email;
}
const result = await updateProfile(updates);
if (result.success) {
const updatedUser = await updateProfile(updates);
if (updatedUser) {
await refreshUser();
dispatch({ type: 'MARK_SAVED' });
dispatch({ type: SettingsActionType.MARK_SAVED });
setEmailModalOpened(false);
onClose();
}
@@ -162,7 +193,7 @@ const AccountSettings = ({ opened, onClose }) => {
centered
size="lg"
>
<Stack spacing="xl">
<Stack gap="xl">
{state.hasUnsavedChanges && (
<Badge color="yellow" variant="light">
Unsaved Changes
@@ -172,25 +203,7 @@ const AccountSettings = ({ opened, onClose }) => {
<Accordion
defaultValue={['profile', 'security', 'danger']}
multiple
styles={(theme) => ({
control: {
paddingTop: theme.spacing.md,
paddingBottom: theme.spacing.md,
},
item: {
borderBottom: `1px solid ${
theme.colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[3]
}`,
'&[data-active]': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[7]
: theme.colors.gray[0],
},
},
})}
styles={(theme) => getAccordionStyles(theme)}
>
<Accordion.Item value="profile">
<AccordionControl>Profile</AccordionControl>
@@ -225,7 +238,7 @@ const AccountSettings = ({ opened, onClose }) => {
Cancel
</Button>
<Button
onClick={handleSubmit}
onClick={() => void handleSubmit}
loading={loading}
disabled={!state.hasUnsavedChanges}
>
@@ -239,7 +252,7 @@ const AccountSettings = ({ opened, onClose }) => {
opened={emailModalOpened}
onClose={() => setEmailModalOpened(false)}
onConfirm={handleEmailConfirm}
email={state.localSettings.email}
email={state.localSettings.email || ''}
/>
</>
);

View File

@@ -4,16 +4,16 @@ import DeleteAccountModal from '../../modals/account/DeleteAccountModal';
import { useAuth } from '../../../contexts/AuthContext';
import { useProfileSettings } from '../../../hooks/useProfileSettings';
const DangerZoneSettings = () => {
const DangerZoneSettings: React.FC = () => {
const { logout } = useAuth();
const { deleteAccount } = useProfileSettings();
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
const [deleteModalOpened, setDeleteModalOpened] = useState<boolean>(false);
const handleDelete = async (password) => {
const result = await deleteAccount(password);
if (result.success) {
const handleDelete = async (password: string): Promise<void> => {
const success = await deleteAccount(password);
if (success) {
setDeleteModalOpened(false);
logout();
await logout();
}
};

View File

@@ -1,9 +1,18 @@
import React from 'react';
import { Box, Stack, TextInput } from '@mantine/core';
import type { UserProfileSettings } from '@/types/models';
const ProfileSettings = ({ settings, onInputChange }) => (
interface ProfileSettingsProps {
settings: UserProfileSettings;
onInputChange: (key: keyof UserProfileSettings, value: string) => void;
}
const ProfileSettingsComponent: React.FC<ProfileSettingsProps> = ({
settings,
onInputChange,
}) => (
<Box>
<Stack spacing="md">
<Stack gap="md">
<TextInput
label="Display Name"
value={settings.displayName || ''}
@@ -20,4 +29,4 @@ const ProfileSettings = ({ settings, onInputChange }) => (
</Box>
);
export default ProfileSettings;
export default ProfileSettingsComponent;

View File

@@ -1,11 +1,22 @@
import React, { useState } from 'react';
import { Box, PasswordInput, Stack, Text } from '@mantine/core';
import type { UserProfileSettings } from '@/types/models';
const SecuritySettings = ({ settings, onInputChange }) => {
interface SecuritySettingsProps {
settings: UserProfileSettings;
onInputChange: (key: keyof UserProfileSettings, value: string) => void;
}
type PasswordField = 'currentPassword' | 'newPassword' | 'confirmNewPassword';
const SecuritySettings: React.FC<SecuritySettingsProps> = ({
settings,
onInputChange,
}) => {
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const handlePasswordChange = (field, value) => {
const handlePasswordChange = (field: PasswordField, value: string) => {
if (field === 'confirmNewPassword') {
setConfirmPassword(value);
// Check if passwords match when either password field changes
@@ -27,7 +38,7 @@ const SecuritySettings = ({ settings, onInputChange }) => {
return (
<Box>
<Stack spacing="md">
<Stack gap="md">
<PasswordInput
label="Current Password"
value={settings.currentPassword || ''}
@@ -55,7 +66,7 @@ const SecuritySettings = ({ settings, onInputChange }) => {
/>
<Text size="xs" c="dimmed">
Password must be at least 8 characters long. Leave password fields
empty if you don't want to change it.
empty if you don&apos;t want to change it.
</Text>
</Stack>
</Box>

View File

@@ -6,13 +6,23 @@ import AdminUsersTab from './AdminUsersTab';
import AdminWorkspacesTab from './AdminWorkspacesTab';
import AdminStatsTab from './AdminStatsTab';
const AdminDashboard = ({ opened, onClose }) => {
interface AdminDashboardProps {
opened: boolean;
onClose: () => void;
}
type AdminTabValue = 'users' | 'workspaces' | 'stats';
const AdminDashboard: React.FC<AdminDashboardProps> = ({ opened, onClose }) => {
const { user: currentUser } = useAuth();
const [activeTab, setActiveTab] = useState('users');
const [activeTab, setActiveTab] = useState<AdminTabValue>('users');
return (
<Modal opened={opened} onClose={onClose} size="xl" title="Admin Dashboard">
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs
value={activeTab}
onChange={(value) => setActiveTab(value as AdminTabValue)}
>
<Tabs.List>
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
Users
@@ -26,7 +36,7 @@ const AdminDashboard = ({ opened, onClose }) => {
</Tabs.List>
<Tabs.Panel value="users" pt="md">
<AdminUsersTab currentUser={currentUser} />
{currentUser && <AdminUsersTab currentUser={currentUser} />}
</Tabs.Panel>
<Tabs.Panel value="workspaces" pt="md">

View File

@@ -4,8 +4,13 @@ import { IconAlertCircle } from '@tabler/icons-react';
import { useAdminData } from '../../../hooks/useAdminData';
import { formatBytes } from '../../../utils/formatBytes';
const AdminStatsTab = () => {
const { data: stats, loading, error } = useAdminData('stats');
interface StatsRow {
label: string;
value: string | number;
}
const AdminStatsTab: React.FC = () => {
const { data: stats, loading, error } = useAdminData<'stats'>('stats');
if (loading) {
return <LoadingOverlay visible={true} />;
@@ -19,7 +24,7 @@ const AdminStatsTab = () => {
);
}
const statsRows = [
const statsRows: StatsRow[] = [
{ label: 'Total Users', value: stats.totalUsers },
{ label: 'Active Users', value: stats.activeUsers },
{ label: 'Total Workspaces', value: stats.totalWorkspaces },
@@ -33,7 +38,7 @@ const AdminStatsTab = () => {
System Statistics
</Text>
<Table striped highlightOnHover withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Metric</Table.Th>

View File

@@ -20,8 +20,14 @@ import { useUserAdmin } from '../../../hooks/useUserAdmin';
import CreateUserModal from '../../modals/user/CreateUserModal';
import EditUserModal from '../../modals/user/EditUserModal';
import DeleteUserModal from '../../modals/user/DeleteUserModal';
import type { User } from '@/types/models';
import type { CreateUserRequest, UpdateUserRequest } from '@/types/api';
const AdminUsersTab = ({ currentUser }) => {
interface AdminUsersTabProps {
currentUser: User;
}
const AdminUsersTab: React.FC<AdminUsersTabProps> = ({ currentUser }) => {
const {
users,
loading,
@@ -31,19 +37,24 @@ const AdminUsersTab = ({ currentUser }) => {
delete: deleteUser,
} = useUserAdmin();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [editModalData, setEditModalData] = useState(null);
const [deleteModalData, setDeleteModalData] = useState(null);
const [createModalOpened, setCreateModalOpened] = useState<boolean>(false);
const [editModalData, setEditModalData] = useState<User | null>(null);
const [deleteModalData, setDeleteModalData] = useState<User | null>(null);
const handleCreateUser = async (userData) => {
const handleCreateUser = async (
userData: CreateUserRequest
): Promise<boolean> => {
return await create(userData);
};
const handleEditUser = async (id, userData) => {
const handleEditUser = async (
id: number,
userData: UpdateUserRequest
): Promise<boolean> => {
return await update(id, userData);
};
const handleDeleteClick = (user) => {
const handleDeleteClick = (user: User): void => {
if (user.id === currentUser.id) {
notifications.show({
title: 'Error',
@@ -55,20 +66,20 @@ const AdminUsersTab = ({ currentUser }) => {
setDeleteModalData(user);
};
const handleDeleteConfirm = async () => {
const handleDeleteConfirm = async (): Promise<void> => {
if (!deleteModalData) return;
const result = await deleteUser(deleteModalData.id);
if (result.success) {
const success = await deleteUser(deleteModalData.id);
if (success) {
setDeleteModalData(null);
}
};
const rows = users.map((user) => (
const renderUserRow = (user: User) => (
<Table.Tr key={user.id}>
<Table.Td>{user.email}</Table.Td>
<Table.Td>{user.displayName}</Table.Td>
<Table.Td>
<Text transform="capitalize">{user.role}</Text>
<Text style={{ textTransform: 'capitalize' }}>{user.role}</Text>
</Table.Td>
<Table.Td>{new Date(user.createdAt).toLocaleDateString()}</Table.Td>
<Table.Td>
@@ -91,7 +102,7 @@ const AdminUsersTab = ({ currentUser }) => {
</Group>
</Table.Td>
</Table.Tr>
));
);
return (
<Box pos="relative">
@@ -130,7 +141,7 @@ const AdminUsersTab = ({ currentUser }) => {
<Table.Th style={{ width: 100 }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
<Table.Tbody>{users.map(renderUserRow)}</Table.Tbody>
</Table>
<CreateUserModal

View File

@@ -1,67 +0,0 @@
import React from 'react';
import {
Table,
Group,
Text,
ActionIcon,
Box,
LoadingOverlay,
Alert,
} from '@mantine/core';
import { IconTrash, IconEdit, IconAlertCircle } from '@tabler/icons-react';
import { useAdminData } from '../../../hooks/useAdminData';
import { formatBytes } from '../../../utils/formatBytes';
const AdminWorkspacesTab = () => {
const { data: workspaces, loading, error } = useAdminData('workspaces');
const rows = workspaces.map((workspace) => (
<Table.Tr key={workspace.id}>
<Table.Td>{workspace.userEmail}</Table.Td>
<Table.Td>{workspace.workspaceName}</Table.Td>
<Table.Td>
{new Date(workspace.workspaceCreatedAt).toLocaleDateString()}
</Table.Td>
<Table.Td>{workspace.totalFiles}</Table.Td>
<Table.Td>{formatBytes(workspace.totalSize)}</Table.Td>
</Table.Tr>
));
return (
<Box pos="relative">
<LoadingOverlay visible={loading} />
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
title="Error"
color="red"
mb="md"
>
{error}
</Alert>
)}
<Group justify="space-between" mb="md">
<Text size="xl" fw={700}>
Workspace Management
</Text>
</Group>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Owner</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Total Files</Table.Th>
<Table.Th>Total Size</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Box>
);
};
export default AdminWorkspacesTab;

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { Table, Group, Text, Box, LoadingOverlay, Alert } from '@mantine/core';
import { IconAlertCircle } from '@tabler/icons-react';
import { useAdminData } from '../../../hooks/useAdminData';
import { formatBytes } from '../../../utils/formatBytes';
import type { FileCountStats, WorkspaceStats } from '@/types/models';
const AdminWorkspacesTab: React.FC = () => {
const {
data: workspaces,
loading,
error,
} = useAdminData<'workspaces'>('workspaces');
const renderWorkspaceRow = (workspace: WorkspaceStats) => {
const fileStats: FileCountStats = workspace.fileCountStats || {
totalFiles: 0,
totalSize: 0,
};
return (
<Table.Tr key={workspace.workspaceID}>
<Table.Td>{workspace.userEmail}</Table.Td>
<Table.Td>{workspace.workspaceName}</Table.Td>
<Table.Td>
{new Date(workspace.workspaceCreatedAt).toLocaleDateString()}
</Table.Td>
<Table.Td>{fileStats.totalFiles}</Table.Td>
<Table.Td>{formatBytes(fileStats.totalSize)}</Table.Td>
</Table.Tr>
);
};
return (
<Box pos="relative">
<LoadingOverlay visible={loading} />
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
title="Error"
color="red"
mb="md"
>
{error}
</Alert>
)}
<Group justify="space-between" mb="md">
<Text size="xl" fw={700}>
Workspace Management
</Text>
</Group>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Owner</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Total Files</Table.Th>
<Table.Th>Total Size</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{!loading && !error && workspaces.map(renderWorkspaceRow)}
</Table.Tbody>
</Table>
</Box>
);
};
export default AdminWorkspacesTab;

View File

@@ -1,24 +0,0 @@
import React from 'react';
import { Text, Switch, Group, Box, Title } from '@mantine/core';
import { useWorkspace } from '../../../contexts/WorkspaceContext';
const AppearanceSettings = ({ themeSettings, onThemeChange }) => {
const { colorScheme, updateColorScheme } = useWorkspace();
const handleThemeChange = () => {
const newTheme = colorScheme === 'dark' ? 'light' : 'dark';
updateColorScheme(newTheme);
onThemeChange(newTheme);
};
return (
<Box mb="md">
<Group justify="space-between" align="center">
<Text size="sm">Dark Mode</Text>
<Switch checked={colorScheme === 'dark'} onChange={handleThemeChange} />
</Group>
</Box>
);
};
export default AppearanceSettings;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { Text, Switch, Group, Box } from '@mantine/core';
import { useTheme } from '../../../contexts/ThemeContext';
import { Theme } from '@/types/models';
interface AppearanceSettingsProps {
onThemeChange: (newTheme: Theme) => void;
}
const AppearanceSettings: React.FC<AppearanceSettingsProps> = ({
onThemeChange,
}) => {
const { colorScheme, updateColorScheme } = useTheme();
const handleThemeChange = (): void => {
const newTheme = colorScheme === 'dark' ? Theme.Light : Theme.Dark;
updateColorScheme(newTheme);
onThemeChange(newTheme);
};
return (
<Box mb="md">
<Group justify="space-between" align="center">
<Text size="sm">Dark Mode</Text>
<Switch checked={colorScheme === 'dark'} onChange={handleThemeChange} />
</Group>
</Box>
);
};
export default AppearanceSettings;

View File

@@ -1,16 +1,16 @@
import React, { useState } from 'react';
import { Box, Button, Title } from '@mantine/core';
import { Box, Button } from '@mantine/core';
import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal';
import { useWorkspace } from '../../../contexts/WorkspaceContext';
import { useWorkspace } from '../../../hooks/useWorkspace';
import { useModalContext } from '../../../contexts/ModalContext';
const DangerZoneSettings = () => {
const DangerZoneSettings: React.FC = () => {
const { currentWorkspace, workspaces, deleteCurrentWorkspace } =
useWorkspace();
const { setSettingsModalVisible } = useModalContext();
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
const [deleteModalOpened, setDeleteModalOpened] = useState<boolean>(false);
const handleDelete = async () => {
const handleDelete = async (): Promise<void> => {
await deleteCurrentWorkspace();
setDeleteModalOpened(false);
setSettingsModalVisible(false);

View File

@@ -1,7 +1,14 @@
import React from 'react';
import { Text, Switch, Tooltip, Group, Box } from '@mantine/core';
const EditorSettings = ({
interface EditorSettingsProps {
autoSave: boolean;
showHiddenFiles: boolean;
onAutoSaveChange: (value: boolean) => void;
onShowHiddenFilesChange: (value: boolean) => void;
}
const EditorSettings: React.FC<EditorSettingsProps> = ({
autoSave,
showHiddenFiles,
onAutoSaveChange,

View File

@@ -1,7 +1,16 @@
import React from 'react';
import { Title, Box, TextInput, Text, Grid } from '@mantine/core';
import { Box, TextInput, Text, Grid } from '@mantine/core';
import type { Workspace } from '@/types/models';
const GeneralSettings = ({ name, onInputChange }) => {
interface GeneralSettingsProps {
name: string;
onInputChange: (key: keyof Workspace, value: string) => void;
}
const GeneralSettings: React.FC<GeneralSettingsProps> = ({
name,
onInputChange,
}) => {
return (
<Box mb="md">
<Grid gutter="md" align="center">
@@ -10,7 +19,7 @@ const GeneralSettings = ({ name, onInputChange }) => {
</Grid.Col>
<Grid.Col span={6}>
<TextInput
value={name || ''}
value={name}
onChange={(event) =>
onInputChange('name', event.currentTarget.value)
}

View File

@@ -8,8 +8,21 @@ import {
Group,
Grid,
} from '@mantine/core';
import type { Workspace } from '@/types/models';
const GitSettings = ({
interface GitSettingsProps {
gitEnabled: boolean;
gitUrl: string;
gitUser: string;
gitToken: string;
gitAutoCommit: boolean;
gitCommitMsgTemplate: string;
gitCommitName: string;
gitCommitEmail: string;
onInputChange: (key: keyof Workspace, value: string | boolean) => void;
}
const GitSettings: React.FC<GitSettingsProps> = ({
gitEnabled,
gitUrl,
gitUser,
@@ -21,7 +34,7 @@ const GitSettings = ({
onInputChange,
}) => {
return (
<Stack spacing="md">
<Stack gap="md">
<Grid gutter="md" align="center">
<Grid.Col span={6}>
<Text size="sm">Enable Git Repository</Text>

View File

@@ -9,7 +9,7 @@ import {
Accordion,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useWorkspace } from '../../../contexts/WorkspaceContext';
import { useWorkspace } from '../../../hooks/useWorkspace';
import AppearanceSettings from './AppearanceSettings';
import EditorSettings from './EditorSettings';
import GitSettings from './GitSettings';
@@ -17,23 +17,39 @@ import GeneralSettings from './GeneralSettings';
import { useModalContext } from '../../../contexts/ModalContext';
import DangerZoneSettings from './DangerZoneSettings';
import AccordionControl from '../AccordionControl';
import {
type Theme,
type Workspace,
type SettingsAction,
SettingsActionType,
} from '@/types/models';
import { getAccordionStyles } from '@/utils/themeStyles';
// State and reducer for workspace settings
interface WorkspaceSettingsState {
localSettings: Partial<Workspace>;
initialSettings: Partial<Workspace>;
hasUnsavedChanges: boolean;
}
const initialState = {
const initialState: WorkspaceSettingsState = {
localSettings: {},
initialSettings: {},
hasUnsavedChanges: false,
};
function settingsReducer(state, action) {
function settingsReducer(
state: WorkspaceSettingsState,
action: SettingsAction<Partial<Workspace>>
): WorkspaceSettingsState {
switch (action.type) {
case 'INIT_SETTINGS':
case SettingsActionType.INIT_SETTINGS:
return {
...state,
localSettings: action.payload,
initialSettings: action.payload,
localSettings: action.payload || {},
initialSettings: action.payload || {},
hasUnsavedChanges: false,
};
case 'UPDATE_LOCAL_SETTINGS':
case SettingsActionType.UPDATE_LOCAL_SETTINGS: {
const newLocalSettings = { ...state.localSettings, ...action.payload };
const hasChanges =
JSON.stringify(newLocalSettings) !==
@@ -43,7 +59,8 @@ function settingsReducer(state, action) {
localSettings: newLocalSettings,
hasUnsavedChanges: hasChanges,
};
case 'MARK_SAVED':
}
case SettingsActionType.MARK_SAVED:
return {
...state,
initialSettings: state.localSettings,
@@ -54,16 +71,16 @@ function settingsReducer(state, action) {
}
}
const WorkspaceSettings = () => {
const WorkspaceSettings: React.FC = () => {
const { currentWorkspace, updateSettings } = useWorkspace();
const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
const [state, dispatch] = useReducer(settingsReducer, initialState);
const isInitialMount = useRef(true);
const isInitialMount = useRef<boolean>(true);
useEffect(() => {
if (isInitialMount.current) {
if (isInitialMount.current && currentWorkspace) {
isInitialMount.current = false;
const settings = {
const settings: Partial<Workspace> = {
name: currentWorkspace.name,
theme: currentWorkspace.theme,
autoSave: currentWorkspace.autoSave,
@@ -77,15 +94,21 @@ const WorkspaceSettings = () => {
gitCommitName: currentWorkspace.gitCommitName,
gitCommitEmail: currentWorkspace.gitCommitEmail,
};
dispatch({ type: 'INIT_SETTINGS', payload: settings });
dispatch({ type: SettingsActionType.INIT_SETTINGS, payload: settings });
}
}, [currentWorkspace]);
const handleInputChange = useCallback((key, value) => {
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
}, []);
const handleInputChange = useCallback(
<K extends keyof Workspace>(key: K, value: Workspace[K]): void => {
dispatch({
type: SettingsActionType.UPDATE_LOCAL_SETTINGS,
payload: { [key]: value } as Partial<Workspace>,
});
},
[]
);
const handleSubmit = async () => {
const handleSubmit = async (): Promise<void> => {
try {
if (!state.localSettings.name?.trim()) {
notifications.show({
@@ -96,7 +119,7 @@ const WorkspaceSettings = () => {
}
await updateSettings(state.localSettings);
dispatch({ type: 'MARK_SAVED' });
dispatch({ type: SettingsActionType.MARK_SAVED });
notifications.show({
message: 'Settings saved successfully',
color: 'green',
@@ -105,7 +128,9 @@ const WorkspaceSettings = () => {
} catch (error) {
console.error('Failed to save settings:', error);
notifications.show({
message: 'Failed to save settings: ' + error.message,
message:
'Failed to save settings: ' +
(error instanceof Error ? error.message : String(error)),
color: 'red',
});
}
@@ -123,7 +148,7 @@ const WorkspaceSettings = () => {
centered
size="lg"
>
<Stack spacing="xl">
<Stack gap="xl">
{state.hasUnsavedChanges && (
<Badge color="yellow" variant="light">
Unsaved Changes
@@ -134,23 +159,7 @@ const WorkspaceSettings = () => {
defaultValue={['general', 'appearance', 'editor', 'git', 'danger']}
multiple
styles={(theme) => ({
control: {
paddingTop: theme.spacing.md,
paddingBottom: theme.spacing.md,
},
item: {
borderBottom: `1px solid ${
theme.colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[3]
}`,
'&[data-active]': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[7]
: theme.colors.gray[0],
},
},
...getAccordionStyles(theme),
chevron: {
'&[data-rotate]': {
transform: 'rotate(180deg)',
@@ -162,7 +171,7 @@ const WorkspaceSettings = () => {
<AccordionControl>General</AccordionControl>
<Accordion.Panel>
<GeneralSettings
name={state.localSettings.name}
name={state.localSettings.name || ''}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
@@ -172,9 +181,8 @@ const WorkspaceSettings = () => {
<AccordionControl>Appearance</AccordionControl>
<Accordion.Panel>
<AppearanceSettings
themeSettings={state.localSettings.theme}
onThemeChange={(newTheme) =>
handleInputChange('theme', newTheme)
onThemeChange={(newTheme: string) =>
handleInputChange('theme', newTheme as Theme)
}
/>
</Accordion.Panel>
@@ -184,12 +192,12 @@ const WorkspaceSettings = () => {
<AccordionControl>Editor</AccordionControl>
<Accordion.Panel>
<EditorSettings
autoSave={state.localSettings.autoSave}
onAutoSaveChange={(value) =>
autoSave={state.localSettings.autoSave || false}
onAutoSaveChange={(value: boolean) =>
handleInputChange('autoSave', value)
}
showHiddenFiles={state.localSettings.showHiddenFiles}
onShowHiddenFilesChange={(value) =>
showHiddenFiles={state.localSettings.showHiddenFiles || false}
onShowHiddenFilesChange={(value: boolean) =>
handleInputChange('showHiddenFiles', value)
}
/>
@@ -200,14 +208,16 @@ const WorkspaceSettings = () => {
<AccordionControl>Git Integration</AccordionControl>
<Accordion.Panel>
<GitSettings
gitEnabled={state.localSettings.gitEnabled}
gitUrl={state.localSettings.gitUrl}
gitUser={state.localSettings.gitUser}
gitToken={state.localSettings.gitToken}
gitAutoCommit={state.localSettings.gitAutoCommit}
gitCommitMsgTemplate={state.localSettings.gitCommitMsgTemplate}
gitCommitName={state.localSettings.gitCommitName}
gitCommitEmail={state.localSettings.gitCommitEmail}
gitEnabled={state.localSettings.gitEnabled || false}
gitUrl={state.localSettings.gitUrl || ''}
gitUser={state.localSettings.gitUser || ''}
gitToken={state.localSettings.gitToken || ''}
gitAutoCommit={state.localSettings.gitAutoCommit || false}
gitCommitMsgTemplate={
state.localSettings.gitCommitMsgTemplate || ''
}
gitCommitName={state.localSettings.gitCommitName || ''}
gitCommitEmail={state.localSettings.gitCommitEmail || ''}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
@@ -225,7 +235,7 @@ const WorkspaceSettings = () => {
<Button variant="default" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSubmit}>Save Changes</Button>
<Button onClick={() => void handleSubmit}>Save Changes</Button>
</Group>
</Stack>
</Modal>

View File

@@ -1,108 +0,0 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
} from 'react';
import { notifications } from '@mantine/notifications';
import * as authApi from '../services/authApi';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [initialized, setInitialized] = useState(false);
// Load user data on mount
useEffect(() => {
const initializeAuth = async () => {
try {
const userData = await authApi.getCurrentUser();
setUser(userData);
} catch (error) {
console.error('Failed to initialize auth:', error);
} finally {
setLoading(false);
setInitialized(true);
}
};
initializeAuth();
}, []);
const login = useCallback(async (email, password) => {
try {
const { user: userData } = await authApi.login(email, password);
setUser(userData);
notifications.show({
title: 'Success',
message: 'Logged in successfully',
color: 'green',
});
return true;
} catch (error) {
console.error('Login failed:', error);
notifications.show({
title: 'Error',
message: error.message || 'Login failed',
color: 'red',
});
return false;
}
}, []);
const logout = useCallback(async () => {
try {
await authApi.logout();
} catch (error) {
console.error('Logout failed:', error);
} finally {
setUser(null);
}
}, []);
const refreshToken = useCallback(async () => {
try {
const success = await authApi.refreshToken();
if (!success) {
await logout();
}
return success;
} catch (error) {
console.error('Token refresh failed:', error);
await logout();
return false;
}
}, [logout]);
const refreshUser = useCallback(async () => {
try {
const userData = await authApi.getCurrentUser();
setUser(userData);
} catch (error) {
console.error('Failed to refresh user data:', error);
}
}, []);
const value = {
user,
loading,
initialized,
login,
logout,
refreshToken,
refreshUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,131 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
} from 'react';
import { notifications } from '@mantine/notifications';
import {
login as apiLogin,
logout as apiLogout,
refreshToken as apiRefreshToken,
getCurrentUser,
} from '@/api/auth';
import type { User } from '@/types/models';
interface AuthContextType {
user: User | null;
loading: boolean;
initialized: boolean;
login: (email: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
refreshToken: () => Promise<boolean>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(null);
interface AuthProviderProps {
children: React.ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [initialized, setInitialized] = useState<boolean>(false);
// Load user data on mount
useEffect(() => {
const initializeAuth = async (): Promise<void> => {
try {
const userData = await getCurrentUser();
setUser(userData);
} catch (error) {
console.error('Failed to initialize auth:', error);
} finally {
setLoading(false);
setInitialized(true);
}
};
void initializeAuth();
}, []);
const login = useCallback(
async (email: string, password: string): Promise<boolean> => {
try {
const userData = await apiLogin(email, password);
setUser(userData);
notifications.show({
title: 'Success',
message: 'Logged in successfully',
color: 'green',
});
return true;
} catch (error) {
console.error('Login failed:', error);
notifications.show({
title: 'Error',
message: error instanceof Error ? error.message : 'Login failed',
color: 'red',
});
return false;
}
},
[]
);
const logout = useCallback(async (): Promise<void> => {
try {
await apiLogout();
} catch (error) {
console.error('Logout failed:', error);
} finally {
setUser(null);
}
}, []);
const refreshToken = useCallback(async (): Promise<boolean> => {
try {
const success = await apiRefreshToken();
if (!success) {
await logout();
}
return success;
} catch (error) {
console.error('Token refresh failed:', error);
await logout();
return false;
}
}, [logout]);
const refreshUser = useCallback(async (): Promise<void> => {
try {
const userData = await getCurrentUser();
setUser(userData);
} catch (error) {
console.error('Failed to refresh user data:', error);
}
}, []);
const value: AuthContextType = {
user,
loading,
initialized,
login,
logout,
refreshToken,
refreshUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -1,36 +0,0 @@
import React, { createContext, useContext, useState } from 'react';
const ModalContext = createContext();
export const ModalProvider = ({ children }) => {
const [newFileModalVisible, setNewFileModalVisible] = useState(false);
const [deleteFileModalVisible, setDeleteFileModalVisible] = useState(false);
const [commitMessageModalVisible, setCommitMessageModalVisible] =
useState(false);
const [settingsModalVisible, setSettingsModalVisible] = useState(false);
const [switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible] =
useState(false);
const [createWorkspaceModalVisible, setCreateWorkspaceModalVisible] =
useState(false);
const value = {
newFileModalVisible,
setNewFileModalVisible,
deleteFileModalVisible,
setDeleteFileModalVisible,
commitMessageModalVisible,
setCommitMessageModalVisible,
settingsModalVisible,
setSettingsModalVisible,
switchWorkspaceModalVisible,
setSwitchWorkspaceModalVisible,
createWorkspaceModalVisible,
setCreateWorkspaceModalVisible,
};
return (
<ModalContext.Provider value={value}>{children}</ModalContext.Provider>
);
};
export const useModalContext = () => useContext(ModalContext);

View File

@@ -0,0 +1,67 @@
import React, {
type ReactNode,
createContext,
useContext,
useState,
} from 'react';
interface ModalContextType {
newFileModalVisible: boolean;
setNewFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
deleteFileModalVisible: boolean;
setDeleteFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
commitMessageModalVisible: boolean;
setCommitMessageModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
settingsModalVisible: boolean;
setSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
switchWorkspaceModalVisible: boolean;
setSwitchWorkspaceModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
createWorkspaceModalVisible: boolean;
setCreateWorkspaceModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
}
// Create the context with a default undefined value
const ModalContext = createContext<ModalContextType | null>(null);
interface ModalProviderProps {
children: ReactNode;
}
export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
const [newFileModalVisible, setNewFileModalVisible] = useState(false);
const [deleteFileModalVisible, setDeleteFileModalVisible] = useState(false);
const [commitMessageModalVisible, setCommitMessageModalVisible] =
useState(false);
const [settingsModalVisible, setSettingsModalVisible] = useState(false);
const [switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible] =
useState(false);
const [createWorkspaceModalVisible, setCreateWorkspaceModalVisible] =
useState(false);
const value: ModalContextType = {
newFileModalVisible,
setNewFileModalVisible,
deleteFileModalVisible,
setDeleteFileModalVisible,
commitMessageModalVisible,
setCommitMessageModalVisible,
settingsModalVisible,
setSettingsModalVisible,
switchWorkspaceModalVisible,
setSwitchWorkspaceModalVisible,
createWorkspaceModalVisible,
setCreateWorkspaceModalVisible,
};
return (
<ModalContext.Provider value={value}>{children}</ModalContext.Provider>
);
};
export const useModalContext = (): ModalContextType => {
const context = useContext(ModalContext);
if (context === null) {
throw new Error('useModalContext must be used within a ModalProvider');
}
return context;
};

View File

@@ -0,0 +1,46 @@
import React, {
createContext,
useContext,
useCallback,
type ReactNode,
} from 'react';
import { useMantineColorScheme, type MantineColorScheme } from '@mantine/core';
interface ThemeContextType {
colorScheme: MantineColorScheme;
updateColorScheme: (newTheme: MantineColorScheme) => void;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
interface ThemeProviderProps {
children: ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { colorScheme, setColorScheme } = useMantineColorScheme();
const updateColorScheme = useCallback(
(newTheme: MantineColorScheme): void => {
setColorScheme(newTheme);
},
[setColorScheme]
);
const value: ThemeContextType = {
colorScheme,
updateColorScheme,
};
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
};
export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

View File

@@ -1,211 +0,0 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from 'react';
import { useMantineColorScheme } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
fetchLastWorkspaceName,
getWorkspace,
updateWorkspace,
updateLastWorkspaceName,
deleteWorkspace,
listWorkspaces,
} from '../services/api';
import { DEFAULT_WORKSPACE_SETTINGS } from '../utils/constants';
const WorkspaceContext = createContext();
export const WorkspaceProvider = ({ children }) => {
const [currentWorkspace, setCurrentWorkspace] = useState(null);
const [workspaces, setWorkspaces] = useState([]);
const [loading, setLoading] = useState(true);
const { colorScheme, setColorScheme } = useMantineColorScheme();
const loadWorkspaces = useCallback(async () => {
try {
const workspaceList = await listWorkspaces();
setWorkspaces(workspaceList);
return workspaceList;
} catch (error) {
console.error('Failed to load workspaces:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspaces list',
color: 'red',
});
return [];
}
}, []);
const loadWorkspaceData = useCallback(async (workspaceName) => {
try {
const workspace = await getWorkspace(workspaceName);
setCurrentWorkspace(workspace);
setColorScheme(workspace.theme);
} catch (error) {
console.error('Failed to load workspace data:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace data',
color: 'red',
});
}
}, []);
const loadFirstAvailableWorkspace = useCallback(async () => {
try {
const allWorkspaces = await listWorkspaces();
if (allWorkspaces.length > 0) {
const firstWorkspace = allWorkspaces[0];
await updateLastWorkspaceName(firstWorkspace.name);
await loadWorkspaceData(firstWorkspace.name);
}
} catch (error) {
console.error('Failed to load first available workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace',
color: 'red',
});
}
}, []);
useEffect(() => {
const initializeWorkspace = async () => {
try {
const { lastWorkspaceName } = await fetchLastWorkspaceName();
if (lastWorkspaceName) {
await loadWorkspaceData(lastWorkspaceName);
} else {
await loadFirstAvailableWorkspace();
}
await loadWorkspaces();
} catch (error) {
console.error('Failed to initialize workspace:', error);
await loadFirstAvailableWorkspace();
} finally {
setLoading(false);
}
};
initializeWorkspace();
}, []);
const switchWorkspace = useCallback(async (workspaceName) => {
try {
setLoading(true);
await updateLastWorkspaceName(workspaceName);
await loadWorkspaceData(workspaceName);
await loadWorkspaces();
} catch (error) {
console.error('Failed to switch workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to switch workspace',
color: 'red',
});
} finally {
setLoading(false);
}
}, []);
const deleteCurrentWorkspace = useCallback(async () => {
if (!currentWorkspace) return;
try {
const allWorkspaces = await loadWorkspaces();
if (allWorkspaces.length <= 1) {
notifications.show({
title: 'Error',
message:
'Cannot delete the last workspace. At least one workspace must exist.',
color: 'red',
});
return;
}
// Delete workspace and get the next workspace ID
const response = await deleteWorkspace(currentWorkspace.name);
// Load the new workspace data
await loadWorkspaceData(response.nextWorkspaceName);
notifications.show({
title: 'Success',
message: 'Workspace deleted successfully',
color: 'green',
});
await loadWorkspaces();
} catch (error) {
console.error('Failed to delete workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete workspace',
color: 'red',
});
}
}, [currentWorkspace]);
const updateSettings = useCallback(
async (newSettings) => {
if (!currentWorkspace) return;
try {
const updatedWorkspace = {
...currentWorkspace,
...newSettings,
};
const response = await updateWorkspace(
currentWorkspace.name,
updatedWorkspace
);
setCurrentWorkspace(response);
setColorScheme(response.theme);
await loadWorkspaces();
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
},
[currentWorkspace, setColorScheme]
);
const updateColorScheme = useCallback(
(newTheme) => {
setColorScheme(newTheme);
},
[setColorScheme]
);
const value = {
currentWorkspace,
workspaces,
settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS,
updateSettings,
loading,
colorScheme,
updateColorScheme,
switchWorkspace,
deleteCurrentWorkspace,
};
return (
<WorkspaceContext.Provider value={value}>
{children}
</WorkspaceContext.Provider>
);
};
export const useWorkspace = () => {
const context = useContext(WorkspaceContext);
if (context === undefined) {
throw new Error('useWorkspace must be used within a WorkspaceProvider');
}
return context;
};

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { ThemeProvider } from './ThemeContext';
import { WorkspaceDataProvider } from './WorkspaceDataContext';
import { useWorkspace as useWorkspaceHook } from '../hooks/useWorkspace';
// Re-export the useWorkspace hook directly for backward compatibility
export const useWorkspace = useWorkspaceHook;
interface WorkspaceProviderProps {
children: React.ReactNode;
}
// Create a backward-compatible WorkspaceProvider that composes our new providers
export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
children,
}) => {
return (
<ThemeProvider>
<WorkspaceDataProvider>{children}</WorkspaceDataProvider>
</ThemeProvider>
);
};

View File

@@ -0,0 +1,146 @@
import React, {
type ReactNode,
createContext,
useContext,
useState,
useEffect,
useCallback,
} from 'react';
import { notifications } from '@mantine/notifications';
import { DEFAULT_WORKSPACE_SETTINGS, type Workspace } from '@/types/models';
import {
getWorkspace,
listWorkspaces,
getLastWorkspaceName,
updateLastWorkspaceName,
} from '@/api/workspace';
import { useTheme } from './ThemeContext';
interface WorkspaceDataContextType {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS;
loading: boolean;
loadWorkspaces: () => Promise<Workspace[]>;
loadWorkspaceData: (workspaceName: string) => Promise<void>;
setCurrentWorkspace: (workspace: Workspace | null) => void;
}
const WorkspaceDataContext = createContext<WorkspaceDataContextType | null>(
null
);
interface WorkspaceDataProviderProps {
children: ReactNode;
}
export const WorkspaceDataProvider: React.FC<WorkspaceDataProviderProps> = ({
children,
}) => {
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(
null
);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const { updateColorScheme } = useTheme();
const loadWorkspaces = useCallback(async (): Promise<Workspace[]> => {
try {
const workspaceList = await listWorkspaces();
setWorkspaces(workspaceList);
return workspaceList;
} catch (error) {
console.error('Failed to load workspaces:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspaces list',
color: 'red',
});
return [];
}
}, []);
const loadWorkspaceData = useCallback(
async (workspaceName: string): Promise<void> => {
try {
const workspace = await getWorkspace(workspaceName);
setCurrentWorkspace(workspace);
updateColorScheme(workspace.theme);
} catch (error) {
console.error('Failed to load workspace data:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace data',
color: 'red',
});
}
},
[updateColorScheme]
);
const loadFirstAvailableWorkspace = useCallback(async (): Promise<void> => {
try {
const allWorkspaces = await listWorkspaces();
if (allWorkspaces.length > 0) {
const firstWorkspace = allWorkspaces[0];
if (!firstWorkspace) throw new Error('No workspaces available');
await updateLastWorkspaceName(firstWorkspace.name);
await loadWorkspaceData(firstWorkspace.name);
}
} catch (error) {
console.error('Failed to load first available workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace',
color: 'red',
});
}
}, [loadWorkspaceData]);
useEffect(() => {
const initializeWorkspace = async (): Promise<void> => {
try {
const lastWorkspaceName = await getLastWorkspaceName();
if (lastWorkspaceName) {
await loadWorkspaceData(lastWorkspaceName);
} else {
await loadFirstAvailableWorkspace();
}
await loadWorkspaces();
} catch (error) {
console.error('Failed to initialize workspace:', error);
await loadFirstAvailableWorkspace();
} finally {
setLoading(false);
}
};
void initializeWorkspace();
}, [loadFirstAvailableWorkspace, loadWorkspaceData, loadWorkspaces]);
const value: WorkspaceDataContextType = {
currentWorkspace,
workspaces,
settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS,
loading,
loadWorkspaces,
loadWorkspaceData,
setCurrentWorkspace,
};
return (
<WorkspaceDataContext.Provider value={value}>
{children}
</WorkspaceDataContext.Provider>
);
};
export const useWorkspaceData = (): WorkspaceDataContextType => {
const context = useContext(WorkspaceDataContext);
if (!context) {
throw new Error(
'useWorkspaceData must be used within a WorkspaceDataProvider'
);
}
return context;
};

View File

@@ -1,48 +0,0 @@
import { useState, useEffect } from 'react';
import { notifications } from '@mantine/notifications';
import { getUsers, getWorkspaces, getSystemStats } from '../services/adminApi';
// Hook for admin data fetching (stats and workspaces)
export const useAdminData = (type) => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const loadData = async () => {
setLoading(true);
setError(null);
try {
let response;
switch (type) {
case 'stats':
response = await getSystemStats();
break;
case 'workspaces':
response = await getWorkspaces();
break;
case 'users':
response = await getUsers();
break;
default:
throw new Error('Invalid data type');
}
setData(response);
} catch (err) {
const message = err.response?.data?.error || err.message;
setError(message);
notifications.show({
title: 'Error',
message: `Failed to load ${type}: ${message}`,
color: 'red',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, [type]);
return { data, loading, error, reload: loadData };
};

View File

@@ -0,0 +1,88 @@
import { useState, useEffect, useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { getUsers, getWorkspaces, getSystemStats } from '@/api/admin';
import type { SystemStats, User, WorkspaceStats } from '@/types/models';
// Possible types of admin data
type AdminDataType = 'stats' | 'workspaces' | 'users';
// Define the return data type based on the requested data type
type AdminData<T extends AdminDataType> = T extends 'stats'
? SystemStats
: T extends 'workspaces'
? WorkspaceStats[]
: T extends 'users'
? User[]
: never;
// Define the return type of the hook
interface AdminDataResult<T extends AdminDataType> {
data: AdminData<T>;
loading: boolean;
error: string | null;
reload: () => Promise<void>;
}
// Hook for admin data fetching (stats and workspaces)
export const useAdminData = <T extends AdminDataType>(
type: T
): AdminDataResult<T> => {
// Initialize with the appropriate empty type
const getInitialData = (): AdminData<T> => {
if (type === 'stats') {
return {} as SystemStats as AdminData<T>;
} else if (type === 'workspaces') {
return [] as WorkspaceStats[] as AdminData<T>;
} else if (type === 'users') {
return [] as User[] as AdminData<T>;
} else {
return [] as unknown as AdminData<T>;
}
};
const [data, setData] = useState<AdminData<T>>(getInitialData());
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
let response;
switch (type) {
case 'stats':
response = await getSystemStats();
break;
case 'workspaces':
response = await getWorkspaces();
break;
case 'users':
response = await getUsers();
break;
default:
throw new Error('Invalid data type');
}
setData(response as AdminData<T>);
} catch (err) {
const message =
err instanceof Error
? (err as { response?: { data?: { error?: string } } })?.response
?.data?.error || err.message
: 'An unknown error occurred';
setError(message);
notifications.show({
title: 'Error',
message: `Failed to load ${type}: ${message}`,
color: 'red',
});
} finally {
setLoading(false);
}
}, [type]);
useEffect(() => {
void loadData();
}, [loadData]);
return { data, loading, error, reload: loadData };
};

View File

@@ -1,25 +1,38 @@
import { useState, useCallback, useEffect } from 'react';
import { fetchFileContent } from '../services/api';
import { isImageFile } from '../utils/fileHelpers';
import { DEFAULT_FILE } from '../utils/constants';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import { getFileContent } from '@/api/file';
import { DEFAULT_FILE } from '@/types/models';
export const useFileContent = (selectedFile) => {
const { currentWorkspace } = useWorkspace();
const [content, setContent] = useState(DEFAULT_FILE.content);
const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
interface UseFileContentResult {
content: string;
setContent: React.Dispatch<React.SetStateAction<string>>;
hasUnsavedChanges: boolean;
setHasUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
loadFileContent: (filePath: string) => Promise<void>;
handleContentChange: (newContent: string) => void;
}
export const useFileContent = (
selectedFile: string | null
): UseFileContentResult => {
const { currentWorkspace } = useWorkspaceData();
const [content, setContent] = useState<string>(DEFAULT_FILE.content);
const [originalContent, setOriginalContent] = useState<string>(
DEFAULT_FILE.content
);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
const loadFileContent = useCallback(
async (filePath) => {
async (filePath: string) => {
if (!currentWorkspace) return;
try {
let newContent;
let newContent: string;
if (filePath === DEFAULT_FILE.path) {
newContent = DEFAULT_FILE.content;
} else if (!isImageFile(filePath)) {
newContent = await fetchFileContent(currentWorkspace.name, filePath);
newContent = await getFileContent(currentWorkspace.name, filePath);
} else {
newContent = ''; // Set empty content for image files
}
@@ -38,12 +51,12 @@ export const useFileContent = (selectedFile) => {
useEffect(() => {
if (selectedFile && currentWorkspace) {
loadFileContent(selectedFile);
void loadFileContent(selectedFile);
}
}, [selectedFile, currentWorkspace, loadFileContent]);
const handleContentChange = useCallback(
(newContent) => {
(newContent: string) => {
setContent(newContent);
setHasUnsavedChanges(newContent !== originalContent);
},

View File

@@ -1,16 +1,22 @@
import { useState, useCallback } from 'react';
import { fetchFileList } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { listFiles } from '../api/file';
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import type { FileNode } from '@/types/models';
export const useFileList = () => {
const [files, setFiles] = useState([]);
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
interface UseFileListResult {
files: FileNode[];
loadFileList: () => Promise<void>;
}
const loadFileList = useCallback(async () => {
export const useFileList = (): UseFileListResult => {
const [files, setFiles] = useState<FileNode[]>([]);
const { currentWorkspace, loading: workspaceLoading } = useWorkspaceData();
const loadFileList = useCallback(async (): Promise<void> => {
if (!currentWorkspace || workspaceLoading) return;
try {
const fileList = await fetchFileList(currentWorkspace.name);
const fileList = await listFiles(currentWorkspace.name);
if (Array.isArray(fileList)) {
setFiles(fileList);
} else {

View File

@@ -1,16 +1,22 @@
import { useState, useCallback, useEffect } from 'react';
import { DEFAULT_FILE } from '../utils/constants';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import { useLastOpenedFile } from './useLastOpenedFile';
import { DEFAULT_FILE } from '@/types/models';
export const useFileNavigation = () => {
const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path);
const [isNewFile, setIsNewFile] = useState(true);
const { currentWorkspace } = useWorkspace();
interface UseFileNavigationResult {
selectedFile: string;
isNewFile: boolean;
handleFileSelect: (filePath: string | null) => Promise<void>;
}
export const useFileNavigation = (): UseFileNavigationResult => {
const [selectedFile, setSelectedFile] = useState<string>(DEFAULT_FILE.path);
const [isNewFile, setIsNewFile] = useState<boolean>(true);
const { currentWorkspace } = useWorkspaceData();
const { loadLastOpenedFile, saveLastOpenedFile } = useLastOpenedFile();
const handleFileSelect = useCallback(
async (filePath) => {
async (filePath: string | null): Promise<void> => {
const newPath = filePath || DEFAULT_FILE.path;
setSelectedFile(newPath);
setIsNewFile(!filePath);
@@ -24,20 +30,20 @@ export const useFileNavigation = () => {
// Load last opened file when workspace changes
useEffect(() => {
const initializeFile = async () => {
const initializeFile = async (): Promise<void> => {
setSelectedFile(DEFAULT_FILE.path);
setIsNewFile(true);
const lastFile = await loadLastOpenedFile();
if (lastFile) {
handleFileSelect(lastFile);
await handleFileSelect(lastFile);
} else {
handleFileSelect(null);
await handleFileSelect(null);
}
};
if (currentWorkspace) {
initializeFile();
void initializeFile();
}
}, [currentWorkspace, loadLastOpenedFile, handleFileSelect]);

View File

@@ -1,15 +1,22 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { saveFileContent, deleteFile } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { saveFile, deleteFile } from '../api/file';
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import { useGitOperations } from './useGitOperations';
import { FileAction } from '@/types/models';
export const useFileOperations = () => {
const { currentWorkspace, settings } = useWorkspace();
interface UseFileOperationsResult {
handleSave: (filePath: string, content: string) => Promise<boolean>;
handleDelete: (filePath: string) => Promise<boolean>;
handleCreate: (fileName: string, initialContent?: string) => Promise<boolean>;
}
export const useFileOperations = (): UseFileOperationsResult => {
const { currentWorkspace, settings } = useWorkspaceData();
const { handleCommitAndPush } = useGitOperations();
const autoCommit = useCallback(
async (filePath, action) => {
async (filePath: string, action: FileAction): Promise<void> => {
if (settings.gitAutoCommit && settings.gitEnabled) {
let commitMessage = settings.gitCommitMsgTemplate
.replace('${filename}', filePath)
@@ -21,21 +28,21 @@ export const useFileOperations = () => {
await handleCommitAndPush(commitMessage);
}
},
[settings]
[settings, handleCommitAndPush]
);
const handleSave = useCallback(
async (filePath, content) => {
async (filePath: string, content: string): Promise<boolean> => {
if (!currentWorkspace) return false;
try {
await saveFileContent(currentWorkspace.name, filePath, content);
await saveFile(currentWorkspace.name, filePath, content);
notifications.show({
title: 'Success',
message: 'File saved successfully',
color: 'green',
});
autoCommit(filePath, 'update');
await autoCommit(filePath, FileAction.Update);
return true;
} catch (error) {
console.error('Error saving file:', error);
@@ -51,7 +58,7 @@ export const useFileOperations = () => {
);
const handleDelete = useCallback(
async (filePath) => {
async (filePath: string): Promise<boolean> => {
if (!currentWorkspace) return false;
try {
@@ -61,7 +68,7 @@ export const useFileOperations = () => {
message: 'File deleted successfully',
color: 'green',
});
autoCommit(filePath, 'delete');
await autoCommit(filePath, FileAction.Delete);
return true;
} catch (error) {
console.error('Error deleting file:', error);
@@ -77,17 +84,17 @@ export const useFileOperations = () => {
);
const handleCreate = useCallback(
async (fileName, initialContent = '') => {
async (fileName: string, initialContent: string = ''): Promise<boolean> => {
if (!currentWorkspace) return false;
try {
await saveFileContent(currentWorkspace.name, fileName, initialContent);
await saveFile(currentWorkspace.name, fileName, initialContent);
notifications.show({
title: 'Success',
message: 'File created successfully',
color: 'green',
});
autoCommit(fileName, 'create');
await autoCommit(fileName, FileAction.Create);
return true;
} catch (error) {
console.error('Error creating new file:', error);

View File

@@ -1,12 +1,18 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { pullChanges, commitAndPush } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { pullChanges, commitAndPush } from '../api/git';
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import type { CommitHash } from '@/types/models';
export const useGitOperations = () => {
const { currentWorkspace, settings } = useWorkspace();
interface UseGitOperationsResult {
handlePull: () => Promise<boolean>;
handleCommitAndPush: (message: string) => Promise<void>;
}
const handlePull = useCallback(async () => {
export const useGitOperations = (): UseGitOperationsResult => {
const { currentWorkspace, settings } = useWorkspaceData();
const handlePull = useCallback(async (): Promise<boolean> => {
if (!currentWorkspace || !settings.gitEnabled) return false;
try {
@@ -29,17 +35,19 @@ export const useGitOperations = () => {
}, [currentWorkspace, settings.gitEnabled]);
const handleCommitAndPush = useCallback(
async (message) => {
if (!currentWorkspace || !settings.gitEnabled) return false;
async (message: string): Promise<void> => {
if (!currentWorkspace || !settings.gitEnabled) return;
const commitHash: CommitHash = await commitAndPush(
currentWorkspace.name,
message
);
try {
await commitAndPush(currentWorkspace.name, message);
notifications.show({
title: 'Success',
message: 'Successfully committed and pushed changes',
message: 'Successfully committed and pushed changes ' + commitHash,
color: 'green',
});
return true;
return;
} catch (error) {
console.error('Failed to commit and push changes:', error);
notifications.show({
@@ -47,7 +55,7 @@ export const useGitOperations = () => {
message: 'Failed to commit and push changes',
color: 'red',
});
return false;
return;
}
},
[currentWorkspace, settings.gitEnabled]

View File

@@ -1,37 +0,0 @@
import { useCallback } from 'react';
import { getLastOpenedFile, updateLastOpenedFile } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
export const useLastOpenedFile = () => {
const { currentWorkspace } = useWorkspace();
const loadLastOpenedFile = useCallback(async () => {
if (!currentWorkspace) return null;
try {
const response = await getLastOpenedFile(currentWorkspace.name);
return response.lastOpenedFilePath || null;
} catch (error) {
console.error('Failed to load last opened file:', error);
return null;
}
}, [currentWorkspace]);
const saveLastOpenedFile = useCallback(
async (filePath) => {
if (!currentWorkspace) return;
try {
await updateLastOpenedFile(currentWorkspace.name, filePath);
} catch (error) {
console.error('Failed to save last opened file:', error);
}
},
[currentWorkspace]
);
return {
loadLastOpenedFile,
saveLastOpenedFile,
};
};

View File

@@ -0,0 +1,42 @@
import { useCallback } from 'react';
import { getLastOpenedFile, updateLastOpenedFile } from '../api/file';
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
interface UseLastOpenedFileResult {
loadLastOpenedFile: () => Promise<string | null>;
saveLastOpenedFile: (filePath: string) => Promise<void>;
}
export const useLastOpenedFile = (): UseLastOpenedFileResult => {
const { currentWorkspace } = useWorkspaceData();
const loadLastOpenedFile = useCallback(async (): Promise<string | null> => {
if (!currentWorkspace) return null;
try {
const response: string = await getLastOpenedFile(currentWorkspace.name);
return response || null;
} catch (error) {
console.error('Failed to load last opened file:', error);
return null;
}
}, [currentWorkspace]);
const saveLastOpenedFile = useCallback(
async (filePath: string): Promise<void> => {
if (!currentWorkspace) return;
try {
await updateLastOpenedFile(currentWorkspace.name, filePath);
} catch (error) {
console.error('Failed to save last opened file:', error);
}
},
[currentWorkspace]
);
return {
loadLastOpenedFile,
saveLastOpenedFile,
};
};

View File

@@ -1,71 +0,0 @@
import { useState, useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { updateProfile, deleteProfile } from '../services/api';
export function useProfileSettings() {
const [loading, setLoading] = useState(false);
const handleProfileUpdate = useCallback(async (updates) => {
setLoading(true);
try {
const updatedUser = await updateProfile(updates);
notifications.show({
title: 'Success',
message: 'Profile updated successfully',
color: 'green',
});
return { success: true, user: updatedUser };
} catch (error) {
let errorMessage = 'Failed to update profile';
if (error.message.includes('password')) {
errorMessage = 'Current password is incorrect';
} else if (error.message.includes('email')) {
errorMessage = 'Email is already in use';
}
notifications.show({
title: 'Error',
message: errorMessage,
color: 'red',
});
return { success: false, error: error.message };
} finally {
setLoading(false);
}
}, []);
const handleAccountDeletion = useCallback(async (password) => {
setLoading(true);
try {
await deleteProfile(password);
notifications.show({
title: 'Success',
message: 'Account deleted successfully',
color: 'green',
});
return { success: true };
} catch (error) {
notifications.show({
title: 'Error',
message: error.message || 'Failed to delete account',
color: 'red',
});
return { success: false, error: error.message };
} finally {
setLoading(false);
}
}, []);
return {
loading,
updateProfile: handleProfileUpdate,
deleteAccount: handleAccountDeletion,
};
}

View File

@@ -0,0 +1,88 @@
import { useState, useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { updateProfile, deleteUser } from '../api/user';
import type { UpdateProfileRequest } from '@/types/api';
import type { User } from '@/types/models';
interface UseProfileSettingsResult {
loading: boolean;
updateProfile: (updates: UpdateProfileRequest) => Promise<User | null>;
deleteAccount: (password: string) => Promise<boolean>;
}
export function useProfileSettings(): UseProfileSettingsResult {
const [loading, setLoading] = useState<boolean>(false);
const handleProfileUpdate = useCallback(
async (updates: UpdateProfileRequest): Promise<User | null> => {
setLoading(true);
try {
const updatedUser = await updateProfile(updates);
notifications.show({
title: 'Success',
message: 'Profile updated successfully',
color: 'green',
});
return updatedUser;
} catch (error) {
let errorMessage = 'Failed to update profile';
if (error instanceof Error) {
if (error.message.includes('password')) {
errorMessage = 'Current password is incorrect';
} else if (error.message.includes('email')) {
errorMessage = 'Email is already in use';
}
}
notifications.show({
title: 'Error',
message: errorMessage,
color: 'red',
});
return null;
} finally {
setLoading(false);
}
},
[]
);
const handleAccountDeletion = useCallback(
async (password: string): Promise<boolean> => {
setLoading(true);
try {
await deleteUser(password);
notifications.show({
title: 'Success',
message: 'Account deleted successfully',
color: 'green',
});
return true;
} catch (error) {
notifications.show({
title: 'Error',
message:
error instanceof Error ? error.message : 'Failed to delete account',
color: 'red',
});
return false;
} finally {
setLoading(false);
}
},
[]
);
return {
loading,
updateProfile: handleProfileUpdate,
deleteAccount: handleAccountDeletion,
};
}

View File

@@ -1,11 +1,28 @@
import { useAdminData } from './useAdminData';
import { createUser, updateUser, deleteUser } from '../services/adminApi';
import {
createUser,
updateUser,
deleteUser as adminDeleteUser,
} from '../api/admin';
import { notifications } from '@mantine/notifications';
import type { User } from '@/types/models';
import type { CreateUserRequest, UpdateUserRequest } from '@/types/api';
export const useUserAdmin = () => {
interface UseUserAdminResult {
users: User[];
loading: boolean;
error: string | null;
create: (userData: CreateUserRequest) => Promise<boolean>;
update: (userId: number, userData: UpdateUserRequest) => Promise<boolean>;
delete: (userId: number) => Promise<boolean>;
}
export const useUserAdmin = (): UseUserAdminResult => {
const { data: users, loading, error, reload } = useAdminData('users');
const handleCreate = async (userData) => {
const handleCreate = async (
userData: CreateUserRequest
): Promise<boolean> => {
try {
await createUser(userData);
notifications.show({
@@ -13,20 +30,23 @@ export const useUserAdmin = () => {
message: 'User created successfully',
color: 'green',
});
reload();
return { success: true };
await reload();
return true;
} catch (err) {
const message = err.response?.data?.error || err.message;
const message = err instanceof Error ? err.message : String(err);
notifications.show({
title: 'Error',
message: `Failed to create user: ${message}`,
color: 'red',
});
return { success: false, error: message };
return false;
}
};
const handleUpdate = async (userId, userData) => {
const handleUpdate = async (
userId: number,
userData: UpdateUserRequest
): Promise<boolean> => {
try {
await updateUser(userId, userData);
notifications.show({
@@ -34,37 +54,37 @@ export const useUserAdmin = () => {
message: 'User updated successfully',
color: 'green',
});
reload();
return { success: true };
await reload();
return true;
} catch (err) {
const message = err.response?.data?.error || err.message;
const message = err instanceof Error ? err.message : String(err);
notifications.show({
title: 'Error',
message: `Failed to update user: ${message}`,
color: 'red',
});
return { success: false, error: message };
return false;
}
};
const handleDelete = async (userId) => {
const handleDelete = async (userId: number): Promise<boolean> => {
try {
await deleteUser(userId);
await adminDeleteUser(userId);
notifications.show({
title: 'Success',
message: 'User deleted successfully',
color: 'green',
});
reload();
return { success: true };
await reload();
return true;
} catch (err) {
const message = err.response?.data?.error || err.message;
const message = err instanceof Error ? err.message : String(err);
notifications.show({
title: 'Error',
message: `Failed to delete user: ${message}`,
color: 'red',
});
return { success: false, error: message };
return false;
}
};

View File

@@ -0,0 +1,37 @@
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import { useTheme } from '../contexts/ThemeContext';
import { useWorkspaceOperations } from './useWorkspaceOperations';
import type { Workspace, DEFAULT_WORKSPACE_SETTINGS } from '@/types/models';
import type { MantineColorScheme } from '@mantine/core';
interface UseWorkspaceResult {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS;
updateSettings: (newSettings: Partial<Workspace>) => Promise<void>;
loading: boolean;
colorScheme: MantineColorScheme;
updateColorScheme: (newTheme: MantineColorScheme) => void;
switchWorkspace: (workspaceName: string) => Promise<void>;
deleteCurrentWorkspace: () => Promise<void>;
}
export const useWorkspace = (): UseWorkspaceResult => {
const { currentWorkspace, workspaces, settings, loading } =
useWorkspaceData();
const { colorScheme, updateColorScheme } = useTheme();
const { switchWorkspace, deleteCurrentWorkspace, updateSettings } =
useWorkspaceOperations();
return {
currentWorkspace,
workspaces,
settings,
updateSettings,
loading,
colorScheme,
updateColorScheme,
switchWorkspace,
deleteCurrentWorkspace,
};
};

View File

@@ -0,0 +1,117 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import {
updateLastWorkspaceName,
updateWorkspace,
deleteWorkspace,
} from '@/api/workspace';
import { useTheme } from '../contexts/ThemeContext';
import type { Workspace } from '@/types/models';
interface UseWorkspaceOperationsResult {
switchWorkspace: (workspaceName: string) => Promise<void>;
deleteCurrentWorkspace: () => Promise<void>;
updateSettings: (newSettings: Partial<Workspace>) => Promise<void>;
}
export const useWorkspaceOperations = (): UseWorkspaceOperationsResult => {
const {
currentWorkspace,
loadWorkspaceData,
loadWorkspaces,
setCurrentWorkspace,
} = useWorkspaceData();
const { updateColorScheme } = useTheme();
const switchWorkspace = useCallback(
async (workspaceName: string): Promise<void> => {
try {
await updateLastWorkspaceName(workspaceName);
await loadWorkspaceData(workspaceName);
await loadWorkspaces();
} catch (error) {
console.error('Failed to switch workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to switch workspace',
color: 'red',
});
}
},
[loadWorkspaceData, loadWorkspaces]
);
const deleteCurrentWorkspace = useCallback(async (): Promise<void> => {
if (!currentWorkspace) return;
try {
const allWorkspaces = await loadWorkspaces();
if (allWorkspaces.length <= 1) {
notifications.show({
title: 'Error',
message:
'Cannot delete the last workspace. At least one workspace must exist.',
color: 'red',
});
return;
}
// Delete workspace and get the next workspace ID
const nextWorkspaceName: string = await deleteWorkspace(
currentWorkspace.name
);
// Load the new workspace data
await loadWorkspaceData(nextWorkspaceName);
notifications.show({
title: 'Success',
message: 'Workspace deleted successfully',
color: 'green',
});
await loadWorkspaces();
} catch (error) {
console.error('Failed to delete workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete workspace',
color: 'red',
});
}
}, [currentWorkspace, loadWorkspaceData, loadWorkspaces]);
const updateSettings = useCallback(
async (newSettings: Partial<Workspace>): Promise<void> => {
if (!currentWorkspace) return;
try {
const updatedWorkspace = {
...currentWorkspace,
...newSettings,
};
const response = await updateWorkspace(
currentWorkspace.name,
updatedWorkspace
);
setCurrentWorkspace(response);
if (newSettings.theme) {
updateColorScheme(response.theme);
}
await loadWorkspaces();
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
},
[currentWorkspace, loadWorkspaces, updateColorScheme, setCurrentWorkspace]
);
return {
switchWorkspace,
deleteCurrentWorkspace,
updateSettings,
};
};

View File

@@ -8,6 +8,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.jsx"></script>
<script type="module" src="./index.tsx"></script>
</body>
</html>

View File

@@ -2,7 +2,9 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
const rootElement = document.getElementById('root') as HTMLElement;
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />

View File

@@ -1,49 +0,0 @@
import { apiCall } from './authApi';
import { API_BASE_URL } from '../utils/constants';
const ADMIN_BASE_URL = `${API_BASE_URL}/admin`;
// User Management
export const getUsers = async () => {
const response = await apiCall(`${ADMIN_BASE_URL}/users`);
return response.json();
};
export const createUser = async (userData) => {
const response = await apiCall(`${ADMIN_BASE_URL}/users`, {
method: 'POST',
body: JSON.stringify(userData),
});
return response.json();
};
export const deleteUser = async (userId) => {
const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, {
method: 'DELETE',
});
if (response.status === 204) {
return;
} else {
throw new Error('Failed to delete user with status: ', response.status);
}
};
export const updateUser = async (userId, userData) => {
const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(userData),
});
return response.json();
};
// Workspace Management
export const getWorkspaces = async () => {
const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`);
return response.json();
};
// System Statistics
export const getSystemStats = async () => {
const response = await apiCall(`${ADMIN_BASE_URL}/stats`);
return response.json();
};

View File

@@ -1,172 +0,0 @@
import { API_BASE_URL } from '../utils/constants';
import { apiCall } from './authApi';
export const updateProfile = async (updates) => {
const response = await apiCall(`${API_BASE_URL}/profile`, {
method: 'PUT',
body: JSON.stringify(updates),
});
return response.json();
};
export const deleteProfile = async (password) => {
const response = await apiCall(`${API_BASE_URL}/profile`, {
method: 'DELETE',
body: JSON.stringify({ password }),
});
return response.json();
};
export const fetchLastWorkspaceName = async () => {
const response = await apiCall(`${API_BASE_URL}/workspaces/last`);
return response.json();
};
export const fetchFileList = async (workspaceName) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files`
);
return response.json();
};
export const fetchFileContent = async (workspaceName, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`
);
return response.text();
};
export const saveFileContent = async (workspaceName, filePath, content) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`,
{
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
body: content,
}
);
return response.json();
};
export const deleteFile = async (workspaceName, filePath) => {
await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`,
{
method: 'DELETE',
}
);
};
export const getWorkspace = async (workspaceName) => {
const response = await apiCall(`${API_BASE_URL}/workspaces/${workspaceName}`);
return response.json();
};
// Combined function to update workspace data including settings
export const updateWorkspace = async (workspaceName, workspaceData) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(workspaceData),
}
);
return response.json();
};
export const pullChanges = async (workspaceName) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/git/pull`,
{
method: 'POST',
}
);
return response.json();
};
export const commitAndPush = async (workspaceName, message) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/git/commit`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message }),
}
);
return response.json();
};
export const getFileUrl = (workspaceName, filePath) => {
return `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`;
};
export const lookupFileByName = async (workspaceName, filename) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/lookup?filename=${encodeURIComponent(
filename
)}`
);
const data = await response.json();
return data.paths;
};
export const updateLastOpenedFile = async (workspaceName, filePath) => {
await apiCall(`${API_BASE_URL}/workspaces/${workspaceName}/files/last`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filePath }),
});
};
export const getLastOpenedFile = async (workspaceName) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/last`
);
return response.json();
};
export const listWorkspaces = async () => {
const response = await apiCall(`${API_BASE_URL}/workspaces`);
return response.json();
};
export const createWorkspace = async (name) => {
const response = await apiCall(`${API_BASE_URL}/workspaces`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
});
return response.json();
};
export const deleteWorkspace = async (workspaceName) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}`,
{
method: 'DELETE',
}
);
return response.json();
};
export const updateLastWorkspaceName = async (workspaceName) => {
const response = await apiCall(`${API_BASE_URL}/workspaces/last`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ workspaceName }),
});
return response.json();
};

View File

@@ -1,98 +0,0 @@
import { API_BASE_URL } from '../utils/constants';
export const apiCall = async (url, options = {}) => {
try {
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (options.method && options.method !== 'GET') {
const csrfToken = document.cookie
.split('; ')
.find((row) => row.startsWith('csrf_token='))
?.split('=')[1];
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
}
const response = await fetch(url, {
...options,
headers,
credentials: 'include',
});
if (response.status === 429) {
throw new Error('Rate limit exceeded');
}
// Handle 401 responses
if (response.status === 401) {
const isRefreshEndpoint = url.endsWith('/auth/refresh');
if (!isRefreshEndpoint) {
// Attempt token refresh and retry the request
const refreshSuccess = await refreshToken();
if (refreshSuccess) {
// Retry the original request
return apiCall(url, options);
}
}
throw new Error('Authentication failed');
}
// Handle other error responses
if (!response.ok && response.status !== 204) {
const errorData = await response.json().catch(() => null);
throw new Error(
errorData?.message || `HTTP error! status: ${response.status}`
);
}
// Return null for 204 responses
if (response.status === 204) {
return null;
}
return response;
} catch (error) {
console.error(`API call failed: ${error.message}`);
throw error;
}
};
// Authentication endpoints
export const login = async (email, password) => {
const response = await apiCall(`${API_BASE_URL}/auth/login`, {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await response.json();
// No need to store tokens as they're in cookies now
return data;
};
export const logout = async () => {
await apiCall(`${API_BASE_URL}/auth/logout`, {
method: 'POST',
});
return;
};
export const refreshToken = async () => {
try {
const response = await apiCall(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
});
return response.status === 200;
} catch (error) {
console.error('Token refresh failed:', error);
return false;
}
};
export const getCurrentUser = async () => {
const response = await apiCall(`${API_BASE_URL}/auth/me`);
return response.json();
};

116
app/src/types/api.ts Normal file
View File

@@ -0,0 +1,116 @@
import { isUser, type User, type UserRole } from './models';
declare global {
interface Window {
API_BASE_URL: string;
}
}
export const API_BASE_URL = window.API_BASE_URL;
/**
* Error response from the API
*/
export interface ErrorResponse {
message: string;
}
/**
* API call options extending the standard RequestInit
*/
export interface ApiCallOptions extends RequestInit {
headers?: HeadersInit;
}
/**
* Login request parameters
*/
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
user: User;
sessionId?: string;
expiresAt?: string; // ISO 8601 string representation of the date
}
export function isLoginResponse(obj: unknown): obj is LoginResponse {
return (
typeof obj === 'object' &&
obj !== null &&
'user' in obj &&
isUser(obj.user) &&
(!('sessionId' in obj) ||
typeof (obj as LoginResponse).sessionId === 'string') &&
(!('expiresAt' in obj) ||
typeof (obj as LoginResponse).expiresAt === 'string')
);
}
// CreateUserRequest holds the request fields for creating a new user
export interface CreateUserRequest {
email: string;
displayName: string;
password: string;
role: UserRole;
}
// UpdateUserRequest holds the request fields for updating a user
export interface UpdateUserRequest {
email?: string;
displayName?: string;
password?: string;
role?: UserRole;
}
export interface LookupResponse {
paths: string[];
}
export function isLookupResponse(obj: unknown): obj is LookupResponse {
return (
typeof obj === 'object' &&
obj !== null &&
'paths' in obj &&
Array.isArray((obj as LookupResponse).paths) &&
(obj as LookupResponse).paths.every((path) => typeof path === 'string')
);
}
export interface SaveFileResponse {
filePath: string;
size: number;
updatedAt: string; // ISO 8601 string representation of the date
}
export function isSaveFileResponse(obj: unknown): obj is SaveFileResponse {
return (
typeof obj === 'object' &&
obj !== null &&
'filePath' in obj &&
typeof (obj as SaveFileResponse).filePath === 'string' &&
'size' in obj &&
typeof (obj as SaveFileResponse).size === 'number' &&
'updatedAt' in obj &&
typeof (obj as SaveFileResponse).updatedAt === 'string'
);
}
export interface UpdateLastOpenedFileRequest {
filePath: string;
}
// UpdateProfileRequest represents a user profile update request
export interface UpdateProfileRequest {
displayName?: string;
email?: string;
currentPassword?: string;
newPassword?: string;
}
// DeleteAccountRequest represents a user account deletion request
export interface DeleteAccountRequest {
password: string;
}

287
app/src/types/models.ts Normal file
View File

@@ -0,0 +1,287 @@
/**
* User model from the API
*/
export interface User {
id: number;
email: string;
displayName?: string;
role: UserRole;
createdAt: string;
lastWorkspaceId: number;
}
/**
* Type guard to check if a value is a valid User
*/
export function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof (value as User).id === 'number' &&
'email' in value &&
typeof (value as User).email === 'string' &&
('displayName' in value
? typeof (value as User).displayName === 'string'
: true) &&
'role' in value &&
isUserRole((value as User).role) &&
'createdAt' in value &&
typeof (value as User).createdAt === 'string' &&
'lastWorkspaceId' in value &&
typeof (value as User).lastWorkspaceId === 'number'
);
}
/**
* User role in the system
*/
export enum UserRole {
Admin = 'admin',
Editor = 'editor',
Viewer = 'viewer',
}
/**
* Type guard to check if a value is a valid UserRole
*/
export function isUserRole(value: unknown): value is UserRole {
return (
typeof value === 'string' &&
Object.values(UserRole).includes(value as UserRole)
);
}
export enum Theme {
Light = 'light',
Dark = 'dark',
}
export interface WorkspaceSettings {
theme: Theme;
autoSave: boolean;
showHiddenFiles: boolean;
gitEnabled: boolean;
gitUrl: string;
gitUser: string;
gitToken: string;
gitAutoCommit: boolean;
gitCommitMsgTemplate: string;
gitCommitName: string;
gitCommitEmail: string;
}
export const DEFAULT_WORKSPACE_SETTINGS: WorkspaceSettings = {
theme: Theme.Light,
autoSave: false,
showHiddenFiles: false,
gitEnabled: false,
gitUrl: '',
gitUser: '',
gitToken: '',
gitAutoCommit: false,
gitCommitMsgTemplate: '${action} ${filename}',
gitCommitName: '',
gitCommitEmail: '',
};
export interface Workspace extends WorkspaceSettings {
id?: number;
userId?: number;
name: string;
createdAt: number | string;
lastOpenedFilePath?: string;
}
export const DEFAULT_WORKSPACE: Workspace = {
name: '',
createdAt: Date.now(),
lastOpenedFilePath: '',
...DEFAULT_WORKSPACE_SETTINGS,
};
export function isWorkspace(obj: unknown): obj is Workspace {
return (
typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
typeof (obj as Workspace).name === 'string' &&
'createdAt' in obj &&
(typeof (obj as Workspace).createdAt === 'number' ||
typeof (obj as Workspace).createdAt === 'string') &&
'theme' in obj &&
typeof (obj as Workspace).theme === 'string' &&
'autoSave' in obj &&
typeof (obj as Workspace).autoSave === 'boolean' &&
'showHiddenFiles' in obj &&
typeof (obj as Workspace).showHiddenFiles === 'boolean' &&
'gitEnabled' in obj &&
typeof (obj as Workspace).gitEnabled === 'boolean' &&
'gitUrl' in obj &&
typeof (obj as Workspace).gitUrl === 'string' &&
'gitUser' in obj &&
typeof (obj as Workspace).gitUser === 'string' &&
'gitToken' in obj &&
typeof (obj as Workspace).gitToken === 'string' &&
'gitAutoCommit' in obj &&
typeof (obj as Workspace).gitAutoCommit === 'boolean' &&
'gitCommitMsgTemplate' in obj &&
typeof (obj as Workspace).gitCommitMsgTemplate === 'string' &&
'gitCommitName' in obj &&
typeof (obj as Workspace).gitCommitName === 'string' &&
'gitCommitEmail' in obj &&
typeof (obj as Workspace).gitCommitEmail === 'string'
);
}
export enum FileAction {
Create = 'create',
Update = 'update',
Delete = 'delete',
Rename = 'rename',
}
export enum FileExtension {
Markdown = '.md',
JPG = '.jpg',
JPEG = '.jpeg',
PNG = '.png',
GIF = '.gif',
WebP = '.webp',
SVG = '.svg',
}
export const IMAGE_EXTENSIONS = [
FileExtension.JPG,
FileExtension.JPEG,
FileExtension.PNG,
FileExtension.GIF,
FileExtension.WebP,
FileExtension.SVG,
];
export interface DefaultFile {
name: string;
path: string;
content: string;
}
export const DEFAULT_FILE: DefaultFile = {
name: 'New File.md',
path: 'New File.md',
content: '# Welcome to NovaMD\n\nStart editing here!',
};
export interface FileNode {
id: string;
name: string;
path: string;
children?: FileNode[];
}
export function isFileNode(obj: unknown): obj is FileNode {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
typeof (obj as FileNode).id === 'string' &&
'name' in obj &&
typeof (obj as FileNode).name === 'string' &&
'path' in obj &&
typeof (obj as FileNode).path === 'string' &&
(!('children' in obj) ||
(obj as FileNode).children === undefined ||
(obj as FileNode).children === null ||
Array.isArray((obj as FileNode).children))
);
}
// WorkspaceStats holds workspace statistics
export interface WorkspaceStats {
userID: number;
userEmail: string;
workspaceID: number;
workspaceName: string;
workspaceCreatedAt: string; // Using ISO string format for time.Time
fileCountStats?: FileCountStats;
}
// Define FileCountStats based on the Go struct definition of storage.FileCountStats
export interface FileCountStats {
totalFiles: number;
totalSize: number;
}
export interface UserStats {
totalUsers: number;
totalWorkspaces: number;
activeUsers: number; // Users with activity in last 30 days
}
// SystemStats holds system-wide statistics
export interface SystemStats extends FileCountStats, UserStats {}
// isSystemStats checks if the given object is a valid SystemStats object
export function isSystemStats(obj: unknown): obj is SystemStats {
return (
typeof obj === 'object' &&
obj !== null &&
'totalUsers' in obj &&
typeof (obj as SystemStats).totalUsers === 'number' &&
'totalWorkspaces' in obj &&
typeof (obj as SystemStats).totalWorkspaces === 'number' &&
'activeUsers' in obj &&
typeof (obj as SystemStats).activeUsers === 'number' &&
'totalFiles' in obj &&
typeof (obj as SystemStats).totalFiles === 'number' &&
'totalSize' in obj &&
typeof (obj as SystemStats).totalSize === 'number'
);
}
export type CommitHash = string;
export enum InlineContainerType {
Paragraph = 'paragraph',
ListItem = 'listItem',
TableCell = 'tableCell',
Blockquote = 'blockquote',
Heading = 'heading',
Emphasis = 'emphasis',
Strong = 'strong',
Delete = 'delete',
}
export const MARKDOWN_REGEX = {
WIKILINK: /(!?)\[\[(.*?)\]\]/g,
} as const;
export enum ModalType {
NewFile = 'newFile',
DeleteFile = 'deleteFile',
CommitMessage = 'commitMessage',
}
export enum SettingsActionType {
INIT_SETTINGS = 'INIT_SETTINGS',
UPDATE_LOCAL_SETTINGS = 'UPDATE_LOCAL_SETTINGS',
MARK_SAVED = 'MARK_SAVED',
}
export interface UserProfileSettings {
displayName?: string;
email?: string;
currentPassword?: string;
newPassword?: string;
}
export interface ProfileSettingsState {
localSettings: UserProfileSettings;
initialSettings: UserProfileSettings;
hasUnsavedChanges: boolean;
}
export interface SettingsAction<T> {
type: SettingsActionType;
payload?: T;
}

View File

@@ -1,67 +0,0 @@
export const API_BASE_URL = window.API_BASE_URL;
export const THEMES = {
LIGHT: 'light',
DARK: 'dark',
};
export const FILE_ACTIONS = {
CREATE: 'create',
DELETE: 'delete',
RENAME: 'rename',
};
export const MODAL_TYPES = {
NEW_FILE: 'newFile',
DELETE_FILE: 'deleteFile',
COMMIT_MESSAGE: 'commitMessage',
};
export const IMAGE_EXTENSIONS = [
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
'.svg',
];
// Renamed from DEFAULT_SETTINGS to be more specific
export const DEFAULT_WORKSPACE_SETTINGS = {
theme: THEMES.LIGHT,
autoSave: false,
gitEnabled: false,
gitUrl: '',
gitUser: '',
gitToken: '',
gitAutoCommit: false,
gitCommitMsgTemplate: '${action} ${filename}',
};
// Template for creating new workspaces
export const DEFAULT_WORKSPACE = {
name: '',
...DEFAULT_WORKSPACE_SETTINGS,
};
export const DEFAULT_FILE = {
name: 'New File.md',
path: 'New File.md',
content: '# Welcome to Lemma\n\nStart editing here!',
};
export const MARKDOWN_REGEX = {
WIKILINK: /(!?)\[\[(.*?)\]\]/g,
};
// List of element types that can contain inline content
export const INLINE_CONTAINER_TYPES = new Set([
'paragraph',
'listItem',
'tableCell',
'blockquote',
'heading',
'emphasis',
'strong',
'delete',
]);

View File

@@ -1,5 +0,0 @@
import { IMAGE_EXTENSIONS } from './constants';
export const isImageFile = (filePath) => {
return IMAGE_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext));
};

View File

@@ -0,0 +1,17 @@
import { API_BASE_URL } from '@/types/api';
import { IMAGE_EXTENSIONS } from '@/types/models';
/**
* Checks if the given file path has an image extension.
* @param filePath - The file path to check.
* @returns True if the file path has an image extension, false otherwise.
*/
export const isImageFile = (filePath: string): boolean => {
return IMAGE_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext));
};
export const getFileUrl = (workspaceName: string, filePath: string) => {
return `${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName
)}/files/${encodeURIComponent(filePath)}`;
};

View File

@@ -1,10 +0,0 @@
export const formatBytes = (bytes) => {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};

View File

@@ -0,0 +1,24 @@
/**
* Units for file size display.
*/
type ByteUnit = 'B' | 'KB' | 'MB' | 'GB';
/**
* An array of size units in ascending order.
*/
const UNITS: readonly ByteUnit[] = ['B', 'KB', 'MB', 'GB'] as const;
/**
* Formats a number of bytes into a human-readable string.
* @param bytes - The number of bytes to format.
* @returns A string representing the formatted file size.
*/
export const formatBytes = (bytes: number): string => {
let size: number = bytes;
let unitIndex: number = 0;
while (size >= 1024 && unitIndex < UNITS.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${UNITS[unitIndex]}`;
};

View File

@@ -1,177 +0,0 @@
import { visit } from 'unist-util-visit';
import { lookupFileByName, getFileUrl } from '../services/api';
import { INLINE_CONTAINER_TYPES, MARKDOWN_REGEX } from './constants';
function createNotFoundLink(fileName, displayText, baseUrl) {
return {
type: 'link',
url: `${baseUrl}/notfound/${encodeURIComponent(fileName)}`,
children: [{ type: 'text', value: displayText }],
data: {
hProperties: { style: { color: 'red', textDecoration: 'underline' } },
},
};
}
function createFileLink(filePath, displayText, heading, baseUrl) {
const url = heading
? `${baseUrl}/internal/${encodeURIComponent(filePath)}#${encodeURIComponent(
heading
)}`
: `${baseUrl}/internal/${encodeURIComponent(filePath)}`;
return {
type: 'link',
url,
children: [{ type: 'text', value: displayText }],
};
}
function createImageNode(workspaceName, filePath, displayText) {
return {
type: 'image',
url: getFileUrl(workspaceName, filePath),
alt: displayText,
title: displayText,
};
}
function addMarkdownExtension(fileName) {
if (fileName.includes('.')) {
return fileName;
}
return `${fileName}.md`;
}
export function remarkWikiLinks(workspaceName) {
return async function transformer(tree) {
if (!workspaceName) {
console.warn('No workspace ID provided to remarkWikiLinks plugin');
return;
}
const baseUrl = window.API_BASE_URL;
const replacements = new Map();
// Find all wiki links
visit(tree, 'text', function (node, index, parent) {
const regex = MARKDOWN_REGEX.WIKILINK;
let match;
const matches = [];
while ((match = regex.exec(node.value)) !== null) {
const [fullMatch, isImage, innerContent] = match;
let fileName, displayText, heading;
const pipeIndex = innerContent.indexOf('|');
const hashIndex = innerContent.indexOf('#');
if (pipeIndex !== -1) {
displayText = innerContent.slice(pipeIndex + 1).trim();
fileName = innerContent.slice(0, pipeIndex).trim();
} else {
displayText = innerContent;
fileName = innerContent;
}
if (hashIndex !== -1 && (pipeIndex === -1 || hashIndex < pipeIndex)) {
heading = fileName.slice(hashIndex + 1).trim();
fileName = fileName.slice(0, hashIndex).trim();
}
matches.push({
fullMatch,
isImage,
fileName,
displayText,
heading,
index: match.index,
});
}
if (matches.length > 0) {
replacements.set(node, { matches, parent, index });
}
});
// Process all matches
for (const [node, { matches, parent }] of replacements) {
const newNodes = [];
let lastIndex = 0;
for (const match of matches) {
// Add text before the match
if (match.index > lastIndex) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex, match.index),
});
}
try {
const lookupFileName = match.isImage
? match.fileName
: addMarkdownExtension(match.fileName);
const paths = await lookupFileByName(workspaceName, lookupFileName);
if (paths && paths.length > 0) {
const filePath = paths[0];
if (match.isImage) {
newNodes.push(
createImageNode(workspaceName, filePath, match.displayText)
);
} else {
newNodes.push(
createFileLink(
filePath,
match.displayText,
match.heading,
baseUrl
)
);
}
} else {
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
);
}
} catch (error) {
console.debug('File lookup failed:', match.fileName, error);
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
);
}
lastIndex = match.index + match.fullMatch.length;
}
// Add any remaining text
if (lastIndex < node.value.length) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex),
});
}
// If the parent is a container that can have inline content,
// replace the text node directly with the new nodes
if (parent && INLINE_CONTAINER_TYPES.has(parent.type)) {
const nodeIndex = parent.children.indexOf(node);
if (nodeIndex !== -1) {
parent.children.splice(nodeIndex, 1, ...newNodes);
}
} else {
// For other types of parents, wrap the nodes in a paragraph
const paragraph = {
type: 'paragraph',
children: newNodes,
};
const nodeIndex = parent.children.indexOf(node);
if (nodeIndex !== -1) {
parent.children.splice(nodeIndex, 1, paragraph);
}
}
}
};
}

View File

@@ -0,0 +1,303 @@
import { visit } from 'unist-util-visit';
import type { Node, Parent } from 'unist';
import type { Text } from 'mdast';
import { lookupFileByName } from '@/api/file';
import { getFileUrl } from './fileHelpers';
import { InlineContainerType, MARKDOWN_REGEX } from '@/types/models';
/**
* Represents a wiki link match from the regex
*/
interface WikiLinkMatch {
fullMatch: string;
isImage: boolean; // Changed from string to boolean
fileName: string;
displayText: string;
heading?: string | undefined;
index: number;
}
/**
* Node replacement information for processing
*/
interface ReplacementInfo {
matches: WikiLinkMatch[];
parent: Parent;
index: number;
}
/**
* Properties for link nodes
*/
interface LinkNodeProps {
style?: {
color?: string;
textDecoration?: string;
};
}
/**
* Link node with data properties
*/
interface LinkNode extends Node {
type: 'link';
url: string;
children: Node[];
data?: {
hProperties?: LinkNodeProps;
};
}
/**
* Image node
*/
interface ImageNode extends Node {
type: 'image';
url: string;
alt?: string;
title?: string;
}
/**
* Text node
*/
interface TextNode extends Node {
type: 'text';
value: string;
}
/**
* Creates a text node with the given value
*/
function createTextNode(value: string): TextNode {
return {
type: 'text',
value,
};
}
/**
* Creates a link node for files that don't exist
*/
function createNotFoundLink(
fileName: string,
displayText: string,
baseUrl: string
): LinkNode {
return {
type: 'link',
url: `${baseUrl}/notfound/${encodeURIComponent(fileName)}`,
children: [createTextNode(displayText)],
data: {
hProperties: { style: { color: 'red', textDecoration: 'underline' } },
},
};
}
/**
* Creates a link node for existing files
*/
function createFileLink(
filePath: string,
displayText: string,
heading: string | undefined,
baseUrl: string
): LinkNode {
const url = heading
? `${baseUrl}/internal/${encodeURIComponent(filePath)}#${encodeURIComponent(
heading
)}`
: `${baseUrl}/internal/${encodeURIComponent(filePath)}`;
return {
type: 'link',
url,
children: [createTextNode(displayText)],
};
}
/**
* Creates an image node
*/
function createImageNode(
workspaceName: string,
filePath: string,
displayText: string
): ImageNode {
return {
type: 'image',
url: getFileUrl(workspaceName, filePath),
alt: displayText,
title: displayText,
};
}
/**
* Adds markdown extension to a filename if it doesn't have one
*/
function addMarkdownExtension(fileName: string): string {
if (fileName.includes('.')) {
return fileName;
}
return `${fileName}.md`;
}
/**
* Determines if a node type can contain inline content
*/
function canContainInline(type: string): boolean {
return Object.values(InlineContainerType).includes(
type as InlineContainerType
);
}
/**
* Plugin for processing wiki-style links in markdown
*/
export function remarkWikiLinks(workspaceName: string) {
return async function transformer(tree: Node): Promise<void> {
if (!workspaceName) {
console.warn('No workspace ID provided to remarkWikiLinks plugin');
return;
}
const baseUrl: string = window.API_BASE_URL;
const replacements = new Map<Text, ReplacementInfo>();
// Find all wiki links
visit(tree, 'text', function (node: Text, index: number, parent: Parent) {
const regex = MARKDOWN_REGEX.WIKILINK;
let match: RegExpExecArray | null;
const matches: WikiLinkMatch[] = [];
while ((match = regex.exec(node.value)) !== null) {
// Provide default values during destructuring to handle potential undefined values
const [fullMatch = '', isImageMark = '', innerContent = ''] = match;
// Skip if we somehow got a match without the expected content
if (!innerContent) {
console.warn('Matched wiki link without inner content:', fullMatch);
continue;
}
let fileName: string;
let displayText: string;
let heading: string | undefined;
// Convert isImageMark string to boolean
const isImage: boolean = isImageMark === '!';
const pipeIndex: number = innerContent.indexOf('|');
const hashIndex: number = innerContent.indexOf('#');
if (pipeIndex !== -1) {
displayText = innerContent.slice(pipeIndex + 1).trim();
fileName = innerContent.slice(0, pipeIndex).trim();
} else {
displayText = innerContent;
fileName = innerContent;
}
if (hashIndex !== -1 && (pipeIndex === -1 || hashIndex < pipeIndex)) {
heading = fileName.slice(hashIndex + 1).trim();
fileName = fileName.slice(0, hashIndex).trim();
}
matches.push({
fullMatch,
isImage,
fileName,
displayText,
heading,
index: match.index,
});
}
if (matches.length > 0) {
replacements.set(node, { matches, parent, index });
}
});
// Process all matches
for (const [node, { matches, parent }] of replacements) {
const newNodes: (LinkNode | ImageNode | TextNode)[] = [];
let lastIndex: number = 0;
for (const match of matches) {
// Add text before the match
if (match.index > lastIndex) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex, match.index),
});
}
try {
const lookupFileName: string = match.isImage
? match.fileName
: addMarkdownExtension(match.fileName);
const paths: string[] = await lookupFileByName(
workspaceName,
lookupFileName
);
if (paths && paths.length > 0 && paths[0]) {
const filePath: string = paths[0];
if (match.isImage) {
newNodes.push(
createImageNode(workspaceName, filePath, match.displayText)
);
} else {
newNodes.push(
createFileLink(
filePath,
match.displayText,
match.heading,
baseUrl
)
);
}
} else {
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
);
}
} catch (error) {
console.debug('File lookup failed:', match.fileName, error);
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
);
}
lastIndex = match.index + match.fullMatch.length;
}
// Add any remaining text
if (lastIndex < node.value.length) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex),
});
}
// Replace nodes in parent
if (parent && canContainInline(parent.type)) {
const nodeIndex: number = parent.children.indexOf(node);
if (nodeIndex !== -1) {
parent.children.splice(nodeIndex, 1, ...newNodes);
}
} else {
// Wrap in paragraph for other types
const paragraph: Parent = {
type: 'paragraph',
children: newNodes,
};
const nodeIndex: number = parent.children.indexOf(node);
if (nodeIndex !== -1) {
parent.children.splice(nodeIndex, 1, paragraph);
}
}
}
};
}

View File

@@ -0,0 +1,80 @@
import type { MantineTheme } from '@mantine/core';
// For type safety - this property exists on the MantineTheme but may not be in types
interface ThemeWithColorScheme extends MantineTheme {
colorScheme?: 'dark' | 'light';
}
// Type-safe hover style function for unstyledButton and similar components
export const getHoverStyle = (theme: MantineTheme) => ({
borderRadius: theme.radius.sm,
'&:hover': {
backgroundColor:
(theme as ThemeWithColorScheme).colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0],
},
});
// Type-safe color function for text or components that need conditional colors
export const getConditionalColor = (
theme: MantineTheme,
isSelected = false
) => {
if (isSelected) {
return (theme as ThemeWithColorScheme).colorScheme === 'dark'
? theme.colors.blue[2]
: theme.colors.blue[7];
}
return 'dimmed';
};
// Helper for accordion styling
export const getAccordionStyles = (theme: MantineTheme) => ({
control: {
paddingTop: theme.spacing.md,
paddingBottom: theme.spacing.md,
},
item: {
borderBottom: `1px solid ${
(theme as ThemeWithColorScheme).colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[3]
}`,
'&[data-active]': {
backgroundColor:
(theme as ThemeWithColorScheme).colorScheme === 'dark'
? theme.colors.dark[7]
: theme.colors.gray[0],
},
},
});
// Helper for workspace paper styling
export const getWorkspacePaperStyle = (
theme: MantineTheme,
isSelected: boolean
) => ({
backgroundColor: isSelected
? theme.colors.blue[
(theme as ThemeWithColorScheme).colorScheme === 'dark' ? 8 : 1
]
: undefined,
borderColor: isSelected
? theme.colors.blue[
(theme as ThemeWithColorScheme).colorScheme === 'dark' ? 7 : 5
]
: undefined,
});
// Helper for text color based on theme and selection
export const getTextColor = (
theme: MantineTheme,
isSelected: boolean
): string | null => {
if (!isSelected) return null;
return (theme as ThemeWithColorScheme).colorScheme === 'dark'
? theme.colors.blue[0]
: theme.colors.blue[9];
};

59
app/tsconfig.json Normal file
View File

@@ -0,0 +1,59 @@
{
"compilerOptions": {
/* Language and Environment */
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"useDefineForClassFields": true,
/* Modules */
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"isolatedModules": true,
/* JavaScript Support */
"allowJs": false,
"checkJs": false,
/* Emit */
"noEmit": true,
"sourceMap": true,
"outDir": "./dist",
/* Type Checking */
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"alwaysStrict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
/* Completeness */
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
/* Additional Checks */
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"],
"references": [{ "path": "./tsconfig.node.json" }]
}

33
app/tsconfig.node.json Normal file
View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
/* Basic Options */
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2020",
/* Strict Type-Checking Options */
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
/* Additional Checks */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
/* Module Resolution Options */
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}

View File

@@ -9,7 +9,7 @@ import { compression } from 'vite-plugin-compression2';
export default defineConfig(({ mode }) => ({
plugins: [
react({
include: ['**/*.jsx', '**/*.js'],
include: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'],
}),
compression(),
],
@@ -124,7 +124,7 @@ export default defineConfig(({ mode }) => ({
alias: {
'@': path.resolve(__dirname, './src'),
},
extensions: ['.js', '.jsx', '.json'],
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
},
// Add performance optimization options

View File

@@ -0,0 +1,35 @@
version: "3.8"
services:
postgres:
image: postgres:17
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: lemma_test
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
pgadmin:
image: dpage/pgadmin4
environment:
PGADMIN_DEFAULT_EMAIL: admin@admin.com
PGADMIN_DEFAULT_PASSWORD: admin
PGADMIN_CONFIG_SERVER_MODE: "False"
ports:
- "8080:80"
volumes:
- pgadmin-data:/var/lib/pgadmin
depends_on:
- postgres
volumes:
postgres-data:
pgadmin-data:

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