218 Commits

Author SHA1 Message Date
c450b4ae2f Merge pull request #32 from lordmathis/fix/docker-ca-certs
Add ca-certs to docker
2024-12-27 20:14:17 +01:00
eb98f801a1 Add ca-certs to docker 2024-12-27 20:10:19 +01:00
f1f622cda3 Merge pull request #31 from lordmathis/chore/update-readme
Update README config section
2024-12-27 17:01:59 +01:00
5079f3c96d Update README config section 2024-12-27 16:55:56 +01:00
2861b1a719 Merge pull request #30 from lordmathis/chore/update-docs
Update documentation
2024-12-27 16:50:27 +01:00
9bc1b2ecd4 Update documentation 2024-12-27 16:37:31 +01:00
339ab657b8 Merge pull request #29 from lordmathis/chore/rename
Rename app to Lemma
2024-12-20 00:08:18 +01:00
28d2c053a8 Rename missed files 2024-12-20 00:05:23 +01:00
b6b4c01f0e Rename app to Lemma 2024-12-19 23:59:27 +01:00
5598c0861d Merge pull request #28 from lordmathis/feat/logging
Implement structured logging
2024-12-19 23:45:28 +01:00
f0b6aa0d6e Setup test logging 2024-12-19 23:42:19 +01:00
cf2e1809a4 Delete more debug logs 2024-12-19 22:26:29 +01:00
b065938211 Remove too many debug messages 2024-12-19 22:00:42 +01:00
0aa67f5cc2 Ignore env dev file 2024-12-18 22:17:22 +01:00
f6de4fb839 Log the config after loading 2024-12-18 22:16:20 +01:00
7ccd36f0e4 Add logging to handlers 2024-12-17 23:28:01 +01:00
54cefac4b7 Update package comment 2024-12-16 22:39:26 +01:00
032ae1354c Add logging to storage package 2024-12-16 22:38:21 +01:00
03e78c3e6b Harmonize logging 2024-12-16 21:25:17 +01:00
b00f01f033 Add logging to secrets package 2024-12-16 21:25:06 +01:00
51004d980d Implement logging for git package 2024-12-16 21:16:31 +01:00
e7a48fcd27 Add logging to context 2024-12-16 20:59:21 +01:00
d8528d1b42 Merge pull request #27 from LordMathis/dependabot/go_modules/server/go_modules-5a9c29dde4
Bump golang.org/x/crypto from 0.21.0 to 0.31.0 in /server in the go_modules group across 1 directory
2024-12-15 18:48:04 +01:00
3edce8a0b9 Add logging to auth package 2024-12-15 18:03:04 +01:00
d6680d8e03 Update db logging 2024-12-15 17:28:53 +01:00
8dd57bdf0b Use lowercase for log messages 2024-12-15 17:13:40 +01:00
a32e0957ed Add logging to app package 2024-12-15 16:46:29 +01:00
ab00276f0d Add logging to db package 2024-12-15 14:12:39 +01:00
d14eae4de4 Add Logger interface 2024-12-15 13:34:31 +01:00
71df436a93 Simplify logging 2024-12-14 23:59:28 +01:00
1ee8d94789 Add logger to server 2024-12-12 21:06:15 +01:00
9d82b6426c Use slog for logging 2024-12-12 20:53:35 +01:00
dependabot[bot]
6280586aaf Bump golang.org/x/crypto
Bumps the go_modules group with 1 update in the /server directory: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.21.0 to 0.31.0
- [Commits](https://github.com/golang/crypto/compare/v0.21.0...v0.31.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-12 15:49:43 +00:00
ea916c3ecc Initial logging implementation 2024-12-10 22:16:50 +01:00
3d03da221b Merge pull request #26 from LordMathis/feat/cookies
Implement cookie auth
2024-12-09 21:19:35 +01:00
26a208123a Implement cookie auth on frontend 2024-12-09 20:57:56 +01:00
8a62c9e306 Regenerate docs 2024-12-08 17:47:44 +01:00
a7a97caa6a Regenerate api docs 2024-12-08 17:46:59 +01:00
8ebbe1e076 Update api docs 2024-12-08 17:43:50 +01:00
ed6ac312ed Fix failing logout tests 2024-12-08 17:22:39 +01:00
2268ea48f2 Fix session validation 2024-12-08 17:13:34 +01:00
69af630332 Update tests to use test user struct 2024-12-08 15:03:39 +01:00
2e1ccb45d6 Remove extra argument 2024-12-07 23:16:12 +01:00
5633406f5c Update handler integration tests 2024-12-07 23:09:57 +01:00
ad4af2f82d Update auth test 2024-12-07 21:41:37 +01:00
8a4508e29f Update session and cookie managers 2024-12-07 21:19:02 +01:00
de9e9102db Migrate backend auth to cookies 2024-12-05 21:56:35 +01:00
b4528c1561 Merge pull request #25 from LordMathis/chore/readme-badge
Readme badges
2024-12-04 22:09:10 +01:00
1c8e16fc80 Rename workflow 2024-12-04 21:52:21 +01:00
bd90cf3813 Add workflow badges 2024-12-04 21:50:49 +01:00
836e233954 Merge pull request #24 from LordMathis/chore/apidocs
Create swagger api docs
2024-12-04 21:44:35 +01:00
3d36c67c90 Update frontend requests 2024-12-04 21:33:34 +01:00
dc8fc6c225 Working swagger api docs 2024-12-03 22:06:57 +01:00
e413e955c5 Update api docs 2024-12-03 21:50:16 +01:00
c400d81c87 Add initial api doc comments 2024-12-02 22:28:12 +01:00
08e76671d0 Rework app setup 2024-12-02 21:23:47 +01:00
7d74821f29 Merge pull request #23 from LordMathis/chore/update-readme
Update README
2024-11-30 22:11:17 +01:00
66415738b6 Add upgrading section 2024-11-30 22:07:58 +01:00
dff66ab1b4 Update README 2024-11-30 22:05:26 +01:00
05933f8c22 Merge pull request #22 from LordMathis/fix/missing-git-email
Fix missing commit author name and email
2024-11-30 21:36:14 +01:00
68ad70b0b7 Add commit author and email to frontend settings 2024-11-30 21:21:54 +01:00
d5d2792689 Fix missing admin user setup 2024-11-30 19:38:47 +01:00
31d00681a1 Update handlers and db 2024-11-30 16:12:51 +01:00
4359267d55 Add commit name and commit email to workspace model 2024-11-30 13:56:07 +01:00
c07f19bbb3 Merge migrations 2024-11-30 13:55:29 +01:00
325cedc235 Add commit name and commit email to git client 2024-11-30 12:54:47 +01:00
453c022959 Merge pull request #21 from LordMathis/chore/split-main
Split main into app package
2024-11-30 12:11:06 +01:00
de2a9364ab Split main into app package 2024-11-30 12:06:34 +01:00
cfa048b8de Merge pull request #20 from LordMathis/chore/backend-test
Implement backend tests
2024-11-30 11:51:35 +01:00
842513f8a5 Add test tags to github workflow 2024-11-30 11:48:57 +01:00
ae48761d34 Implement workspace handlers integration tests 2024-11-30 11:44:17 +01:00
8bed3614ee Fix user deletion handler 2024-11-30 00:12:14 +01:00
2a53be5a6e Fix user update tests 2024-11-30 00:09:01 +01:00
af9ab42969 Add integration tests for use handlers 2024-11-29 23:57:17 +01:00
d47b601447 Rename mock secrets 2024-11-29 23:15:37 +01:00
1ddf93a8be Implement git handlers integration test 2024-11-29 23:14:36 +01:00
6aa3fd6c65 Add script for generating single file documentation 2024-11-28 22:05:27 +01:00
9b4db528ca Fix lint issues 2024-11-28 21:55:01 +01:00
f5d616fe00 Update documentation 2024-11-28 21:53:03 +01:00
51ed9e53a4 Implement static handler tests 2024-11-28 21:33:28 +01:00
3fb40a8817 Implement file handlers integration tests 2024-11-28 21:18:47 +01:00
91489ca633 Update path validation error handling 2024-11-28 21:18:30 +01:00
fbb8fa3a60 Implement admin handlers integration test 2024-11-27 21:28:59 +01:00
4ddf1f570f Implement auth handler integration test 2024-11-26 22:50:43 +01:00
e8868dde39 Test users and workspaces 2024-11-25 21:58:16 +01:00
32bd202d6f Implement session and system tests 2024-11-25 21:44:43 +01:00
9ac047d440 Delete unused test case fixture 2024-11-25 20:54:49 +01:00
1e7cd0934e Add migrations tests 2024-11-24 00:17:08 +01:00
9d81b1036d Refactor db init 2024-11-23 22:33:55 +01:00
9f241271a7 Test context package 2024-11-23 22:15:25 +01:00
8f2f8b30dd Test secrets package 2024-11-23 21:28:15 +01:00
1150c4ba39 Test config package 2024-11-23 16:36:29 +01:00
ebdd7bd741 Implement auth package tests 2024-11-23 00:29:26 +01:00
b3ec4e136c Implement auth tests 2024-11-22 23:17:59 +01:00
807e96a76c Rework db package to make it testable 2024-11-21 22:36:12 +01:00
2faefb6db5 Implement JWTManager interface 2024-11-21 21:25:29 +01:00
435dce89d9 Add go test workflow 2024-11-21 19:42:50 +01:00
6cb5aec372 Implement storage git tests 2024-11-20 22:06:38 +01:00
7396b57a5d Rework gitutils package to make it testable 2024-11-19 22:43:24 +01:00
53e52bfdb5 Test workspace 2024-11-19 22:17:00 +01:00
de2c9a6d0c Implement files test 2024-11-19 21:44:06 +01:00
2fe642ac61 Rework mock filesystem 2024-11-19 21:43:52 +01:00
408746187e Implement test list files 2024-11-14 22:11:40 +01:00
e4510298ed Rename filesystem interfaces and structs 2024-11-14 21:13:45 +01:00
5311d2e144 Move storage to separate file 2024-11-13 22:34:22 +01:00
6a9461d928 Rename fs variable 2024-11-13 22:32:43 +01:00
93963b1867 Refactor filesystem to make it testable 2024-11-13 22:31:04 +01:00
52ffb17e2d Merge pull request #19 from LordMathis/chore/rename-folders
Rename root folders
2024-11-12 21:28:42 +01:00
fb1c9a499f Rename root folders 2024-11-12 21:25:02 +01:00
f4c21edca0 Merge pull request #18 from LordMathis/feat/show-hidden-setting
Implement show hidden files setting
2024-11-12 21:08:23 +01:00
1b58b693d0 Add show hidden files toggle to settings 2024-11-12 20:27:56 +01:00
d11525732d Filter hidden files on frontend 2024-11-12 20:15:12 +01:00
03cdb133e7 Fix get workspace db query 2024-11-12 20:14:58 +01:00
bac4702771 Add show_hidden_files filed to workspace 2024-11-11 22:24:27 +01:00
f3f3cb7371 Merge pull request #17 from LordMathis/feat/security-hardening
Implement Rate Limit, Secure Headers and CORS
2024-11-10 21:09:02 +01:00
d4c671caa7 Increase default rate limit 2024-11-10 20:56:04 +01:00
29b35f6b91 Add password length check 2024-11-10 20:49:07 +01:00
e275b45c86 Add secure headers and cors middlewares 2024-11-10 20:43:24 +01:00
77d9abb691 Implement rate limiting 2024-11-10 18:12:25 +01:00
8cf850a62c Merge pull request #16 from LordMathis/feat/admin-dashboard
Admin dashboard
2024-11-10 15:16:42 +01:00
5e2d434b4b Implement admin dash workspaces tab 2024-11-10 15:03:51 +01:00
148001be43 Implement admin edit user 2024-11-10 00:05:32 +01:00
33bc28560f Update admin stats 2024-11-09 23:33:07 +01:00
118591df62 Unify errors 2024-11-09 23:12:52 +01:00
7b1da94e8a Implement file system stats 2024-11-09 23:11:23 +01:00
9688b2d528 Split filesystem.go file 2024-11-09 21:59:04 +01:00
ebf32e775c Remove not working stats 2024-11-08 23:58:57 +01:00
dd3ea9f65f Improve admin dashboard 2024-11-08 23:49:12 +01:00
51751a5af6 Load users in AdminDashboard 2024-11-07 22:12:37 +01:00
0480c165ae Implement admin api handlers 2024-11-07 21:32:09 +01:00
24f877e50b Initial admin dashboard layout 2024-11-06 23:34:37 +01:00
adf5287db2 Merge pull request #15 from LordMathis/chore/componants-structure
Restructure components
2024-11-06 23:16:05 +01:00
0f6dcd3a60 Split AccountSettings 2024-11-06 23:12:38 +01:00
7f8c40c3a2 Reorganize components 2024-11-06 22:31:29 +01:00
64029615ea Merge pull request #14 from LordMathis/feat/user-auth
User authentication and account settings
2024-11-06 21:56:48 +01:00
1a14c06be2 Retrieve pass hash from db 2024-11-06 21:52:46 +01:00
48f75b3839 Update AccountSettings layout 2024-11-06 21:51:45 +01:00
e56378f1f0 Imlement user update on frontend 2024-11-05 21:56:08 +01:00
505b93ff09 Implement update and delete account handlers 2024-11-05 21:49:09 +01:00
8b8bfaa8c8 Add account settings 2024-11-05 21:03:25 +01:00
9581e32e06 Implement frontend logout 2024-11-04 22:26:05 +01:00
771650d66e Reset file when workspace switch 2024-11-04 22:09:11 +01:00
69afef15ec Make logging in work on frontend 2024-11-04 21:51:38 +01:00
9cdbf9fec8 Add initial frontend auth implementation 2024-11-03 23:16:57 +01:00
fae628c02b Remove user id from frintend api call 2024-11-03 22:14:17 +01:00
927d1feb05 Split user and workspace contexts 2024-11-03 22:02:39 +01:00
c8cc854fd6 Rework request context handler 2024-11-03 19:17:10 +01:00
dfd9544fba Reorganize handlers 2024-11-03 17:41:17 +01:00
72680abdf4 Setup api auth middleware 2024-11-01 17:04:24 +01:00
46eeb18a31 Setup jwt signing key 2024-11-01 17:04:08 +01:00
34868c53eb Update api for auth 2024-11-01 15:43:04 +01:00
be0f97ab24 Update db for auth 2024-11-01 15:42:46 +01:00
ce245c980a Implement jwt auth backend 2024-11-01 15:42:31 +01:00
ea91d8d608 Merge pull request #13 from LordMathis/fix/package-json
Fix wrong package.json
2024-10-31 23:17:02 +01:00
0b0a5253f0 Fix wrong package.json 2024-10-31 23:13:23 +01:00
f8cd11c9ac Merge pull request #12 from LordMathis/feat/remark
Migrate from react-markdown to remark
2024-10-31 23:04:42 +01:00
0ed2813643 Migrate to rehype-mathjax 2024-10-31 23:03:50 +01:00
3c855fce21 Migrate to remark 2024-10-31 22:41:50 +01:00
a5aa2dd45b Merge pull request #11 from LordMathis/feat/encrypt-tokens
Feat/encrypt tokens
2024-10-31 21:24:40 +01:00
43f35b7943 Update README 2024-10-31 21:19:47 +01:00
f8728923a5 Encrypt git token 2024-10-31 21:15:38 +01:00
72817aa06a Merge pull request #10 from LordMathis/feat/central-config
Centralize env vars loading
2024-10-30 22:13:02 +01:00
a11a6b18bf Centralize env vars loading 2024-10-30 22:07:17 +01:00
0fe1186ea6 Merge pull request #9 from LordMathis/feat/last-opened-file
Feat/last opened file
2024-10-30 21:55:23 +01:00
477ada70e8 Fix admin user error check 2024-10-30 21:47:37 +01:00
a73296451e Load last opened file on frontend 2024-10-30 21:43:37 +01:00
bef682e5cb Move last opened file to workspaces table 2024-10-30 21:37:07 +01:00
1086c402ec Merge pull request #8 from LordMathis/fix/file-list
Fix/file list
2024-10-29 22:18:28 +01:00
a62a6f8966 Fix workspace initialization 2024-10-29 22:01:13 +01:00
3ba9d57b11 Select default files when no files listed 2024-10-29 21:46:59 +01:00
91f0ebe99c Recursively sort filelist 2024-10-29 21:38:36 +01:00
ad3fa28bc7 Show file list on load 2024-10-29 21:38:15 +01:00
1695b411e1 Unify env var reading in backend 2024-10-29 20:41:11 +01:00
87a2f12766 Merge pull request #7 from LordMathis/feat/vite
Feat/vite
2024-10-28 21:51:12 +01:00
d82f7e2b1e Serve precompressed files 2024-10-28 21:45:11 +01:00
d417b9f0c7 Set up compression 2024-10-28 21:29:38 +01:00
64a86152d6 Split bundle to chunks 2024-10-28 21:11:52 +01:00
69506f739c Update sass api 2024-10-28 20:43:23 +01:00
012465fb02 Rename files to .jsx 2024-10-28 20:36:04 +01:00
552016f61d Set up vite configuration 2024-10-28 20:32:42 +01:00
f1b5027e7f Merge pull request #6 from LordMathis/feat/workspaces
Feat/workspaces
2024-10-27 21:40:35 +01:00
239b441aa6 Improve Settings modal 2024-10-27 21:34:59 +01:00
ba4a0dadca Implement workspace deletion 2024-10-27 21:19:42 +01:00
b679af08e7 Enable workspace renaming 2024-10-27 18:15:11 +01:00
c5e0937070 Add rename and delete workspace ui elements 2024-10-27 17:39:56 +01:00
17c03c2d14 Update Workspace Settings on frontend 2024-10-27 16:53:15 +01:00
4544af8f0f Combine settings and workspaces tables 2024-10-27 16:00:06 +01:00
ab7b018f88 Update WorkspaceSwitcher ui 2024-10-27 14:50:21 +01:00
eaad78730e Save default settings with workspace creation 2024-10-27 12:44:01 +01:00
bbd7358d15 Add CreateWorkspaceModal 2024-10-26 23:29:33 +02:00
12312137b7 Implement workspace switching 2024-10-26 23:15:23 +02:00
fd313c1d7f Add Workspace menu 2024-10-22 21:47:10 +02:00
05dd3a83b0 Fix settings saving 2024-10-22 21:21:18 +02:00
a231fc48c2 Remove debug print 2024-10-22 18:32:46 +02:00
4ade504b5b Run go mod tidy 2024-10-22 18:32:33 +02:00
1c59f8da4f Fix file list loading 2024-10-22 00:11:03 +02:00
ffe82b335a Get full workspace object 2024-10-21 23:48:50 +02:00
749461f11b Fix router 2024-10-19 14:10:49 +02:00
2f87d87833 Fix admin user creation 2024-10-19 13:49:11 +02:00
6eb3eecb24 Add Workspace context 2024-10-19 13:48:37 +02:00
3b7bf83073 Set up admin user 2024-10-19 12:07:58 +02:00
1df4952300 Create workspace on user create 2024-10-19 12:07:45 +02:00
a24f0d637c Fix chi error 2024-10-15 22:33:59 +02:00
68ec134bf5 Fix migration error 2024-10-15 22:33:50 +02:00
403ded509a Fix main 2024-10-15 22:23:09 +02:00
6cf141bfd9 Rework api 2024-10-15 22:17:34 +02:00
071619cfb3 Rework db schema 2024-10-15 21:05:28 +02:00
d440ac0fd7 Update handlers to pass user id 2024-10-14 22:43:00 +02:00
4953138154 Fix filesystem paths 2024-10-14 22:36:37 +02:00
cbdf51db05 Fix filesystem 2024-10-14 22:06:38 +02:00
18bc50f5b4 Update handlers for workspaces 2024-10-14 22:01:17 +02:00
97ebf1c08e Update fs for workspaces 2024-10-14 21:26:31 +02:00
2d2b596f2c Add db support for workspaces 2024-10-14 21:08:37 +02:00
b36c5b30c6 Merge pull request #5 from LordMathis/fix/autocommit-on-delete
Fix/autocommit on delete
2024-10-13 17:16:08 +02:00
903393953a Dont await autocommit 2024-10-13 16:58:54 +02:00
b6e8a93d86 Fix file list reload after delete 2024-10-13 16:13:47 +02:00
da5a3ebeee Remove autocommit from backend 2024-10-13 16:00:17 +02:00
1341881968 Enable autocommit for delete and create 2024-10-13 15:02:31 +02:00
169 changed files with 28303 additions and 12195 deletions

View File

@@ -1,4 +1,4 @@
name: Build, Push, and Release
name: Docker Build
on:
push:
@@ -8,7 +8,7 @@ on:
env:
REGISTRY: ghcr.io
OWNER: lordmathis
REPO: novamd
REPO: lemma
jobs:
build-and-push-image:

34
.github/workflows/go-test.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Go Tests
on:
push:
branches:
- "*"
pull_request:
branches:
- main
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./server
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
cache: true
- name: Run Tests
run: go test -tags=test,integration ./... -v
- name: Run Tests with Race Detector
run: go test -tags=test,integration -race ./... -v

6
.gitignore vendored
View File

@@ -129,6 +129,9 @@ dist
.yarn/install-state.gz
.pnp.*
# Vite
.vite
##### Go #####
@@ -154,7 +157,8 @@ go.work.sum
# env file
.env
.env.dev
main
*.db
data
data

14
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch Lemma Server",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/server/cmd/server/main.go",
"cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/server/.env"
}
]
}

View File

@@ -14,14 +14,17 @@
"go.lintTool": "golangci-lint",
"go.lintOnSave": "package",
"go.formatTool": "goimports",
"go.testFlags": ["-tags=test,integration"],
"[go]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
},
"editor.defaultFormatter": "golang.go"
},
"gopls": {
"usePlaceholders": true,
"staticcheck": true
"staticcheck": true,
"buildFlags": ["-tags", "test,integration"]
}
}

View File

@@ -1,33 +1,35 @@
# Stage 1: Build the frontend
FROM node:20 AS frontend-builder
WORKDIR /app
COPY frontend/package*.json ./
COPY app/package*.json ./
RUN npm ci
COPY frontend .
COPY app .
RUN npm run build
# Stage 2: Build the backend
FROM golang:1.23 AS backend-builder
WORKDIR /app
RUN apt-get update && apt-get install -y gcc musl-dev
COPY backend/go.mod backend/go.sum ./
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY backend .
RUN CGO_ENABLED=1 GOOS=linux go build -o novamd ./cmd/server
COPY server .
RUN CGO_ENABLED=1 GOOS=linux go build -o lemma ./cmd/server
# Stage 3: Final stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates
RUN update-ca-certificates
WORKDIR /app
COPY --from=backend-builder /app/novamd .
COPY --from=backend-builder /app/lemma .
COPY --from=frontend-builder /app/dist ./dist
RUN mkdir -p /app/data
# Set default environment variables
ENV NOVAMD_STATIC_PATH=/app/dist
ENV NOVAMD_PORT=8080
ENV NOVAMD_WORKDIR=/app/data
ENV LEMMA_STATIC_PATH=/app/dist
ENV LEMMA_PORT=8080
ENV LEMMA_WORKDIR=/app/data
EXPOSE 8080
CMD ["./novamd"]
CMD ["./lemma"]

View File

@@ -1,4 +1,6 @@
# NovaMD
# 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)
Yet another markdown editor. Work in progress
@@ -8,7 +10,8 @@ Yet another markdown editor. Work in progress
- File tree navigation
- Git integration for version control
- Dark and light theme support
- Math equation support (KaTeX)
- Multiple workspaces
- Math equation support (MathJax)
- Code syntax highlighting
## Prerequisites
@@ -17,23 +20,55 @@ Yet another markdown editor. Work in progress
- Node.js 20 or later
- gcc (for go-sqlite3 package)
## Running the Backend
## Configuration
1. Navigate to the `backend` directory
2. Set the following environment variables:
- `CGO_ENABLED=1`: This is necessary for the go-sqlite3 package
- `NOVAMD_DB_PATH`: Path to the SQLite database file (default: "./sqlite.db")
- `NOVAMD_WORKDIR`: Directory for storing Markdown files (default: "./data")
- `NOVAMD_STATIC_PATH`: Path to the frontend build files (default: "../frontend/dist")
- `NOVAMD_PORT`: Port to run the server on (default: "8080")
3. Run the server:
Lemma can be configured using environment variables. Here are the available configuration options:
### Required Environment Variables
- `LEMMA_ADMIN_EMAIL`: Email address for the admin account
- `LEMMA_ADMIN_PASSWORD`: Password for the admin account
- `LEMMA_ENCRYPTION_KEY`: Base64-encoded 32-byte key used for encrypting sensitive data
### 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_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
- `LEMMA_CORS_ORIGINS`: Comma-separated list of allowed CORS origins
- `LEMMA_JWT_SIGNING_KEY`: Key used for signing JWT tokens
- `LEMMA_LOG_LEVEL`: Logging level (defaults to DEBUG in development mode, INFO in production)
- `LEMMA_RATE_LIMIT_REQUESTS`: Number of allowed requests per window (default: 100)
- `LEMMA_RATE_LIMIT_WINDOW`: Duration of the rate limit window (default: 15m)
### Generating Encryption Keys
The encryption key must be a base64-encoded 32-byte value. You can generate a secure encryption key using OpenSSL:
```bash
# Generate a random 32-byte key and encode it as base64
openssl rand -base64 32
```
Store the generated key securely - it will be needed to decrypt any data encrypted by the application. If the key is lost or changed, previously encrypted data will become inaccessible.
## Running the backend server
1. Navigate to the `server` directory
2. Install dependecies: `go mod tidy`
3. Ensure all environment variables are set
4. Additionally set `CGO_ENABLED=1` (needed for sqlite3)
5. Run the server:
```
go run cmd/server/main.go
```
## Running the Frontend
## Running the frontend app
1. Navigate to the `frontend` directory
1. Navigate to the `app` directory
2. Install dependencies:
```
npm install
@@ -44,20 +79,20 @@ Yet another markdown editor. Work in progress
```
The frontend will be available at `http://localhost:3000`
## Building for Production
## Building for production
1. Build the frontend:
1. Build the frontend app:
```
cd frontend
cd app
npm run build
```
2. Build the backend:
```
cd backend
go build -o novamd ./cmd/server
cd server
go build -o lemma ./cmd/server
```
3. Set the `NOVAMD_STATIC_PATH` environment variable to point to the frontend build directory
4. Run the `novamd` executable
3. Set the `LEMMA_STATIC_PATH` environment variable to point to the frontend build directory
4. Run the `lemma` executable
## Docker Support
@@ -65,9 +100,13 @@ A Dockerfile is provided for easy deployment. To build and run the Docker image:
1. Build the image:
```
docker build -t novamd .
docker build -t lemma .
```
2. Run the container:
```
docker run -p 8080:8080 -v /path/to/data:/app/data novamd
docker run -p 8080:8080 -v /path/to/data:/app/data lemma
```
## Upgrading
Before first stable release (1.0.0) there is not upgrade path. You have to delete the database file and start over.

6060
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,16 @@
{
"name": "novamd-frontend",
"name": "lemma-frontend",
"version": "0.1.0",
"description": "Yet another markdown editor",
"main": "index.js",
"type": "module",
"scripts": {
"start": "webpack serve --mode development --open",
"build": "webpack --mode production"
"start": "vite",
"build": "vite build",
"preview": "vite preview"
},
"repository": {
"type": "git",
"url": "git+https://github.com/LordMathis/NovaMD.git"
"url": "git+https://github.com/LordMathis/Lemma.git"
},
"keywords": [
"markdown",
@@ -18,9 +19,9 @@
"author": "Matúš Námešný",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/LordMathis/NovaMD/issues"
"url": "https://github.com/LordMathis/Lemma/issues"
},
"homepage": "https://github.com/LordMathis/NovaMD#readme",
"homepage": "https://github.com/LordMathis/Lemma#readme",
"dependencies": {
"@codemirror/commands": "^6.6.2",
"@codemirror/lang-markdown": "^6.2.5",
@@ -34,20 +35,30 @@
"@react-hook/resize-observer": "^2.0.2",
"@tabler/icons-react": "^3.19.0",
"codemirror": "^6.0.1",
"katex": "^0.16.11",
"react": "^18.3.1",
"react-arborist": "^3.4.0",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0"
"rehype-mathjax": "^6.0.0",
"rehype-prism": "^2.3.3",
"rehype-react": "^8.0.0",
"remark": "^15.0.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
"devDependencies": {
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",
"postcss": "^8.4.47",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"sass": "^1.80.4",
"vite": "^5.4.10",
"vite-plugin-compression2": "^1.3.0"
},
"browserslist": {
"production": [
@@ -60,26 +71,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@babel/core": "^7.25.7",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-transform-class-properties": "^7.25.7",
"@babel/plugin-transform-runtime": "^7.25.7",
"@babel/preset-env": "^7.25.7",
"@babel/preset-react": "^7.25.7",
"babel-loader": "^9.2.1",
"css-loader": "^7.1.2",
"html-webpack-plugin": "^5.6.0",
"postcss": "^8.4.47",
"postcss-loader": "^8.1.1",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"sass": "^1.79.4",
"sass-loader": "^16.0.2",
"style-loader": "^4.0.0",
"webpack": "^5.94.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.1.0"
}
}

View File

@@ -2,21 +2,37 @@ import React from 'react';
import { MantineProvider, ColorSchemeScript } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals';
import Layout from './components/Layout';
import { SettingsProvider, useSettings } from './contexts/SettingsContext';
import Layout from './components/layout/Layout';
import LoginPage from './components/auth/LoginPage';
import { WorkspaceProvider } from './contexts/WorkspaceContext';
import { ModalProvider } from './contexts/ModalContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import './App.scss';
function AppContent() {
const { loading } = useSettings();
function AuthenticatedContent() {
const { user, loading, initialized } = useAuth();
if (!initialized) {
return null;
}
if (loading) {
return <div>Loading...</div>;
}
return <Layout />;
if (!user) {
return <LoginPage />;
}
return (
<WorkspaceProvider>
<ModalProvider>
<Layout />
</ModalProvider>
</WorkspaceProvider>
);
}
function App() {
@@ -26,11 +42,9 @@ function App() {
<MantineProvider defaultColorScheme="light">
<Notifications />
<ModalsProvider>
<SettingsProvider>
<ModalProvider>
<AppContent />
</ModalProvider>
</SettingsProvider>
<AuthProvider>
<AuthenticatedContent />
</AuthProvider>
</ModalsProvider>
</MantineProvider>
</>

View File

@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import {
TextInput,
PasswordInput,
Paper,
Title,
Container,
Button,
Text,
Stack,
} from '@mantine/core';
import { useAuth } from '../../contexts/AuthContext';
const LoginPage = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
} finally {
setLoading(false);
}
};
return (
<Container size={420} my={40}>
<Title ta="center">Welcome to Lemma</Title>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Please sign in to continue
</Text>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
label="Email"
placeholder="your@email.com"
required
value={email}
onChange={(event) => setEmail(event.currentTarget.value)}
/>
<PasswordInput
label="Password"
placeholder="Your password"
required
value={password}
onChange={(event) => setPassword(event.currentTarget.value)}
/>
<Button type="submit" loading={loading}>
Sign in
</Button>
</Stack>
</form>
</Paper>
</Container>
);
};
export default LoginPage;

View File

@@ -2,8 +2,8 @@ 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 } from '../../services/api';
import { isImageFile } from '../../utils/fileHelpers';
const ContentView = ({
activeTab,
@@ -11,7 +11,7 @@ const ContentView = ({
content,
handleContentChange,
handleSave,
handleLinkClick,
handleFileSelect,
}) => {
if (!selectedFile) {
return (
@@ -47,7 +47,7 @@ const ContentView = ({
selectedFile={selectedFile}
/>
) : (
<MarkdownPreview content={content} handleLinkClick={handleLinkClick} />
<MarkdownPreview content={content} handleFileSelect={handleFileSelect} />
);
};

View File

@@ -5,10 +5,10 @@ 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 { useSettings } from '../contexts/SettingsContext';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
const { settings } = useSettings();
const { colorScheme } = useWorkspace();
const editorRef = useRef();
const viewRef = useRef();
@@ -27,12 +27,12 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
overflow: 'auto',
},
'.cm-gutters': {
backgroundColor: settings.theme === 'dark' ? '#1e1e1e' : '#f5f5f5',
color: settings.theme === 'dark' ? '#858585' : '#999',
backgroundColor: colorScheme === 'dark' ? '#1e1e1e' : '#f5f5f5',
color: colorScheme === 'dark' ? '#858585' : '#999',
border: 'none',
},
'.cm-activeLineGutter': {
backgroundColor: settings.theme === 'dark' ? '#2c313a' : '#e8e8e8',
backgroundColor: colorScheme === 'dark' ? '#2c313a' : '#e8e8e8',
},
});
@@ -56,7 +56,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
}
}),
theme,
settings.theme === 'dark' ? oneDark : [],
colorScheme === 'dark' ? oneDark : [],
],
});
@@ -70,7 +70,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
return () => {
view.destroy();
};
}, [settings.theme, handleContentChange]);
}, [colorScheme, handleContentChange]);
useEffect(() => {
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {

View File

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

@@ -6,11 +6,11 @@ import {
IconGitPullRequest,
IconGitCommit,
} from '@tabler/icons-react';
import { useSettings } from '../contexts/SettingsContext';
import { useModalContext } from '../contexts/ModalContext';
import { useModalContext } from '../../contexts/ModalContext';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const FileActions = ({ handlePullChanges, selectedFile }) => {
const { settings } = useSettings();
const { settings } = useWorkspace();
const {
setNewFileModalVisible,
setDeleteFileModalVisible,

View File

@@ -65,10 +65,17 @@ const Node = ({ node, style, dragHandle }) => {
);
};
const FileTree = ({ files, handleFileSelect }) => {
const FileTree = ({ files, handleFileSelect, showHiddenFiles }) => {
const target = useRef(null);
const size = useSize(target);
files = files.filter((file) => {
if (file.name.startsWith('.') && !showHiddenFiles) {
return false;
}
return true;
});
return (
<div
ref={target}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Group, Text } from '@mantine/core';
import UserMenu from '../navigation/UserMenu';
import WorkspaceSwitcher from '../navigation/WorkspaceSwitcher';
import WorkspaceSettings from '../settings/workspace/WorkspaceSettings';
const Header = () => {
return (
<Group justify="space-between" h={60} px="md">
<Text fw={700} size="lg">
Lemma
</Text>
<Group>
<WorkspaceSwitcher />
<UserMenu />
</Group>
<WorkspaceSettings />
</Group>
);
};
export default Header;

View File

@@ -1,13 +1,28 @@
import React from 'react';
import { AppShell, Container } from '@mantine/core';
import { AppShell, Container, Loader, Center } from '@mantine/core';
import Header from './Header';
import Sidebar from './Sidebar';
import MainContent from './MainContent';
import { useFileNavigation } from '../hooks/useFileNavigation';
import { useFileNavigation } from '../../hooks/useFileNavigation';
import { useFileList } from '../../hooks/useFileList';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const Layout = () => {
const { selectedFile, handleFileSelect, handleLinkClick } =
useFileNavigation();
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
const { selectedFile, handleFileSelect } = useFileNavigation();
const { files, loadFileList } = useFileList();
if (workspaceLoading) {
return (
<Center style={{ height: '100vh' }}>
<Loader size="xl" />
</Center>
);
}
if (!currentWorkspace) {
return <div>No workspace found. Please create a workspace.</div>;
}
return (
<AppShell header={{ height: 60 }} padding="md">
@@ -27,11 +42,13 @@ const Layout = () => {
<Sidebar
selectedFile={selectedFile}
handleFileSelect={handleFileSelect}
files={files}
loadFileList={loadFileList}
/>
<MainContent
selectedFile={selectedFile}
handleFileSelect={handleFileSelect}
handleLinkClick={handleLinkClick}
loadFileList={loadFileList}
/>
</Container>
</AppShell.Main>

View File

@@ -2,19 +2,19 @@ import React, { useState, useCallback, useMemo } from 'react';
import { Tabs, Breadcrumbs, Group, Box, Text, Flex } from '@mantine/core';
import { IconCode, IconEye, IconPointFilled } from '@tabler/icons-react';
import ContentView from './ContentView';
import CreateFileModal from './modals/CreateFileModal';
import DeleteFileModal from './modals/DeleteFileModal';
import CommitMessageModal from './modals/CommitMessageModal';
import ContentView from '../editor/ContentView';
import CreateFileModal from '../modals/file/CreateFileModal';
import DeleteFileModal from '../modals/file/DeleteFileModal';
import CommitMessageModal from '../modals/git/CommitMessageModal';
import { useFileContent } from '../hooks/useFileContent';
import { useFileOperations } from '../hooks/useFileOperations';
import { useGitOperations } from '../hooks/useGitOperations';
import { useSettings } from '../contexts/SettingsContext';
import { useFileContent } from '../../hooks/useFileContent';
import { useFileOperations } from '../../hooks/useFileOperations';
import { useGitOperations } from '../../hooks/useGitOperations';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const MainContent = ({ selectedFile, handleFileSelect, handleLinkClick }) => {
const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
const [activeTab, setActiveTab] = useState('source');
const { settings } = useSettings();
const { settings } = useWorkspace();
const {
content,
hasUnsavedChanges,
@@ -33,38 +33,32 @@ const MainContent = ({ selectedFile, handleFileSelect, handleLinkClick }) => {
let success = await handleSave(filePath, content);
if (success) {
setHasUnsavedChanges(false);
if (settings.gitAutoCommit && settings.gitEnabled) {
const commitMessage = settings.gitCommitMsgTemplate.replace(
'${filename}',
filePath
);
success = await handleCommitAndPush(commitMessage);
}
}
return success;
},
[handleSave, setHasUnsavedChanges, settings, handleCommitAndPush]
[handleSave, setHasUnsavedChanges]
);
const handleCreateFile = useCallback(
async (fileName) => {
const success = await handleCreate(fileName);
if (success) {
loadFileList();
handleFileSelect(fileName);
}
},
[handleCreate, handleFileSelect]
[handleCreate, handleFileSelect, loadFileList]
);
const handleDeleteFile = useCallback(
async (filePath) => {
const success = await handleDelete(filePath);
if (success) {
loadFileList();
handleFileSelect(null);
}
},
[handleDelete, handleFileSelect]
[handleDelete, handleFileSelect, loadFileList]
);
const renderBreadcrumbs = useMemo(() => {
@@ -114,7 +108,7 @@ const MainContent = ({ selectedFile, handleFileSelect, handleLinkClick }) => {
content={content}
handleContentChange={handleContentChange}
handleSave={handleSaveFile}
handleLinkClick={handleLinkClick}
handleFileSelect={handleFileSelect}
/>
</Box>
<CreateFileModal onCreateFile={handleCreateFile} />

View File

@@ -1,19 +1,17 @@
import React, { useEffect } from 'react';
import { Box } from '@mantine/core';
import FileActions from './FileActions';
import FileTree from './FileTree';
import { useFileList } from '../hooks/useFileList';
import { useGitOperations } from '../hooks/useGitOperations';
import { useSettings } from '../contexts/SettingsContext';
import FileActions from '../files/FileActions';
import FileTree from '../files/FileTree';
import { useGitOperations } from '../../hooks/useGitOperations';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const Sidebar = ({ selectedFile, handleFileSelect }) => {
const { settings } = useSettings();
const { files, loadFileList } = useFileList();
const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
const { settings } = useWorkspace();
const { handlePull } = useGitOperations(settings.gitEnabled);
useEffect(() => {
loadFileList();
}, [settings.gitEnabled, loadFileList]);
}, [loadFileList]);
return (
<Box
@@ -30,7 +28,7 @@ const Sidebar = ({ selectedFile, handleFileSelect }) => {
<FileTree
files={files}
handleFileSelect={handleFileSelect}
selectedFile={selectedFile}
showHiddenFiles={settings.showHiddenFiles}
/>
</Box>
);

View File

@@ -0,0 +1,55 @@
import React, { useState } from 'react';
import {
Modal,
Stack,
Text,
PasswordInput,
Group,
Button,
} from '@mantine/core';
const DeleteAccountModal = ({ opened, onClose, onConfirm }) => {
const [password, setPassword] = useState('');
return (
<Modal
opened={opened}
onClose={onClose}
title="Delete Account"
centered
size="sm"
>
<Stack>
<Text c="red" fw={500}>
Warning: This action cannot be undone
</Text>
<Text size="sm">
Please enter your password to confirm account deletion.
</Text>
<PasswordInput
label="Current Password"
placeholder="Enter your current password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
color="red"
onClick={() => {
onConfirm(password);
setPassword('');
}}
>
Delete Account
</Button>
</Group>
</Stack>
</Modal>
);
};
export default DeleteAccountModal;

View File

@@ -0,0 +1,51 @@
import React, { useState } from 'react';
import {
Modal,
Text,
Button,
Group,
Stack,
PasswordInput,
} from '@mantine/core';
const EmailPasswordModal = ({ opened, onClose, onConfirm, email }) => {
const [password, setPassword] = useState('');
return (
<Modal
opened={opened}
onClose={onClose}
title="Confirm Password"
centered
size="sm"
>
<Stack>
<Text size="sm">
Please enter your password to confirm changing your email to: {email}
</Text>
<PasswordInput
label="Current Password"
placeholder="Enter your current password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => {
onConfirm(password);
setPassword('');
}}
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
);
};
export default EmailPasswordModal;

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../contexts/ModalContext';
import { useModalContext } from '../../../contexts/ModalContext';
const CreateFileModal = ({ onCreateFile }) => {
const [fileName, setFileName] = useState('');

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Modal, Text, Button, Group } from '@mantine/core';
import { useModalContext } from '../../contexts/ModalContext';
import { useModalContext } from '../../../contexts/ModalContext';
const DeleteFileModal = ({ onDeleteFile, selectedFile }) => {
const { deleteFileModalVisible, setDeleteFileModalVisible } =

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../contexts/ModalContext';
import { useModalContext } from '../../../contexts/ModalContext';
const CommitMessageModal = ({ onCommitAndPush }) => {
const [message, setMessage] = useState('');

View File

@@ -0,0 +1,76 @@
import React, { useState } from 'react';
import {
Modal,
Stack,
TextInput,
PasswordInput,
Select,
Button,
Group,
} from '@mantine/core';
const CreateUserModal = ({ opened, onClose, onCreateUser, loading }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [role, setRole] = useState('viewer');
const handleSubmit = async () => {
const result = await onCreateUser({ email, password, displayName, role });
if (result.success) {
setEmail('');
setPassword('');
setDisplayName('');
setRole('viewer');
onClose();
}
};
return (
<Modal opened={opened} onClose={onClose} title="Create New User" centered>
<Stack>
<TextInput
label="Email"
required
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
placeholder="user@example.com"
/>
<TextInput
label="Display Name"
value={displayName}
onChange={(e) => setDisplayName(e.currentTarget.value)}
placeholder="John Doe"
/>
<PasswordInput
label="Password"
required
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
placeholder="Enter password"
/>
<Select
label="Role"
required
value={role}
onChange={setRole}
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'editor', label: 'Editor' },
{ value: 'viewer', label: 'Viewer' },
]}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={loading}>
Create User
</Button>
</Group>
</Stack>
</Modal>
);
};
export default CreateUserModal;

View File

@@ -0,0 +1,29 @@
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,105 @@
import React, { useState, useEffect } from 'react';
import {
Modal,
Stack,
TextInput,
Select,
Button,
Group,
PasswordInput,
Text,
} from '@mantine/core';
const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
const [formData, setFormData] = useState({
email: '',
displayName: '',
role: '',
password: '',
});
useEffect(() => {
if (user) {
setFormData({
email: user.email,
displayName: user.displayName || '',
role: user.role,
password: '',
});
}
}, [user]);
const handleSubmit = async () => {
const updateData = {
...formData,
...(formData.password ? { password: formData.password } : {}),
};
const result = await onEditUser(user.id, updateData);
if (result.success) {
setFormData({
email: '',
displayName: '',
role: '',
password: '',
});
onClose();
}
};
return (
<Modal opened={opened} onClose={onClose} title="Edit User" centered>
<Stack>
<TextInput
label="Email"
required
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.currentTarget.value })
}
placeholder="user@example.com"
/>
<TextInput
label="Display Name"
value={formData.displayName}
onChange={(e) =>
setFormData({ ...formData, displayName: e.currentTarget.value })
}
placeholder="John Doe"
/>
<Select
label="Role"
required
value={formData.role}
onChange={(value) => setFormData({ ...formData, role: value })}
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'editor', label: 'Editor' },
{ value: 'viewer', label: 'Viewer' },
]}
/>
<PasswordInput
label="New Password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.currentTarget.value })
}
placeholder="Enter new password (leave empty to keep current)"
/>
<Text size="xs" c="dimmed">
Leave password empty to keep the current password
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={loading}>
Save Changes
</Button>
</Group>
</Stack>
</Modal>
);
};
export default EditUserModal;

View File

@@ -0,0 +1,82 @@
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';
const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const { createWorkspaceModalVisible, setCreateWorkspaceModalVisible } =
useModalContext();
const handleSubmit = async () => {
if (!name.trim()) {
notifications.show({
title: 'Error',
message: 'Workspace name is required',
color: 'red',
});
return;
}
setLoading(true);
try {
const workspace = await createWorkspace(name);
notifications.show({
title: 'Success',
message: 'Workspace created successfully',
color: 'green',
});
setName('');
setCreateWorkspaceModalVisible(false);
if (onWorkspaceCreated) {
onWorkspaceCreated(workspace);
}
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to create workspace',
color: 'red',
});
} finally {
setLoading(false);
}
};
return (
<Modal
opened={createWorkspaceModalVisible}
onClose={() => setCreateWorkspaceModalVisible(false)}
title="Create New Workspace"
centered
size="sm"
>
<Box maw={400} mx="auto">
<TextInput
label="Workspace Name"
placeholder="Enter workspace name"
value={name}
onChange={(event) => setName(event.currentTarget.value)}
mb="md"
w="100%"
disabled={loading}
/>
<Group justify="flex-end" mt="md">
<Button
variant="default"
onClick={() => setCreateWorkspaceModalVisible(false)}
disabled={loading}
>
Cancel
</Button>
<Button onClick={handleSubmit} loading={loading}>
Create
</Button>
</Group>
</Box>
</Modal>
);
};
export default CreateWorkspaceModal;

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
const DeleteWorkspaceModal = ({
opened,
onClose,
onConfirm,
workspaceName,
}) => (
<Modal
opened={opened}
onClose={onClose}
title="Delete Workspace"
centered
size="sm"
>
<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.
</Text>
<Group justify="flex-end" mt="xl">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button color="red" onClick={onConfirm}>
Delete Workspace
</Button>
</Group>
</Stack>
</Modal>
);
export default DeleteWorkspaceModal;

View File

@@ -0,0 +1,155 @@
import React, { useState } from 'react';
import {
Avatar,
Popover,
Stack,
UnstyledButton,
Group,
Text,
Divider,
} from '@mantine/core';
import {
IconUser,
IconUsers,
IconLogout,
IconSettings,
} from '@tabler/icons-react';
import { useAuth } from '../../contexts/AuthContext';
import AccountSettings from '../settings/account/AccountSettings';
import AdminDashboard from '../settings/admin/AdminDashboard';
const UserMenu = () => {
const [accountSettingsOpened, setAccountSettingsOpened] = useState(false);
const [adminDashboardOpened, setAdminDashboardOpened] = useState(false);
const [opened, setOpened] = useState(false);
const { user, logout } = useAuth();
const handleLogout = () => {
logout();
};
return (
<>
<Popover
width={200}
position="bottom-end"
withArrow
shadow="md"
opened={opened}
onChange={setOpened}
>
<Popover.Target>
<Avatar
radius="xl"
style={{ cursor: 'pointer' }}
onClick={() => setOpened((o) => !o)}
>
<IconUser size={24} />
</Avatar>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
{/* User Info Section */}
<Group gap="sm">
<Avatar radius="xl" size="md">
<IconUser size={24} />
</Avatar>
<div>
<Text size="sm" fw={500}>
{user.displayName || user.email}
</Text>
</div>
</Group>
<Divider />
{/* Menu Items */}
<UnstyledButton
onClick={() => {
setAccountSettingsOpened(true);
setOpened(false);
}}
px="sm"
py="xs"
style={(theme) => ({
borderRadius: theme.radius.sm,
'&:hover': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0],
},
})}
>
<Group>
<IconSettings size={16} />
<Text size="sm">Account Settings</Text>
</Group>
</UnstyledButton>
{user.role === 'admin' && (
<UnstyledButton
onClick={() => {
setAdminDashboardOpened(true);
setOpened(false);
}}
px="sm"
py="xs"
style={(theme) => ({
borderRadius: theme.radius.sm,
'&:hover': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0],
},
})}
>
<Group>
<IconUsers size={16} />
<Text size="sm">Admin Dashboard</Text>
</Group>
</UnstyledButton>
)}
<UnstyledButton
onClick={handleLogout}
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],
},
})}
>
<Group>
<IconLogout size={16} color="red" />
<Text size="sm" c="red">
Logout
</Text>
</Group>
</UnstyledButton>
</Stack>
</Popover.Dropdown>
</Popover>
<AccountSettings
opened={accountSettingsOpened}
onClose={() => setAccountSettingsOpened(false)}
/>
<AdminDashboard
opened={adminDashboardOpened}
onClose={() => setAdminDashboardOpened(false)}
/>
</>
);
};
export default UserMenu;

View File

@@ -0,0 +1,197 @@
import React, { useState } from 'react';
import {
Box,
Popover,
Stack,
Paper,
ScrollArea,
Group,
UnstyledButton,
Text,
Loader,
Center,
ActionIcon,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react';
import { useWorkspace } from '../../contexts/WorkspaceContext';
import { useModalContext } from '../../contexts/ModalContext';
import { listWorkspaces } from '../../services/api';
import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal';
const WorkspaceSwitcher = () => {
const { currentWorkspace, switchWorkspace } = useWorkspace();
const { setSettingsModalVisible, setCreateWorkspaceModalVisible } =
useModalContext();
const [workspaces, setWorkspaces] = useState([]);
const [loading, setLoading] = useState(false);
const [popoverOpened, setPopoverOpened] = useState(false);
const theme = useMantineTheme();
const loadWorkspaces = async () => {
setLoading(true);
try {
const list = await listWorkspaces();
setWorkspaces(list);
} catch (error) {
console.error('Failed to load workspaces:', error);
}
setLoading(false);
};
const handleCreateWorkspace = () => {
setPopoverOpened(false);
setCreateWorkspaceModalVisible(true);
};
const handleWorkspaceCreated = async (newWorkspace) => {
await loadWorkspaces();
switchWorkspace(newWorkspace.name);
};
return (
<>
<Popover
width={300}
position="bottom-start"
shadow="md"
opened={popoverOpened}
onChange={setPopoverOpened}
>
<Popover.Target>
<UnstyledButton
onClick={() => {
setPopoverOpened((o) => !o);
if (!popoverOpened) {
loadWorkspaces();
}
}}
>
<Group gap="xs">
<IconFolders size={20} />
<div>
<Text size="sm" fw={500}>
{currentWorkspace?.name || 'No workspace'}
</Text>
</div>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown p="xs">
<Group justify="space-between" mb="md" px="xs">
<Text size="sm" fw={600}>
Workspaces
</Text>
<Tooltip label="Create New Workspace">
<ActionIcon
variant="default"
size="md"
onClick={handleCreateWorkspace}
>
<IconFolderPlus size={16} />
</ActionIcon>
</Tooltip>
</Group>
<ScrollArea.Autosize mah={400} offsetScrollbars>
<Stack gap="xs">
{loading ? (
<Center p="md">
<Loader size="sm" />
</Center>
) : (
workspaces.map((workspace) => {
const isSelected = workspace.name === currentWorkspace?.name;
return (
<Paper
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,
}}
>
<Group justify="space-between" wrap="nowrap">
<UnstyledButton
style={{ flex: 1 }}
onClick={() => {
switchWorkspace(workspace.name);
setPopoverOpened(false);
}}
>
<Box>
<Text
size="sm"
fw={500}
truncate
c={
isSelected
? theme.colors.blue[
theme.colorScheme === 'dark' ? 0 : 9
]
: undefined
}
>
{workspace.name}
</Text>
<Text
size="xs"
c={
isSelected
? theme.colorScheme === 'dark'
? theme.colors.blue[2]
: theme.colors.blue[7]
: 'dimmed'
}
>
{new Date(
workspace.createdAt
).toLocaleDateString()}
</Text>
</Box>
</UnstyledButton>
{isSelected && (
<Tooltip label="Workspace Settings">
<ActionIcon
variant="subtle"
size="lg"
color={
theme.colorScheme === 'dark'
? 'blue.2'
: 'blue.7'
}
onClick={(e) => {
e.stopPropagation();
setSettingsModalVisible(true);
setPopoverOpened(false);
}}
>
<IconSettings size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Paper>
);
})
)}
</Stack>
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>
<CreateWorkspaceModal onWorkspaceCreated={handleWorkspaceCreated} />
</>
);
};
export default WorkspaceSwitcher;

View File

@@ -0,0 +1,10 @@
import React from 'react';
import { Accordion, Title } from '@mantine/core';
const AccordionControl = ({ children }) => (
<Accordion.Control>
<Title order={4}>{children}</Title>
</Accordion.Control>
);
export default AccordionControl;

View File

@@ -0,0 +1,248 @@
import React, { useState, useReducer, useRef, useEffect } from 'react';
import {
Modal,
Badge,
Button,
Group,
Title,
Stack,
Accordion,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useAuth } from '../../../contexts/AuthContext';
import { useProfileSettings } from '../../../hooks/useProfileSettings';
import EmailPasswordModal from '../../modals/account/EmailPasswordModal';
import SecuritySettings from './SecuritySettings';
import ProfileSettings from './ProfileSettings';
import DangerZoneSettings from './DangerZoneSettings';
import AccordionControl from '../AccordionControl';
// Reducer for managing settings state
const initialState = {
localSettings: {},
initialSettings: {},
hasUnsavedChanges: false,
};
function settingsReducer(state, action) {
switch (action.type) {
case 'INIT_SETTINGS':
return {
...state,
localSettings: action.payload,
initialSettings: action.payload,
hasUnsavedChanges: false,
};
case 'UPDATE_LOCAL_SETTINGS':
const newLocalSettings = { ...state.localSettings, ...action.payload };
const hasChanges =
JSON.stringify(newLocalSettings) !==
JSON.stringify(state.initialSettings);
return {
...state,
localSettings: newLocalSettings,
hasUnsavedChanges: hasChanges,
};
case 'MARK_SAVED':
return {
...state,
initialSettings: state.localSettings,
hasUnsavedChanges: false,
};
default:
return state;
}
}
const AccountSettings = ({ 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);
// Initialize settings on mount
useEffect(() => {
if (isInitialMount.current && user) {
isInitialMount.current = false;
const settings = {
displayName: user.displayName,
email: user.email,
currentPassword: '',
newPassword: '',
};
dispatch({ type: 'INIT_SETTINGS', payload: settings });
}
}, [user]);
const handleInputChange = (key, value) => {
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
};
const handleSubmit = async () => {
const updates = {};
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;
}
// Handle password change
if (state.localSettings.newPassword) {
if (!state.localSettings.currentPassword) {
notifications.show({
title: 'Error',
message: 'Current password is required to change password',
color: 'red',
});
return;
}
updates.newPassword = state.localSettings.newPassword;
updates.currentPassword = state.localSettings.currentPassword;
}
// 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;
// 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;
}
}
const result = await updateProfile(updates);
if (result.success) {
await refreshUser();
dispatch({ type: 'MARK_SAVED' });
onClose();
}
} else {
// Only show the email confirmation modal if we don't already have the password
setEmailModalOpened(true);
}
};
const handleEmailConfirm = async (password) => {
const updates = {
...state.localSettings,
currentPassword: password,
};
// Remove any undefined/empty values
Object.keys(updates).forEach((key) => {
if (updates[key] === undefined || updates[key] === '') {
delete updates[key];
}
});
// Remove keys that haven't changed
if (updates.displayName === state.initialSettings.displayName) {
delete updates.displayName;
}
if (updates.email === state.initialSettings.email) {
delete updates.email;
}
const result = await updateProfile(updates);
if (result.success) {
await refreshUser();
dispatch({ type: 'MARK_SAVED' });
setEmailModalOpened(false);
onClose();
}
};
return (
<>
<Modal
opened={opened}
onClose={onClose}
title={<Title order={2}>Account Settings</Title>}
centered
size="lg"
>
<Stack spacing="xl">
{state.hasUnsavedChanges && (
<Badge color="yellow" variant="light">
Unsaved Changes
</Badge>
)}
<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],
},
},
})}
>
<Accordion.Item value="profile">
<AccordionControl>Profile</AccordionControl>
<Accordion.Panel>
<ProfileSettings
settings={state.localSettings}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="security">
<AccordionControl>Security</AccordionControl>
<Accordion.Panel>
<SecuritySettings
settings={state.localSettings}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="danger">
<AccordionControl>Danger Zone</AccordionControl>
<Accordion.Panel>
<DangerZoneSettings />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleSubmit}
loading={loading}
disabled={!state.hasUnsavedChanges}
>
Save Changes
</Button>
</Group>
</Stack>
</Modal>
<EmailPasswordModal
opened={emailModalOpened}
onClose={() => setEmailModalOpened(false)}
onConfirm={handleEmailConfirm}
email={state.localSettings.email}
/>
</>
);
};
export default AccountSettings;

View File

@@ -0,0 +1,43 @@
import React, { useState } from 'react';
import { Box, Button, Text } from '@mantine/core';
import DeleteAccountModal from '../../modals/account/DeleteAccountModal';
import { useAuth } from '../../../contexts/AuthContext';
import { useProfileSettings } from '../../../hooks/useProfileSettings';
const DangerZoneSettings = () => {
const { logout } = useAuth();
const { deleteAccount } = useProfileSettings();
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
const handleDelete = async (password) => {
const result = await deleteAccount(password);
if (result.success) {
setDeleteModalOpened(false);
logout();
}
};
return (
<Box mb="md">
<Text size="sm" mb="sm" c="dimmed">
Once you delete your account, there is no going back. Please be certain.
</Text>
<Button
color="red"
variant="light"
onClick={() => setDeleteModalOpened(true)}
fullWidth
>
Delete Account
</Button>
<DeleteAccountModal
opened={deleteModalOpened}
onClose={() => setDeleteModalOpened(false)}
onConfirm={handleDelete}
/>
</Box>
);
};
export default DangerZoneSettings;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Box, Stack, TextInput } from '@mantine/core';
const ProfileSettings = ({ settings, onInputChange }) => (
<Box>
<Stack spacing="md">
<TextInput
label="Display Name"
value={settings.displayName || ''}
onChange={(e) => onInputChange('displayName', e.currentTarget.value)}
placeholder="Enter display name"
/>
<TextInput
label="Email"
value={settings.email || ''}
onChange={(e) => onInputChange('email', e.currentTarget.value)}
placeholder="Enter email"
/>
</Stack>
</Box>
);
export default ProfileSettings;

View File

@@ -0,0 +1,65 @@
import React, { useState } from 'react';
import { Box, PasswordInput, Stack, Text } from '@mantine/core';
const SecuritySettings = ({ settings, onInputChange }) => {
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const handlePasswordChange = (field, value) => {
if (field === 'confirmNewPassword') {
setConfirmPassword(value);
// Check if passwords match when either password field changes
if (value !== settings.newPassword) {
setError('Passwords do not match');
} else {
setError('');
}
} else {
onInputChange(field, value);
// Check if passwords match when either password field changes
if (field === 'newPassword' && value !== confirmPassword) {
setError('Passwords do not match');
} else if (value === confirmPassword) {
setError('');
}
}
};
return (
<Box>
<Stack spacing="md">
<PasswordInput
label="Current Password"
value={settings.currentPassword || ''}
onChange={(e) =>
handlePasswordChange('currentPassword', e.currentTarget.value)
}
placeholder="Enter current password"
/>
<PasswordInput
label="New Password"
value={settings.newPassword || ''}
onChange={(e) =>
handlePasswordChange('newPassword', e.currentTarget.value)
}
placeholder="Enter new password"
/>
<PasswordInput
label="Confirm New Password"
value={confirmPassword}
onChange={(e) =>
handlePasswordChange('confirmNewPassword', e.currentTarget.value)
}
placeholder="Confirm new password"
error={error}
/>
<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.
</Text>
</Stack>
</Box>
);
};
export default SecuritySettings;

View File

@@ -0,0 +1,44 @@
import React, { useState } from 'react';
import { Modal, Tabs } from '@mantine/core';
import { IconUsers, IconFolders, IconChartBar } from '@tabler/icons-react';
import { useAuth } from '../../../contexts/AuthContext';
import AdminUsersTab from './AdminUsersTab';
import AdminWorkspacesTab from './AdminWorkspacesTab';
import AdminStatsTab from './AdminStatsTab';
const AdminDashboard = ({ opened, onClose }) => {
const { user: currentUser } = useAuth();
const [activeTab, setActiveTab] = useState('users');
return (
<Modal opened={opened} onClose={onClose} size="xl" title="Admin Dashboard">
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
Users
</Tabs.Tab>
<Tabs.Tab value="workspaces" leftSection={<IconFolders size={16} />}>
Workspaces
</Tabs.Tab>
<Tabs.Tab value="stats" leftSection={<IconChartBar size={16} />}>
Statistics
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="users" pt="md">
<AdminUsersTab currentUser={currentUser} />
</Tabs.Panel>
<Tabs.Panel value="workspaces" pt="md">
<AdminWorkspacesTab />
</Tabs.Panel>
<Tabs.Panel value="stats" pt="md">
<AdminStatsTab />
</Tabs.Panel>
</Tabs>
</Modal>
);
};
export default AdminDashboard;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { Table, Text, Box, LoadingOverlay, Alert } from '@mantine/core';
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');
if (loading) {
return <LoadingOverlay visible={true} />;
}
if (error) {
return (
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
{error}
</Alert>
);
}
const statsRows = [
{ label: 'Total Users', value: stats.totalUsers },
{ label: 'Active Users', value: stats.activeUsers },
{ label: 'Total Workspaces', value: stats.totalWorkspaces },
{ label: 'Total Files', value: stats.totalFiles },
{ label: 'Total Storage Size', value: formatBytes(stats.totalSize) },
];
return (
<Box pos="relative">
<Text size="xl" fw={700} mb="md">
System Statistics
</Text>
<Table striped highlightOnHover withBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Metric</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{statsRows.map((row) => (
<Table.Tr key={row.label}>
<Table.Td>{row.label}</Table.Td>
<Table.Td>{row.value}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
);
};
export default AdminStatsTab;

View File

@@ -0,0 +1,162 @@
import React, { useState } from 'react';
import {
Table,
Button,
Group,
Text,
ActionIcon,
Box,
LoadingOverlay,
Alert,
} from '@mantine/core';
import {
IconTrash,
IconEdit,
IconPlus,
IconAlertCircle,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useUserAdmin } from '../../../hooks/useUserAdmin';
import CreateUserModal from '../../modals/user/CreateUserModal';
import EditUserModal from '../../modals/user/EditUserModal';
import DeleteUserModal from '../../modals/user/DeleteUserModal';
const AdminUsersTab = ({ currentUser }) => {
const {
users,
loading,
error,
create,
update,
delete: deleteUser,
} = useUserAdmin();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [editModalData, setEditModalData] = useState(null);
const [deleteModalData, setDeleteModalData] = useState(null);
const handleCreateUser = async (userData) => {
return await create(userData);
};
const handleEditUser = async (id, userData) => {
return await update(id, userData);
};
const handleDeleteClick = (user) => {
if (user.id === currentUser.id) {
notifications.show({
title: 'Error',
message: 'You cannot delete your own account',
color: 'red',
});
return;
}
setDeleteModalData(user);
};
const handleDeleteConfirm = async () => {
if (!deleteModalData) return;
const result = await deleteUser(deleteModalData.id);
if (result.success) {
setDeleteModalData(null);
}
};
const rows = users.map((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>
</Table.Td>
<Table.Td>{new Date(user.createdAt).toLocaleDateString()}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
<ActionIcon
variant="subtle"
color="blue"
onClick={() => setEditModalData(user)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDeleteClick(user)}
disabled={user.id === currentUser.id}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</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}>
User Management
</Text>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => setCreateModalOpened(true)}
>
Create User
</Button>
</Group>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Email</Table.Th>
<Table.Th>Display Name</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th style={{ width: 100 }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
<CreateUserModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onCreateUser={handleCreateUser}
loading={loading}
/>
<EditUserModal
opened={!!editModalData}
onClose={() => setEditModalData(null)}
onEditUser={handleEditUser}
user={editModalData}
loading={loading}
/>
<DeleteUserModal
opened={!!deleteModalData}
onClose={() => setDeleteModalData(null)}
onConfirm={handleDeleteConfirm}
user={deleteModalData}
loading={loading}
/>
</Box>
);
};
export default AdminUsersTab;

View File

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

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

View File

@@ -0,0 +1,46 @@
import React, { useState } from 'react';
import { Box, Button, Title } from '@mantine/core';
import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal';
import { useWorkspace } from '../../../contexts/WorkspaceContext';
import { useModalContext } from '../../../contexts/ModalContext';
const DangerZoneSettings = () => {
const { currentWorkspace, workspaces, deleteCurrentWorkspace } =
useWorkspace();
const { setSettingsModalVisible } = useModalContext();
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
const handleDelete = async () => {
await deleteCurrentWorkspace();
setDeleteModalOpened(false);
setSettingsModalVisible(false);
};
return (
<Box mb="md">
<Button
color="red"
variant="light"
onClick={() => setDeleteModalOpened(true)}
fullWidth
disabled={workspaces.length <= 1}
title={
workspaces.length <= 1
? 'Cannot delete the last workspace'
: 'Delete this workspace'
}
>
Delete Workspace
</Button>
<DeleteWorkspaceModal
opened={deleteModalOpened}
onClose={() => setDeleteModalOpened(false)}
onConfirm={handleDelete}
workspaceName={currentWorkspace?.name}
/>
</Box>
);
};
export default DangerZoneSettings;

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { Text, Switch, Tooltip, Group, Box } from '@mantine/core';
const EditorSettings = ({
autoSave,
showHiddenFiles,
onAutoSaveChange,
onShowHiddenFilesChange,
}) => {
return (
<Box mb="md">
<Tooltip label="Auto Save feature is coming soon!" position="left">
<Group justify="space-between" align="center" mb="sm">
<Text size="sm">Auto Save</Text>
<Switch
checked={autoSave}
onChange={(event) => onAutoSaveChange(event.currentTarget.checked)}
disabled
/>
</Group>
</Tooltip>
<Group justify="space-between" align="center">
<Text size="sm">Show Hidden Files</Text>
<Switch
checked={showHiddenFiles}
onChange={(event) =>
onShowHiddenFilesChange(event.currentTarget.checked)
}
/>
</Group>
</Box>
);
};
export default EditorSettings;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { Title, Box, TextInput, Text, Grid } from '@mantine/core';
const GeneralSettings = ({ name, onInputChange }) => {
return (
<Box mb="md">
<Grid gutter="md" align="center">
<Grid.Col span={6}>
<Text size="sm">Workspace Name</Text>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
value={name || ''}
onChange={(event) =>
onInputChange('name', event.currentTarget.value)
}
placeholder="Enter workspace name"
required
/>
</Grid.Col>
</Grid>
</Box>
);
};
export default GeneralSettings;

View File

@@ -6,7 +6,6 @@ import {
Stack,
PasswordInput,
Group,
Title,
Grid,
} from '@mantine/core';
@@ -17,14 +16,15 @@ const GitSettings = ({
gitToken,
gitAutoCommit,
gitCommitMsgTemplate,
gitCommitName,
gitCommitEmail,
onInputChange,
}) => {
return (
<Stack spacing="md">
<Title order={3}>Git Integration</Title>
<Grid gutter="md" align="center">
<Grid.Col span={6}>
<Text size="sm">Enable Git</Text>
<Text size="sm">Enable Git Repository</Text>
</Grid.Col>
<Grid.Col span={6}>
<Group justify="flex-end">
@@ -43,6 +43,7 @@ const GitSettings = ({
<Grid.Col span={6}>
<TextInput
value={gitUrl}
description="The URL of your Git repository"
onChange={(event) =>
onInputChange('gitUrl', event.currentTarget.value)
}
@@ -52,11 +53,12 @@ const GitSettings = ({
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Git Username</Text>
<Text size="sm">Username</Text>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
value={gitUser}
description="The username used to authenticate with the repository"
onChange={(event) =>
onInputChange('gitUser', event.currentTarget.value)
}
@@ -66,11 +68,12 @@ const GitSettings = ({
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Git Token</Text>
<Text size="sm">Access Token</Text>
</Grid.Col>
<Grid.Col span={6}>
<PasswordInput
value={gitToken}
description="Personal access token with repository read/write permissions"
onChange={(event) =>
onInputChange('gitToken', event.currentTarget.value)
}
@@ -80,7 +83,7 @@ const GitSettings = ({
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Auto Commit</Text>
<Text size="sm">Commit on Save</Text>
</Grid.Col>
<Grid.Col span={6}>
<Group justify="flex-end">
@@ -100,6 +103,7 @@ const GitSettings = ({
<Grid.Col span={6}>
<TextInput
value={gitCommitMsgTemplate}
description="Template for automated commit messages. Use ${filename} and ${action} as a placeholder."
onChange={(event) =>
onInputChange('gitCommitMsgTemplate', event.currentTarget.value)
}
@@ -107,6 +111,36 @@ const GitSettings = ({
placeholder="Enter commit message template"
/>
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Commit Author</Text>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
value={gitCommitName}
description="Name to appear in commit history. Leave empty to use Git username."
onChange={(event) =>
onInputChange('gitCommitName', event.currentTarget.value)
}
disabled={!gitEnabled}
placeholder="Enter commit author name."
/>
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Commit Author Email</Text>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
value={gitCommitEmail}
description="Email address to associate with your commits"
onChange={(event) =>
onInputChange('gitCommitEmail', event.currentTarget.value)
}
disabled={!gitEnabled}
placeholder="Enter commit author email."
/>
</Grid.Col>
</Grid>
</Stack>
);

View File

@@ -0,0 +1,235 @@
import React, { useReducer, useEffect, useCallback, useRef } from 'react';
import {
Modal,
Badge,
Button,
Group,
Title,
Stack,
Accordion,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useWorkspace } from '../../../contexts/WorkspaceContext';
import AppearanceSettings from './AppearanceSettings';
import EditorSettings from './EditorSettings';
import GitSettings from './GitSettings';
import GeneralSettings from './GeneralSettings';
import { useModalContext } from '../../../contexts/ModalContext';
import DangerZoneSettings from './DangerZoneSettings';
import AccordionControl from '../AccordionControl';
const initialState = {
localSettings: {},
initialSettings: {},
hasUnsavedChanges: false,
};
function settingsReducer(state, action) {
switch (action.type) {
case 'INIT_SETTINGS':
return {
...state,
localSettings: action.payload,
initialSettings: action.payload,
hasUnsavedChanges: false,
};
case 'UPDATE_LOCAL_SETTINGS':
const newLocalSettings = { ...state.localSettings, ...action.payload };
const hasChanges =
JSON.stringify(newLocalSettings) !==
JSON.stringify(state.initialSettings);
return {
...state,
localSettings: newLocalSettings,
hasUnsavedChanges: hasChanges,
};
case 'MARK_SAVED':
return {
...state,
initialSettings: state.localSettings,
hasUnsavedChanges: false,
};
default:
return state;
}
}
const WorkspaceSettings = () => {
const { currentWorkspace, updateSettings } = useWorkspace();
const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
const [state, dispatch] = useReducer(settingsReducer, initialState);
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
const settings = {
name: currentWorkspace.name,
theme: currentWorkspace.theme,
autoSave: currentWorkspace.autoSave,
showHiddenFiles: currentWorkspace.showHiddenFiles,
gitEnabled: currentWorkspace.gitEnabled,
gitUrl: currentWorkspace.gitUrl,
gitUser: currentWorkspace.gitUser,
gitToken: currentWorkspace.gitToken,
gitAutoCommit: currentWorkspace.gitAutoCommit,
gitCommitMsgTemplate: currentWorkspace.gitCommitMsgTemplate,
gitCommitName: currentWorkspace.gitCommitName,
gitCommitEmail: currentWorkspace.gitCommitEmail,
};
dispatch({ type: 'INIT_SETTINGS', payload: settings });
}
}, [currentWorkspace]);
const handleInputChange = useCallback((key, value) => {
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
}, []);
const handleSubmit = async () => {
try {
if (!state.localSettings.name?.trim()) {
notifications.show({
message: 'Workspace name cannot be empty',
color: 'red',
});
return;
}
await updateSettings(state.localSettings);
dispatch({ type: 'MARK_SAVED' });
notifications.show({
message: 'Settings saved successfully',
color: 'green',
});
setSettingsModalVisible(false);
} catch (error) {
console.error('Failed to save settings:', error);
notifications.show({
message: 'Failed to save settings: ' + error.message,
color: 'red',
});
}
};
const handleClose = useCallback(() => {
setSettingsModalVisible(false);
}, [setSettingsModalVisible]);
return (
<Modal
opened={settingsModalVisible}
onClose={handleClose}
title={<Title order={2}>Workspace Settings</Title>}
centered
size="lg"
>
<Stack spacing="xl">
{state.hasUnsavedChanges && (
<Badge color="yellow" variant="light">
Unsaved Changes
</Badge>
)}
<Accordion
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],
},
},
chevron: {
'&[data-rotate]': {
transform: 'rotate(180deg)',
},
},
})}
>
<Accordion.Item value="general">
<AccordionControl>General</AccordionControl>
<Accordion.Panel>
<GeneralSettings
name={state.localSettings.name}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="appearance">
<AccordionControl>Appearance</AccordionControl>
<Accordion.Panel>
<AppearanceSettings
themeSettings={state.localSettings.theme}
onThemeChange={(newTheme) =>
handleInputChange('theme', newTheme)
}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="editor">
<AccordionControl>Editor</AccordionControl>
<Accordion.Panel>
<EditorSettings
autoSave={state.localSettings.autoSave}
onAutoSaveChange={(value) =>
handleInputChange('autoSave', value)
}
showHiddenFiles={state.localSettings.showHiddenFiles}
onShowHiddenFilesChange={(value) =>
handleInputChange('showHiddenFiles', value)
}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="git">
<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}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="danger">
<AccordionControl>Danger Zone</AccordionControl>
<Accordion.Panel>
<DangerZoneSettings />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="flex-end">
<Button variant="default" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSubmit}>Save Changes</Button>
</Group>
</Stack>
</Modal>
);
};
export default WorkspaceSettings;

View File

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

@@ -8,6 +8,10 @@ export const ModalProvider = ({ children }) => {
const [commitMessageModalVisible, setCommitMessageModalVisible] =
useState(false);
const [settingsModalVisible, setSettingsModalVisible] = useState(false);
const [switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible] =
useState(false);
const [createWorkspaceModalVisible, setCreateWorkspaceModalVisible] =
useState(false);
const value = {
newFileModalVisible,
@@ -18,6 +22,10 @@ export const ModalProvider = ({ children }) => {
setCommitMessageModalVisible,
settingsModalVisible,
setSettingsModalVisible,
switchWorkspaceModalVisible,
setSwitchWorkspaceModalVisible,
createWorkspaceModalVisible,
setCreateWorkspaceModalVisible,
};
return (

View File

@@ -0,0 +1,211 @@
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,48 @@
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,61 @@
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';
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);
const loadFileContent = useCallback(
async (filePath) => {
if (!currentWorkspace) return;
try {
let newContent;
if (filePath === DEFAULT_FILE.path) {
newContent = DEFAULT_FILE.content;
} else if (!isImageFile(filePath)) {
newContent = await fetchFileContent(currentWorkspace.name, filePath);
} else {
newContent = ''; // Set empty content for image files
}
setContent(newContent);
setOriginalContent(newContent);
setHasUnsavedChanges(false);
} catch (err) {
console.error('Error loading file content:', err);
setContent(''); // Set empty content on error
setOriginalContent('');
setHasUnsavedChanges(false);
}
},
[currentWorkspace]
);
useEffect(() => {
if (selectedFile && currentWorkspace) {
loadFileContent(selectedFile);
}
}, [selectedFile, currentWorkspace, loadFileContent]);
const handleContentChange = useCallback(
(newContent) => {
setContent(newContent);
setHasUnsavedChanges(newContent !== originalContent);
},
[originalContent]
);
return {
content,
setContent,
hasUnsavedChanges,
setHasUnsavedChanges,
loadFileContent,
handleContentChange,
};
};

View File

@@ -1,12 +1,16 @@
import { useState, useCallback } from 'react';
import { fetchFileList } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
export const useFileList = () => {
const [files, setFiles] = useState([]);
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
const loadFileList = useCallback(async () => {
if (!currentWorkspace || workspaceLoading) return;
try {
const fileList = await fetchFileList();
const fileList = await fetchFileList(currentWorkspace.name);
if (Array.isArray(fileList)) {
setFiles(fileList);
} else {
@@ -14,8 +18,9 @@ export const useFileList = () => {
}
} catch (error) {
console.error('Failed to load file list:', error);
setFiles([]);
}
}, []);
}, [currentWorkspace, workspaceLoading]);
return { files, loadFileList };
};

View File

@@ -0,0 +1,45 @@
import { useState, useCallback, useEffect } from 'react';
import { DEFAULT_FILE } from '../utils/constants';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useLastOpenedFile } from './useLastOpenedFile';
export const useFileNavigation = () => {
const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path);
const [isNewFile, setIsNewFile] = useState(true);
const { currentWorkspace } = useWorkspace();
const { loadLastOpenedFile, saveLastOpenedFile } = useLastOpenedFile();
const handleFileSelect = useCallback(
async (filePath) => {
const newPath = filePath || DEFAULT_FILE.path;
setSelectedFile(newPath);
setIsNewFile(!filePath);
if (filePath) {
await saveLastOpenedFile(filePath);
}
},
[saveLastOpenedFile]
);
// Load last opened file when workspace changes
useEffect(() => {
const initializeFile = async () => {
setSelectedFile(DEFAULT_FILE.path);
setIsNewFile(true);
const lastFile = await loadLastOpenedFile();
if (lastFile) {
handleFileSelect(lastFile);
} else {
handleFileSelect(null);
}
};
if (currentWorkspace) {
initializeFile();
}
}, [currentWorkspace, loadLastOpenedFile, handleFileSelect]);
return { selectedFile, isNewFile, handleFileSelect };
};

View File

@@ -0,0 +1,106 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { saveFileContent, deleteFile } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useGitOperations } from './useGitOperations';
export const useFileOperations = () => {
const { currentWorkspace, settings } = useWorkspace();
const { handleCommitAndPush } = useGitOperations();
const autoCommit = useCallback(
async (filePath, action) => {
if (settings.gitAutoCommit && settings.gitEnabled) {
let commitMessage = settings.gitCommitMsgTemplate
.replace('${filename}', filePath)
.replace('${action}', action);
commitMessage =
commitMessage.charAt(0).toUpperCase() + commitMessage.slice(1);
await handleCommitAndPush(commitMessage);
}
},
[settings]
);
const handleSave = useCallback(
async (filePath, content) => {
if (!currentWorkspace) return false;
try {
await saveFileContent(currentWorkspace.name, filePath, content);
notifications.show({
title: 'Success',
message: 'File saved successfully',
color: 'green',
});
autoCommit(filePath, 'update');
return true;
} catch (error) {
console.error('Error saving file:', error);
notifications.show({
title: 'Error',
message: 'Failed to save file',
color: 'red',
});
return false;
}
},
[currentWorkspace, autoCommit]
);
const handleDelete = useCallback(
async (filePath) => {
if (!currentWorkspace) return false;
try {
await deleteFile(currentWorkspace.name, filePath);
notifications.show({
title: 'Success',
message: 'File deleted successfully',
color: 'green',
});
autoCommit(filePath, 'delete');
return true;
} catch (error) {
console.error('Error deleting file:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete file',
color: 'red',
});
return false;
}
},
[currentWorkspace, autoCommit]
);
const handleCreate = useCallback(
async (fileName, initialContent = '') => {
if (!currentWorkspace) return false;
try {
await saveFileContent(currentWorkspace.name, fileName, initialContent);
notifications.show({
title: 'Success',
message: 'File created successfully',
color: 'green',
});
autoCommit(fileName, 'create');
return true;
} catch (error) {
console.error('Error creating new file:', error);
notifications.show({
title: 'Error',
message: 'Failed to create new file',
color: 'red',
});
return false;
}
},
[currentWorkspace, autoCommit]
);
return { handleSave, handleDelete, handleCreate };
};

View File

@@ -1,12 +1,16 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { pullChanges, commitAndPush } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
export const useGitOperations = () => {
const { currentWorkspace, settings } = useWorkspace();
export const useGitOperations = (gitEnabled) => {
const handlePull = useCallback(async () => {
if (!gitEnabled) return false;
if (!currentWorkspace || !settings.gitEnabled) return false;
try {
await pullChanges();
await pullChanges(currentWorkspace.name);
notifications.show({
title: 'Success',
message: 'Successfully pulled latest changes',
@@ -22,13 +26,14 @@ export const useGitOperations = (gitEnabled) => {
});
return false;
}
}, [gitEnabled]);
}, [currentWorkspace, settings.gitEnabled]);
const handleCommitAndPush = useCallback(
async (message) => {
if (!gitEnabled) return false;
if (!currentWorkspace || !settings.gitEnabled) return false;
try {
await commitAndPush(message);
await commitAndPush(currentWorkspace.name, message);
notifications.show({
title: 'Success',
message: 'Successfully committed and pushed changes',
@@ -45,7 +50,7 @@ export const useGitOperations = (gitEnabled) => {
return false;
}
},
[gitEnabled]
[currentWorkspace, settings.gitEnabled]
);
return { handlePull, handleCommitAndPush };

View File

@@ -0,0 +1,37 @@
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,71 @@
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,79 @@
import { useAdminData } from './useAdminData';
import { createUser, updateUser, deleteUser } from '../services/adminApi';
import { notifications } from '@mantine/notifications';
export const useUserAdmin = () => {
const { data: users, loading, error, reload } = useAdminData('users');
const handleCreate = async (userData) => {
try {
await createUser(userData);
notifications.show({
title: 'Success',
message: 'User created successfully',
color: 'green',
});
reload();
return { success: true };
} catch (err) {
const message = err.response?.data?.error || err.message;
notifications.show({
title: 'Error',
message: `Failed to create user: ${message}`,
color: 'red',
});
return { success: false, error: message };
}
};
const handleUpdate = async (userId, userData) => {
try {
await updateUser(userId, userData);
notifications.show({
title: 'Success',
message: 'User updated successfully',
color: 'green',
});
reload();
return { success: true };
} catch (err) {
const message = err.response?.data?.error || err.message;
notifications.show({
title: 'Error',
message: `Failed to update user: ${message}`,
color: 'red',
});
return { success: false, error: message };
}
};
const handleDelete = async (userId) => {
try {
await deleteUser(userId);
notifications.show({
title: 'Success',
message: 'User deleted successfully',
color: 'green',
});
reload();
return { success: true };
} catch (err) {
const message = err.response?.data?.error || err.message;
notifications.show({
title: 'Error',
message: `Failed to delete user: ${message}`,
color: 'red',
});
return { success: false, error: message };
}
};
return {
users,
loading,
error,
create: handleCreate,
update: handleUpdate,
delete: handleDelete,
};
};

13
app/src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lemma</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,49 @@
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();
};

172
app/src/services/api.js Normal file
View File

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

@@ -0,0 +1,98 @@
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();
};

View File

@@ -26,7 +26,8 @@ export const IMAGE_EXTENSIONS = [
'.svg',
];
export const DEFAULT_SETTINGS = {
// Renamed from DEFAULT_SETTINGS to be more specific
export const DEFAULT_WORKSPACE_SETTINGS = {
theme: THEMES.LIGHT,
autoSave: false,
gitEnabled: false,
@@ -34,15 +35,33 @@ export const DEFAULT_SETTINGS = {
gitUser: '',
gitToken: '',
gitAutoCommit: false,
gitCommitMsgTemplate: 'Update ${filename}',
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 NovaMD\n\nStart editing here!',
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

@@ -0,0 +1,10 @@
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,177 @@
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);
}
}
}
};
}

141
app/vite.config.js Normal file
View File

@@ -0,0 +1,141 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import postcssPresetMantine from 'postcss-preset-mantine';
import postcssSimpleVars from 'postcss-simple-vars';
import { compression } from 'vite-plugin-compression2';
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
plugins: [
react({
include: ['**/*.jsx', '**/*.js'],
}),
compression(),
],
root: 'src',
publicDir: '../public',
build: {
outDir: '../dist',
emptyOutDir: true,
assetsDir: 'assets',
sourcemap: mode === 'development',
rollupOptions: {
input: {
main: path.resolve(__dirname, 'src/index.html'),
},
output: {
manualChunks: {
// React core libraries
'react-core': ['react', 'react-dom'],
// Mantine UI components and related
mantine: [
'@mantine/core',
'@mantine/hooks',
'@mantine/modals',
'@mantine/notifications',
],
// Editor related packages
editor: [
'codemirror',
'@codemirror/commands',
'@codemirror/lang-markdown',
'@codemirror/state',
'@codemirror/theme-one-dark',
'@codemirror/view',
],
// Markdown processing
markdown: [
'react-syntax-highlighter',
'rehype-mathjax',
'rehype-prism',
'rehype-react',
'remark',
'remark-math',
'remark-parse',
'remark-rehype',
'unified',
'unist-util-visit',
],
// Icons and utilities
utils: [
'@tabler/icons-react',
'@react-hook/resize-observer',
'react-arborist',
],
},
// Optimize chunk naming for better caching
chunkFileNames: (chunkInfo) => {
const name = chunkInfo.name;
if (name === 'react-core') return 'assets/react.[hash].js';
if (name === 'mantine') return 'assets/mantine.[hash].js';
if (name === 'editor') return 'assets/editor.[hash].js';
if (name === 'markdown') return 'assets/markdown.[hash].js';
if (name === 'utils') return 'assets/utils.[hash].js';
return 'assets/[name].[hash].js';
},
// Optimize asset naming
assetFileNames: 'assets/[name].[hash][extname]',
},
},
},
server: {
port: 3000,
open: true,
},
define: {
'window.API_BASE_URL': JSON.stringify(
mode === 'production' ? '/api/v1' : 'http://localhost:8080/api/v1'
),
},
css: {
preprocessorOptions: {
scss: {
api: 'modern',
},
},
postcss: {
plugins: [
postcssPresetMantine(),
postcssSimpleVars({
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
}),
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
extensions: ['.js', '.jsx', '.json'],
},
// Add performance optimization options
optimizeDeps: {
include: [
'react',
'react-dom',
'@mantine/core',
'@mantine/hooks',
'codemirror',
'react-markdown',
],
},
}));

View File

@@ -1,90 +0,0 @@
package main
import (
"log"
"net/http"
"os"
"path/filepath"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"novamd/internal/api"
"novamd/internal/db"
"novamd/internal/filesystem"
)
func main() {
// Initialize database
dbPath := os.Getenv("NOVAMD_DB_PATH")
if dbPath == "" {
dbPath = "./sqlite.db"
}
database, err := db.Init(dbPath)
if err != nil {
log.Fatal(err)
}
defer func() {
if err := database.Close(); err != nil {
log.Printf("Error closing database: %v", err)
}
}()
// Workdir
workdir := os.Getenv("NOVAMD_WORKDIR")
if workdir == "" {
workdir = "./data"
}
settings, err := database.GetSettings(1) // Assuming user ID 1 for now
if err != nil {
log.Print("Settings not found, using default settings")
}
fs := filesystem.New(workdir, &settings)
if settings.Settings.GitEnabled {
if err := fs.InitializeGitRepo(); err != nil {
log.Fatal(err)
}
}
// Set up router
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Set up API routes
r.Route("/api/v1", func(r chi.Router) {
api.SetupRoutes(r, database, fs)
})
// Set up static file server with path validation
staticPath := os.Getenv("NOVAMD_STATIC_PATH")
if staticPath == "" {
staticPath = "../frontend/dist"
}
fileServer := http.FileServer(http.Dir(staticPath))
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
requestedPath := r.URL.Path
validatedPath, err := filesystem.ValidatePath(staticPath, requestedPath)
if err != nil {
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
_, err = os.Stat(validatedPath)
if os.IsNotExist(err) {
http.ServeFile(w, r, filepath.Join(staticPath, "index.html"))
return
}
http.StripPrefix("/", fileServer).ServeHTTP(w, r)
})
// Start server
port := os.Getenv("NOVAMD_PORT")
if port == "" {
port = "8080"
}
log.Printf("Server starting on port %s", port)
log.Fatal(http.ListenAndServe(":"+port, r))
}

View File

@@ -1,235 +0,0 @@
package api
import (
"encoding/json"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"novamd/internal/db"
"novamd/internal/filesystem"
"novamd/internal/models"
)
func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
files, err := fs.ListFilesRecursively()
if err != nil {
http.Error(w, "Failed to list files", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(files); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
}
func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
filenameOrPath := r.URL.Query().Get("filename")
if filenameOrPath == "" {
http.Error(w, "Filename or path is required", http.StatusBadRequest)
return
}
filePaths, err := fs.FindFileByName(filenameOrPath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string][]string{"paths": filePaths}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
}
func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")
content, err := fs.GetFileContent(filePath)
if err != nil {
http.Error(w, "Failed to read file", http.StatusNotFound)
return
}
// Determine content type based on file extension
contentType := "text/plain"
switch filepath.Ext(filePath) {
case ".png":
contentType = "image/png"
case ".jpg", ".jpeg":
contentType = "image/jpeg"
case ".webp":
contentType = "image/webp"
case ".gif":
contentType = "image/gif"
case ".svg":
contentType = "image/svg+xml"
case ".md":
contentType = "text/markdown"
}
w.Header().Set("Content-Type", contentType)
if _, err := w.Write(content); err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
}
}
}
func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")
content, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
err = fs.SaveFile(filePath, content)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{"message": "File saved successfully"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
}
func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")
err := fs.DeleteFile(filePath)
if err != nil {
http.Error(w, "Failed to delete file", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte("File deleted successfully")); err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
}
}
}
func GetSettings(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userIDStr := r.URL.Query().Get("userId")
userID, err := strconv.Atoi(userIDStr)
if err != nil {
http.Error(w, "Invalid userId", http.StatusBadRequest)
return
}
settings, err := db.GetSettings(userID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
settings.SetDefaults()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(settings); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
}
func UpdateSettings(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var settings models.Settings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
settings.SetDefaults()
if err := settings.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err := db.SaveSettings(settings)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if settings.Settings.GitEnabled {
err := fs.SetupGitRepo(settings.Settings.GitURL, settings.Settings.GitUser, settings.Settings.GitToken)
if err != nil {
http.Error(w, "Failed to setup git repo", http.StatusInternalServerError)
}
} else {
fs.DisableGitRepo()
}
// Fetch the saved settings to return
savedSettings, err := db.GetSettings(settings.UserID)
if err != nil {
http.Error(w, "Settings saved but could not be retrieved", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(savedSettings); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
}
func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var requestBody struct {
Message string `json:"message"`
}
err := json.NewDecoder(r.Body).Decode(&requestBody)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if requestBody.Message == "" {
http.Error(w, "Commit message is required", http.StatusBadRequest)
return
}
err = fs.StageCommitAndPush(requestBody.Message)
if err != nil {
http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{"message": "Changes staged, committed, and pushed successfully"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
}
func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
err := fs.Pull()
if err != nil {
http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(map[string]string{"message": "Pulled changes from remote"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
}

View File

@@ -1,28 +0,0 @@
package api
import (
"novamd/internal/db"
"novamd/internal/filesystem"
"github.com/go-chi/chi/v5"
)
func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) {
r.Route("/", func(r chi.Router) {
r.Route("/settings", func(r chi.Router) {
r.Get("/", GetSettings(db))
r.Post("/", UpdateSettings(db, fs))
})
r.Route("/files", func(r chi.Router) {
r.Get("/", ListFiles(fs))
r.Get("/*", GetFileContent(fs))
r.Post("/*", SaveFile(fs))
r.Delete("/*", DeleteFile(fs))
r.Get("/lookup", LookupFileByName(fs))
})
r.Route("/git", func(r chi.Router) {
r.Post("/commit", StageCommitAndPush(fs))
r.Post("/pull", PullChanges(fs))
})
})
}

View File

@@ -1,33 +0,0 @@
package db
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
*sql.DB
}
func Init(dbPath string) (*DB, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
database := &DB{db}
if err := database.Migrate(); err != nil {
return nil, err
}
return database, nil
}
func (db *DB) Close() error {
return db.DB.Close()
}

View File

@@ -1,76 +0,0 @@
package db
import (
"fmt"
"log"
)
type Migration struct {
Version int
SQL string
}
var migrations = []Migration{
{
Version: 1,
SQL: `CREATE TABLE IF NOT EXISTS settings (
user_id INTEGER PRIMARY KEY,
settings JSON NOT NULL
)`,
},
}
func (db *DB) Migrate() error {
// Create migrations table if it doesn't exist
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS migrations (
version INTEGER PRIMARY KEY
)`)
if err != nil {
return err
}
// Get current version
var currentVersion int
err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM migrations").Scan(&currentVersion)
if err != nil {
return err
}
// Apply new migrations
for _, migration := range migrations {
if migration.Version > currentVersion {
log.Printf("Applying migration %d", migration.Version)
tx, err := db.Begin()
if err != nil {
return err
}
_, err = tx.Exec(migration.SQL)
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("migration %d failed: %v, rollback failed: %v", migration.Version, err, rbErr)
}
return fmt.Errorf("migration %d failed: %v", migration.Version, err)
}
_, err = tx.Exec("INSERT INTO migrations (version) VALUES (?)", migration.Version)
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("failed to update migration version: %v, rollback failed: %v", err, rbErr)
}
return fmt.Errorf("failed to update migration version: %v", err)
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("failed to commit migration %d: %v", migration.Version, err)
}
currentVersion = migration.Version
}
}
log.Printf("Database is at version %d", currentVersion)
return nil
}

View File

@@ -1,45 +0,0 @@
package db
import (
"database/sql"
"encoding/json"
"novamd/internal/models"
)
func (db *DB) GetSettings(userID int) (models.Settings, error) {
var settings models.Settings
var settingsJSON []byte
err := db.QueryRow("SELECT user_id, settings FROM settings WHERE user_id = ?", userID).Scan(&settings.UserID, &settingsJSON)
if err != nil {
if err == sql.ErrNoRows {
// If no settings found, return default settings
settings.UserID = userID
settings.Settings = models.UserSettings{} // This will be filled with defaults later
return settings, nil
}
return settings, err
}
err = json.Unmarshal(settingsJSON, &settings.Settings)
if err != nil {
return settings, err
}
return settings, nil
}
func (db *DB) SaveSettings(settings models.Settings) error {
if err := settings.Validate(); err != nil {
return err
}
settingsJSON, err := json.Marshal(settings.Settings)
if err != nil {
return err
}
_, err = db.Exec("INSERT OR REPLACE INTO settings (user_id, settings) VALUES (?, json(?))", settings.UserID, string(settingsJSON))
return err
}

View File

@@ -1,227 +0,0 @@
package filesystem
import (
"errors"
"fmt"
"novamd/internal/gitutils"
"novamd/internal/models"
"os"
"path/filepath"
"sort"
"strings"
)
type FileSystem struct {
RootDir string
GitRepo *gitutils.GitRepo
Settings *models.Settings
}
type FileNode struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Children []FileNode `json:"children,omitempty"`
}
func New(rootDir string, settings *models.Settings) *FileSystem {
fs := &FileSystem{
RootDir: rootDir,
Settings: settings,
}
if settings.Settings.GitEnabled {
fs.GitRepo = gitutils.New(
settings.Settings.GitURL,
settings.Settings.GitUser,
settings.Settings.GitToken,
rootDir,
)
}
return fs
}
func (fs *FileSystem) SetupGitRepo(gitURL string, gitUser string, gitToken string) error {
fs.GitRepo = gitutils.New(gitURL, gitUser, gitToken, fs.RootDir)
return fs.InitializeGitRepo()
}
func (fs *FileSystem) DisableGitRepo() {
fs.GitRepo = nil;
}
func (fs *FileSystem) InitializeGitRepo() error {
if fs.GitRepo == nil {
return errors.New("git settings not configured")
}
return fs.GitRepo.EnsureRepo()
}
func ValidatePath(rootDir, path string) (string, error) {
fullPath := filepath.Join(rootDir, path)
cleanPath := filepath.Clean(fullPath)
if !strings.HasPrefix(cleanPath, filepath.Clean(rootDir)) {
return "", fmt.Errorf("invalid path: outside of root directory")
}
relPath, err := filepath.Rel(rootDir, cleanPath)
if err != nil {
return "", err
}
if strings.HasPrefix(relPath, "..") {
return "", fmt.Errorf("invalid path: outside of root directory")
}
return cleanPath, nil
}
func (fs *FileSystem) validatePath(path string) (string, error) {
return ValidatePath(fs.RootDir, path)
}
func (fs *FileSystem) ListFilesRecursively() ([]FileNode, error) {
return fs.walkDirectory(fs.RootDir, "")
}
func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var folders []FileNode
var files []FileNode
for _, entry := range entries {
name := entry.Name()
path := filepath.Join(prefix, name)
fullPath := filepath.Join(dir, name)
if entry.IsDir() {
children, err := fs.walkDirectory(fullPath, path)
if err != nil {
return nil, err
}
folders = append(folders, FileNode{
ID: path, // Using path as ID ensures uniqueness
Name: name,
Path: path,
Children: children,
})
} else {
files = append(files, FileNode{
ID: path, // Using path as ID ensures uniqueness
Name: name,
Path: path,
})
}
}
// Sort folders and files alphabetically
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[i].Name })
// Combine folders and files, with folders first
return append(folders, files...), nil
}
func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) {
var foundPaths []string
var searchPattern string
// If no extension is provided, assume .md
if !strings.Contains(filenameOrPath, ".") {
searchPattern = filenameOrPath + ".md"
} else {
searchPattern = filenameOrPath
}
err := filepath.Walk(fs.RootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
relPath, err := filepath.Rel(fs.RootDir, path)
if err != nil {
return err
}
// Check if the file matches the search pattern
if strings.HasSuffix(relPath, searchPattern) ||
strings.EqualFold(info.Name(), searchPattern) {
foundPaths = append(foundPaths, relPath)
}
}
return nil
})
if err != nil {
return nil, err
}
if len(foundPaths) == 0 {
return nil, errors.New("file not found")
}
return foundPaths, nil
}
func (fs *FileSystem) GetFileContent(filePath string) ([]byte, error) {
fullPath, err := fs.validatePath(filePath)
if err != nil {
return nil, err
}
return os.ReadFile(fullPath)
}
func (fs *FileSystem) SaveFile(filePath string, content []byte) error {
fullPath, err := fs.validatePath(filePath)
if err != nil {
return err
}
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
if fs.Settings.Settings.GitAutoCommit && fs.GitRepo != nil {
message := strings.Replace(fs.Settings.Settings.GitCommitMsgTemplate, "${filename}", filePath, -1)
return fs.StageCommitAndPush(message)
}
return os.WriteFile(fullPath, content, 0644)
}
func (fs *FileSystem) DeleteFile(filePath string) error {
fullPath, err := fs.validatePath(filePath)
if err != nil {
return err
}
return os.Remove(fullPath)
}
func (fs *FileSystem) StageCommitAndPush(message string) error {
if fs.GitRepo == nil {
return errors.New("git settings not configured")
}
if err := fs.GitRepo.Commit(message); err != nil {
return err
}
return fs.GitRepo.Push()
}
func (fs *FileSystem) Pull() error {
if fs.GitRepo == nil {
return errors.New("git settings not configured")
}
return fs.GitRepo.Pull()
}

View File

@@ -1,133 +0,0 @@
package gitutils
import (
"fmt"
"os"
"path/filepath"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/transport/http"
)
type GitRepo struct {
URL string
Username string
Token string
WorkDir string
repo *git.Repository
}
func New(url, username, token, workDir string) *GitRepo {
return &GitRepo{
URL: url,
Username: username,
Token: token,
WorkDir: workDir,
}
}
func (g *GitRepo) Clone() error {
auth := &http.BasicAuth{
Username: g.Username,
Password: g.Token,
}
var err error
g.repo, err = git.PlainClone(g.WorkDir, false, &git.CloneOptions{
URL: g.URL,
Auth: auth,
Progress: os.Stdout,
})
if err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
return nil
}
func (g *GitRepo) Pull() error {
if g.repo == nil {
return fmt.Errorf("repository not initialized")
}
w, err := g.repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
auth := &http.BasicAuth{
Username: g.Username,
Password: g.Token,
}
err = w.Pull(&git.PullOptions{
Auth: auth,
Progress: os.Stdout,
})
if err != nil && err != git.NoErrAlreadyUpToDate {
return fmt.Errorf("failed to pull changes: %w", err)
}
return nil
}
func (g *GitRepo) Commit(message string) error {
if g.repo == nil {
return fmt.Errorf("repository not initialized")
}
w, err := g.repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
_, err = w.Add(".")
if err != nil {
return fmt.Errorf("failed to add changes: %w", err)
}
_, err = w.Commit(message, &git.CommitOptions{})
if err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
}
return nil
}
func (g *GitRepo) Push() error {
if g.repo == nil {
return fmt.Errorf("repository not initialized")
}
auth := &http.BasicAuth{
Username: g.Username,
Password: g.Token,
}
err := g.repo.Push(&git.PushOptions{
Auth: auth,
Progress: os.Stdout,
})
if err != nil && err != git.NoErrAlreadyUpToDate {
return fmt.Errorf("failed to push changes: %w", err)
}
return nil
}
func (g *GitRepo) EnsureRepo() error {
if _, err := os.Stat(filepath.Join(g.WorkDir, ".git")); os.IsNotExist(err) {
return g.Clone()
}
var err error
g.repo, err = git.PlainOpen(g.WorkDir)
if err != nil {
return fmt.Errorf("failed to open existing repository: %w", err)
}
return g.Pull()
}

View File

@@ -1,58 +0,0 @@
package models
import (
"encoding/json"
"github.com/go-playground/validator/v10"
)
type UserSettings struct {
Theme string `json:"theme" validate:"oneof=light dark"`
AutoSave bool `json:"autoSave"`
GitEnabled bool `json:"gitEnabled"`
GitURL string `json:"gitUrl" validate:"required_with=GitEnabled"`
GitUser string `json:"gitUser" validate:"required_with=GitEnabled"`
GitToken string `json:"gitToken" validate:"required_with=GitEnabled"`
GitAutoCommit bool `json:"gitAutoCommit"`
GitCommitMsgTemplate string `json:"gitCommitMsgTemplate"`
}
type Settings struct {
UserID int `json:"userId" validate:"required,min=1"`
Settings UserSettings `json:"settings" validate:"required"`
}
var defaultUserSettings = UserSettings{
Theme: "light",
AutoSave: false,
GitEnabled: false,
GitCommitMsgTemplate: "Update ${filename}",
}
var validate = validator.New()
func (s *Settings) Validate() error {
return validate.Struct(s)
}
func (s *Settings) SetDefaults() {
if s.Settings.Theme == "" {
s.Settings.Theme = defaultUserSettings.Theme
}
if s.Settings.GitCommitMsgTemplate == "" {
s.Settings.GitCommitMsgTemplate = defaultUserSettings.GitCommitMsgTemplate
}
}
func (s *Settings) UnmarshalJSON(data []byte) error {
type Alias Settings
aux := &struct {
*Alias
}{
Alias: (*Alias)(s),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
return s.Validate()
}

View File

@@ -1,10 +0,0 @@
{
"presets": [
"@babel/preset-env",
["@babel/preset-react", { "runtime": "automatic" }]
],
"plugins": [
"@babel/plugin-transform-class-properties",
"@babel/plugin-transform-runtime"
]
}

10313
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +0,0 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NovaMD</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -1,28 +0,0 @@
import React from 'react';
import { Group, Text, ActionIcon, Avatar } from '@mantine/core';
import { IconSettings } from '@tabler/icons-react';
import Settings from './Settings';
import { useModalContext } from '../contexts/ModalContext';
const Header = () => {
const { setSettingsModalVisible } = useModalContext();
const openSettings = () => setSettingsModalVisible(true);
return (
<Group justify="space-between" h={60} px="md">
<Text fw={700} size="lg">
NovaMD
</Text>
<Group>
<Avatar src="https://via.placeholder.com/40" radius="xl" />
<ActionIcon variant="subtle" onClick={openSettings} size="lg">
<IconSettings size={24} />
</ActionIcon>
</Group>
<Settings />
</Group>
);
};
export default Header;

View File

@@ -1,159 +0,0 @@
import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import 'katex/dist/katex.min.css';
import { lookupFileByName } from '../services/api';
const MarkdownPreview = ({ content, handleLinkClick }) => {
const [processedContent, setProcessedContent] = useState(content);
const baseUrl = window.API_BASE_URL;
useEffect(() => {
const processContent = async (rawContent) => {
const regex = /(!?)\[\[(.*?)\]\]/g;
let result = rawContent;
const matches = [...rawContent.matchAll(regex)];
for (const match of matches) {
const [fullMatch, isImage, innerContent] = match;
let fileName, displayText, heading;
// Parse the inner content
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();
}
try {
const paths = await lookupFileByName(fileName);
if (paths && paths.length > 0) {
const filePath = paths[0];
if (isImage) {
result = result.replace(
fullMatch,
`![${displayText}](${baseUrl}/files/${filePath})`
);
} else {
// Include heading in the URL if present
const url = heading
? `${baseUrl}/internal/${encodeURIComponent(
filePath
)}#${encodeURIComponent(heading)}`
: `${baseUrl}/internal/${encodeURIComponent(filePath)}`;
result = result.replace(fullMatch, `[${displayText}](${url})`);
}
} else {
result = result.replace(
fullMatch,
`[${displayText}](${baseUrl}/notfound/${encodeURIComponent(
fileName
)})`
);
}
} catch (error) {
console.error('Error looking up file:', error);
result = result.replace(
fullMatch,
`[${displayText}](${baseUrl}/notfound/${encodeURIComponent(
fileName
)})`
);
}
}
return result;
};
processContent(content).then(setProcessedContent);
}, [content, baseUrl]);
const handleImageError = (event) => {
console.error('Failed to load image:', event.target.src);
event.target.alt = 'Failed to load image';
};
return (
<div className="markdown-preview">
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
img: ({ src, alt, ...props }) => (
<img src={src} alt={alt} onError={handleImageError} {...props} />
),
a: ({ href, children }) => {
if (href.startsWith(`${baseUrl}/internal/`)) {
const [filePath, heading] = decodeURIComponent(
href.replace(`${baseUrl}/internal/`, '')
).split('#');
return (
<a
href="#"
onClick={(e) => {
e.preventDefault();
handleLinkClick(filePath, heading);
}}
>
{children}
</a>
);
} else if (href.startsWith(`${baseUrl}/notfound/`)) {
const fileName = decodeURIComponent(
href.replace(`${baseUrl}/notfound/`, '')
);
return (
<a
href="#"
style={{ color: 'red', textDecoration: 'underline' }}
onClick={(e) => {
e.preventDefault();
handleLinkClick(fileName);
}}
>
{children}
</a>
);
}
// Regular markdown link
return <a href={href}>{children}</a>;
},
}}
>
{processedContent}
</ReactMarkdown>
</div>
);
};
export default MarkdownPreview;

View File

@@ -1,141 +0,0 @@
import React, { useReducer, useEffect, useCallback, useRef } from 'react';
import { Modal, Badge, Button, Group, Title } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useSettings } from '../contexts/SettingsContext';
import AppearanceSettings from './settings/AppearanceSettings';
import EditorSettings from './settings/EditorSettings';
import GitSettings from './settings/GitSettings';
import { useModalContext } from '../contexts/ModalContext';
const initialState = {
localSettings: {},
initialSettings: {},
hasUnsavedChanges: false,
};
function settingsReducer(state, action) {
switch (action.type) {
case 'INIT_SETTINGS':
return {
...state,
localSettings: action.payload,
initialSettings: action.payload,
hasUnsavedChanges: false,
};
case 'UPDATE_LOCAL_SETTINGS':
const newLocalSettings = { ...state.localSettings, ...action.payload };
const hasChanges =
JSON.stringify(newLocalSettings) !==
JSON.stringify(state.initialSettings);
return {
...state,
localSettings: newLocalSettings,
hasUnsavedChanges: hasChanges,
};
case 'MARK_SAVED':
return {
...state,
initialSettings: state.localSettings,
hasUnsavedChanges: false,
};
case 'RESET':
return {
...state,
localSettings: state.initialSettings,
hasUnsavedChanges: false,
};
default:
return state;
}
}
const Settings = () => {
const { settings, updateSettings, colorScheme } = useSettings();
const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
const [state, dispatch] = useReducer(settingsReducer, initialState);
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
dispatch({ type: 'INIT_SETTINGS', payload: settings });
}
}, [settings]);
useEffect(() => {
dispatch({
type: 'UPDATE_LOCAL_SETTINGS',
payload: { theme: colorScheme },
});
}, [colorScheme]);
const handleInputChange = useCallback((key, value) => {
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
}, []);
const handleSubmit = async () => {
try {
await updateSettings(state.localSettings);
dispatch({ type: 'MARK_SAVED' });
notifications.show({
message: 'Settings saved successfully',
color: 'green',
});
setSettingsModalVisible(false);
} catch (error) {
console.error('Failed to save settings:', error);
notifications.show({
message: 'Failed to save settings: ' + error.message,
color: 'red',
});
}
};
const handleClose = useCallback(() => {
if (state.hasUnsavedChanges) {
dispatch({ type: 'RESET' });
}
setSettingsModalVisible(false);
}, [state.hasUnsavedChanges, setSettingsModalVisible]);
return (
<Modal
opened={settingsModalVisible}
onClose={handleClose}
title={<Title order={2}>Settings</Title>}
centered
size="lg"
>
{state.hasUnsavedChanges && (
<Badge color="yellow" variant="light" mb="md">
Unsaved Changes
</Badge>
)}
<AppearanceSettings
themeSettings={state.localSettings.theme}
onThemeChange={(newTheme) => handleInputChange('theme', newTheme)}
/>
<EditorSettings
autoSave={state.localSettings.autoSave}
onAutoSaveChange={(value) => handleInputChange('autoSave', value)}
/>
<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}
onInputChange={handleInputChange}
/>
<Group justify="flex-end" mt="xl">
<Button variant="default" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSubmit}>Save Changes</Button>
</Group>
</Modal>
);
};
export default Settings;

View File

@@ -1,24 +0,0 @@
import React from 'react';
import { Text, Switch, Tooltip, Group, Box, Title } from '@mantine/core';
const EditorSettings = ({ autoSave, onAutoSaveChange }) => {
return (
<Box mb="md">
<Title order={3} mb="md">
Editor
</Title>
<Tooltip label="Auto Save feature is coming soon!" position="left">
<Group justify="space-between" align="center">
<Text size="sm">Auto Save</Text>
<Switch
checked={autoSave}
onChange={(event) => onAutoSaveChange(event.currentTarget.checked)}
disabled
/>
</Group>
</Tooltip>
</Box>
);
};
export default EditorSettings;

View File

@@ -1,79 +0,0 @@
import React, {
createContext,
useContext,
useEffect,
useMemo,
useCallback,
useState,
} from 'react';
import { useMantineColorScheme } from '@mantine/core';
import { fetchUserSettings, saveUserSettings } from '../services/api';
import { DEFAULT_SETTINGS } from '../utils/constants';
const SettingsContext = createContext();
export const useSettings = () => useContext(SettingsContext);
export const SettingsProvider = ({ children }) => {
const { colorScheme, setColorScheme } = useMantineColorScheme();
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadSettings = async () => {
try {
const userSettings = await fetchUserSettings(1);
setSettings(userSettings.settings);
setColorScheme(userSettings.settings.theme);
} catch (error) {
console.error('Failed to load user settings:', error);
} finally {
setLoading(false);
}
};
loadSettings();
}, []);
const updateSettings = useCallback(
async (newSettings) => {
try {
await saveUserSettings({
userId: 1,
settings: newSettings,
});
setSettings(newSettings);
if (newSettings.theme) {
setColorScheme(newSettings.theme);
}
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
},
[setColorScheme]
);
const toggleColorScheme = useCallback(() => {
const newTheme = colorScheme === 'dark' ? 'light' : 'dark';
setColorScheme(newTheme);
updateSettings({ ...settings, theme: newTheme });
}, [colorScheme, settings, setColorScheme, updateSettings]);
const contextValue = useMemo(
() => ({
settings,
updateSettings,
toggleColorScheme,
loading,
colorScheme,
}),
[settings, updateSettings, toggleColorScheme, loading, colorScheme]
);
return (
<SettingsContext.Provider value={contextValue}>
{children}
</SettingsContext.Provider>
);
};

View File

@@ -1,54 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import { fetchFileContent } from '../services/api';
import { isImageFile } from '../utils/fileHelpers';
import { DEFAULT_FILE } from '../utils/constants';
export const useFileContent = (selectedFile) => {
const [content, setContent] = useState(DEFAULT_FILE.content);
const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const loadFileContent = useCallback(async (filePath) => {
try {
let newContent;
if (filePath === DEFAULT_FILE.path) {
newContent = DEFAULT_FILE.content;
} else if (!isImageFile(filePath)) {
newContent = await fetchFileContent(filePath);
} else {
newContent = ''; // Set empty content for image files
}
setContent(newContent);
setOriginalContent(newContent);
setHasUnsavedChanges(false);
} catch (err) {
console.error('Error loading file content:', err);
setContent(''); // Set empty content on error
setOriginalContent('');
setHasUnsavedChanges(false);
}
}, []);
useEffect(() => {
if (selectedFile) {
loadFileContent(selectedFile);
}
}, [selectedFile, loadFileContent]);
const handleContentChange = useCallback(
(newContent) => {
setContent(newContent);
setHasUnsavedChanges(newContent !== originalContent);
},
[originalContent]
);
return {
content,
setContent,
hasUnsavedChanges,
setHasUnsavedChanges,
loadFileContent,
handleContentChange,
};
};

View File

@@ -1,41 +0,0 @@
import { useState, useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { lookupFileByName } from '../services/api';
import { DEFAULT_FILE } from '../utils/constants';
export const useFileNavigation = () => {
const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path);
const [isNewFile, setIsNewFile] = useState(true);
const handleFileSelect = useCallback((filePath) => {
setSelectedFile(filePath);
setIsNewFile(filePath === DEFAULT_FILE.path);
}, []);
const handleLinkClick = useCallback(
async (filename) => {
try {
const filePaths = await lookupFileByName(filename);
if (filePaths.length >= 1) {
handleFileSelect(filePaths[0]);
} else {
notifications.show({
title: 'File Not Found',
message: `File "${filename}" not found`,
color: 'red',
});
}
} catch (error) {
console.error('Error looking up file:', error);
notifications.show({
title: 'Error',
message: 'Failed to lookup file.',
color: 'red',
});
}
},
[handleFileSelect]
);
return { handleLinkClick, selectedFile, isNewFile, handleFileSelect };
};

View File

@@ -1,67 +0,0 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { saveFileContent, deleteFile } from '../services/api';
export const useFileOperations = () => {
const handleSave = useCallback(async (filePath, content) => {
try {
await saveFileContent(filePath, content);
notifications.show({
title: 'Success',
message: 'File saved successfully',
color: 'green',
});
return true;
} catch (error) {
console.error('Error saving file:', error);
notifications.show({
title: 'Error',
message: 'Failed to save file',
color: 'red',
});
return false;
}
}, []);
const handleDelete = useCallback(async (filePath) => {
try {
await deleteFile(filePath);
notifications.show({
title: 'Success',
message: 'File deleted successfully',
color: 'green',
});
return true;
} catch (error) {
console.error('Error deleting file:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete file',
color: 'red',
});
return false;
}
}, []);
const handleCreate = useCallback(async (fileName, initialContent = '') => {
try {
await saveFileContent(fileName, initialContent);
notifications.show({
title: 'Success',
message: 'File created successfully',
color: 'green',
});
return true;
} catch (error) {
console.error('Error creating new file:', error);
notifications.show({
title: 'Error',
message: 'Failed to create new file',
color: 'red',
});
return false;
}
}, []);
return { handleSave, handleDelete, handleCreate };
};

View File

@@ -1,91 +0,0 @@
const API_BASE_URL = window.API_BASE_URL;
const apiCall = async (url, options = {}) => {
try {
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(
errorData?.message || `HTTP error! status: ${response.status}`
);
}
return response;
} catch (error) {
console.error(`API call failed: ${error.message}`);
throw error;
}
};
export const fetchFileList = async () => {
const response = await apiCall(`${API_BASE_URL}/files`);
return response.json();
};
export const fetchFileContent = async (filePath) => {
const response = await apiCall(`${API_BASE_URL}/files/${filePath}`);
return response.text();
};
export const saveFileContent = async (filePath, content) => {
const response = await apiCall(`${API_BASE_URL}/files/${filePath}`, {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
body: content,
});
return response.text();
};
export const deleteFile = async (filePath) => {
const response = await apiCall(`${API_BASE_URL}/files/${filePath}`, {
method: 'DELETE',
});
return response.text();
};
export const fetchUserSettings = async (userId) => {
const response = await apiCall(`${API_BASE_URL}/settings?userId=${userId}`);
return response.json();
};
export const saveUserSettings = async (settings) => {
const response = await apiCall(`${API_BASE_URL}/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings),
});
return response.json();
};
export const pullChanges = async () => {
const response = await apiCall(`${API_BASE_URL}/git/pull`, {
method: 'POST',
});
return response.json();
};
export const commitAndPush = async (message) => {
const response = await apiCall(`${API_BASE_URL}/git/commit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message }),
});
return response.json();
};
export const getFileUrl = (filePath) => {
return `${API_BASE_URL}/files/${filePath}`;
};
export const lookupFileByName = async (filename) => {
const response = await apiCall(
`${API_BASE_URL}/files/lookup?filename=${encodeURIComponent(filename)}`
);
const data = await response.json();
return data.paths;
};

View File

@@ -1,49 +0,0 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
module.exports = (env, argv) => {
const isProduction = argv.mode === 'production';
return {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: ['babel-loader'],
},
{
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader', 'postcss-loader'],
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
new webpack.DefinePlugin({
'window.API_BASE_URL': JSON.stringify(
isProduction ? '/api/v1' : 'http://localhost:8080/api/v1'
),
}),
],
devServer: {
static: {
directory: path.join(__dirname, 'public'),
},
port: 3000,
open: true,
},
};
};

48
server/cmd/server/main.go Normal file
View File

@@ -0,0 +1,48 @@
// Package main provides the entry point for the application. It loads the configuration, initializes the server, and starts the server.
package main
import (
"log"
"lemma/internal/app"
"lemma/internal/logging"
)
// @title Lemma API
// @version 1.0
// @description This is the API for Lemma markdown note taking app.
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @BasePath /api/v1
// @SecurityDefinitions.ApiKey CookieAuth
// @In cookie
// @Name access_token
func main() {
// Load configuration
cfg, err := app.LoadConfig()
if err != nil {
log.Fatal("Failed to load configuration:", err)
}
// Setup logging
logging.Setup(cfg.LogLevel)
logging.Debug("Configuration loaded", "config", cfg.Redact())
// Initialize and start server
options, err := app.DefaultOptions(cfg)
if err != nil {
log.Fatal("Failed to initialize server options:", err)
}
server := app.NewServer(options)
defer func() {
if err := server.Close(); err != nil {
logging.Error("Failed to close server:", err)
}
}()
// Start server
if err := server.Start(); err != nil {
log.Fatal("Server error:", err)
}
}

1790
server/docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

1765
server/docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

1143
server/docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

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