158 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
166 changed files with 21568 additions and 2560 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

1
.gitignore vendored
View File

@@ -157,6 +157,7 @@ go.work.sum
# env file
.env
.env.dev
main
*.db

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,6 +14,7 @@
"go.lintTool": "golangci-lint",
"go.lintOnSave": "package",
"go.formatTool": "goimports",
"go.testFlags": ["-tags=test,integration"],
"[go]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
@@ -23,6 +24,7 @@
},
"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
@@ -9,7 +11,7 @@ Yet another markdown editor. Work in progress
- Git integration for version control
- Dark and light theme support
- Multiple workspaces
- Math equation support (KaTeX)
- Math equation support (MathJax)
- Code syntax highlighting
## Prerequisites
@@ -18,37 +20,55 @@ Yet another markdown editor. Work in progress
- Node.js 20 or later
- gcc (for go-sqlite3 package)
## Setup
## Configuration
Set the following environment variables:
Lemma can be configured using environment variables. Here are the available configuration options:
- `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")
- `NOVAMD_ADMIN_EMAIL`: Admin user email
- `NOVAMD_ADMIN_PASSWORD`: Admin user password
- `NOVAMD_ENCRYPTION_KEY`: 32-byte key for encrypting sensitive data
### Required Environment Variables
To generate a secure encryption key you can use openssl:
- `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
```
## Running the Backend
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.
1. Navigate to the `backend` directory
2. Ensure all environment variables are set
3. Run the server:
## 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
@@ -59,20 +79,20 @@ openssl rand -base64 32
```
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
@@ -80,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.

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "novamd-frontend",
"name": "lemma-frontend",
"version": "0.1.0",
"description": "Yet another markdown editor",
"type": "module",
@@ -10,7 +10,7 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/LordMathis/NovaMD.git"
"url": "git+https://github.com/LordMathis/Lemma.git"
},
"keywords": [
"markdown",
@@ -19,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",
@@ -35,14 +35,19 @@
"@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"
},
"devDependencies": {
"@types/react": "^18.2.67",

View File

@@ -2,15 +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 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() {
return <Layout />;
function AuthenticatedContent() {
const { user, loading, initialized } = useAuth();
if (!initialized) {
return null;
}
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return <LoginPage />;
}
return (
<WorkspaceProvider>
<ModalProvider>
<Layout />
</ModalProvider>
</WorkspaceProvider>
);
}
function App() {
@@ -20,11 +42,9 @@ function App() {
<MantineProvider defaultColorScheme="light">
<Notifications />
<ModalsProvider>
<WorkspaceProvider>
<ModalProvider>
<AppContent />
</ModalProvider>
</WorkspaceProvider>
<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,7 +5,7 @@ import { EditorView, keymap } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown';
import { defaultKeymap } from '@codemirror/commands';
import { oneDark } from '@codemirror/theme-one-dark';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
const { colorScheme } = useWorkspace();

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,8 +6,8 @@ import {
IconGitPullRequest,
IconGitCommit,
} from '@tabler/icons-react';
import { useModalContext } from '../contexts/ModalContext';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useModalContext } from '../../contexts/ModalContext';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const FileActions = ({ handlePullChanges, selectedFile }) => {
const { settings } = useWorkspace();

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

@@ -3,14 +3,13 @@ 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 { useFileList } from '../hooks/useFileList';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useFileNavigation } from '../../hooks/useFileNavigation';
import { useFileList } from '../../hooks/useFileList';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const Layout = () => {
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
const { selectedFile, handleFileSelect, handleLinkClick } =
useFileNavigation();
const { selectedFile, handleFileSelect } = useFileNavigation();
const { files, loadFileList } = useFileList();
if (workspaceLoading) {
@@ -49,7 +48,6 @@ const Layout = () => {
<MainContent
selectedFile={selectedFile}
handleFileSelect={handleFileSelect}
handleLinkClick={handleLinkClick}
loadFileList={loadFileList}
/>
</Container>

View File

@@ -2,22 +2,17 @@ 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 { useWorkspace } from '../contexts/WorkspaceContext';
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,
loadFileList,
}) => {
const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
const [activeTab, setActiveTab] = useState('source');
const { settings } = useWorkspace();
const {
@@ -113,7 +108,7 @@ const MainContent = ({
content={content}
handleContentChange={handleContentChange}
handleSave={handleSaveFile}
handleLinkClick={handleLinkClick}
handleFileSelect={handleFileSelect}
/>
</Box>
<CreateFileModal onCreateFile={handleCreateFile} />

View File

@@ -1,9 +1,9 @@
import React, { useEffect } from 'react';
import { Box } from '@mantine/core';
import FileActions from './FileActions';
import FileTree from './FileTree';
import { useGitOperations } from '../hooks/useGitOperations';
import { useWorkspace } from '../contexts/WorkspaceContext';
import FileActions from '../files/FileActions';
import FileTree from '../files/FileTree';
import { useGitOperations } from '../../hooks/useGitOperations';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
const { settings } = useWorkspace();
@@ -28,7 +28,7 @@ const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
<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

@@ -1,7 +1,7 @@
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 { useModalContext } from '../../../contexts/ModalContext';
import { createWorkspace } from '../../../services/api';
import { notifications } from '@mantine/notifications';
const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {

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

@@ -15,10 +15,10 @@ import {
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/CreateWorkspaceModal';
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();
@@ -47,7 +47,7 @@ const WorkspaceSwitcher = () => {
const handleWorkspaceCreated = async (newWorkspace) => {
await loadWorkspaces();
switchWorkspace(newWorkspace.id);
switchWorkspace(newWorkspace.name);
};
return (
@@ -102,10 +102,10 @@ const WorkspaceSwitcher = () => {
</Center>
) : (
workspaces.map((workspace) => {
const isSelected = workspace.id === currentWorkspace?.id;
const isSelected = workspace.name === currentWorkspace?.name;
return (
<Paper
key={workspace.id}
key={workspace.name}
p="xs"
withBorder
style={{
@@ -125,7 +125,7 @@ const WorkspaceSwitcher = () => {
<UnstyledButton
style={{ flex: 1 }}
onClick={() => {
switchWorkspace(workspace.id);
switchWorkspace(workspace.name);
setPopoverOpened(false);
}}
>

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,6 +1,6 @@
import React from 'react';
import { Text, Switch, Group, Box, Title } from '@mantine/core';
import { useWorkspace } from '../../contexts/WorkspaceContext';
import { useWorkspace } from '../../../contexts/WorkspaceContext';
const AppearanceSettings = ({ themeSettings, onThemeChange }) => {
const { colorScheme, updateColorScheme } = useWorkspace();

View File

@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { Box, Button, Title } from '@mantine/core';
import DeleteWorkspaceModal from '../modals/DeleteWorkspaceModal';
import { useWorkspace } from '../../contexts/WorkspaceContext';
import { useModalContext } from '../../contexts/ModalContext';
import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal';
import { useWorkspace } from '../../../contexts/WorkspaceContext';
import { useModalContext } from '../../../contexts/ModalContext';
const DangerZoneSettings = () => {
const { currentWorkspace, workspaces, deleteCurrentWorkspace } =

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

@@ -6,7 +6,6 @@ import {
Stack,
PasswordInput,
Group,
Title,
Grid,
} from '@mantine/core';
@@ -17,13 +16,15 @@ const GitSettings = ({
gitToken,
gitAutoCommit,
gitCommitMsgTemplate,
gitCommitName,
gitCommitEmail,
onInputChange,
}) => {
return (
<Stack spacing="md">
<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">
@@ -42,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)
}
@@ -51,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)
}
@@ -65,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)
}
@@ -79,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">
@@ -99,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)
}
@@ -106,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

@@ -9,13 +9,14 @@ import {
Accordion,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useWorkspace } from '../contexts/WorkspaceContext';
import AppearanceSettings from './settings/AppearanceSettings';
import EditorSettings from './settings/EditorSettings';
import GitSettings from './settings/GitSettings';
import GeneralSettings from './settings/GeneralSettings';
import { useModalContext } from '../contexts/ModalContext';
import DangerZoneSettings from './settings/DangerZoneSettings';
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: {},
@@ -53,13 +54,7 @@ function settingsReducer(state, action) {
}
}
const AccordionControl = ({ children }) => (
<Accordion.Control>
<Title order={4}>{children}</Title>
</Accordion.Control>
);
const Settings = () => {
const WorkspaceSettings = () => {
const { currentWorkspace, updateSettings } = useWorkspace();
const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
const [state, dispatch] = useReducer(settingsReducer, initialState);
@@ -72,12 +67,15 @@ 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 });
}
@@ -121,7 +119,7 @@ const Settings = () => {
<Modal
opened={settingsModalVisible}
onClose={handleClose}
title={<Title order={2}>Settings</Title>}
title={<Title order={2}>Workspace Settings</Title>}
centered
size="lg"
>
@@ -190,6 +188,10 @@ const Settings = () => {
onAutoSaveChange={(value) =>
handleInputChange('autoSave', value)
}
showHiddenFiles={state.localSettings.showHiddenFiles}
onShowHiddenFilesChange={(value) =>
handleInputChange('showHiddenFiles', value)
}
/>
</Accordion.Panel>
</Accordion.Item>
@@ -204,6 +206,8 @@ const Settings = () => {
gitToken={state.localSettings.gitToken}
gitAutoCommit={state.localSettings.gitAutoCommit}
gitCommitMsgTemplate={state.localSettings.gitCommitMsgTemplate}
gitCommitName={state.localSettings.gitCommitName}
gitCommitEmail={state.localSettings.gitCommitEmail}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
@@ -228,4 +232,4 @@ const Settings = () => {
);
};
export default Settings;
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,10 +8,10 @@ import React, {
import { useMantineColorScheme } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
fetchLastWorkspaceId,
fetchLastWorkspaceName,
getWorkspace,
updateWorkspace,
updateLastWorkspace,
updateLastWorkspaceName,
deleteWorkspace,
listWorkspaces,
} from '../services/api';
@@ -41,9 +41,9 @@ export const WorkspaceProvider = ({ children }) => {
}
}, []);
const loadWorkspaceData = useCallback(async (workspaceId) => {
const loadWorkspaceData = useCallback(async (workspaceName) => {
try {
const workspace = await getWorkspace(workspaceId);
const workspace = await getWorkspace(workspaceName);
setCurrentWorkspace(workspace);
setColorScheme(workspace.theme);
} catch (error) {
@@ -61,8 +61,8 @@ export const WorkspaceProvider = ({ children }) => {
const allWorkspaces = await listWorkspaces();
if (allWorkspaces.length > 0) {
const firstWorkspace = allWorkspaces[0];
await updateLastWorkspace(firstWorkspace.id);
await loadWorkspaceData(firstWorkspace.id);
await updateLastWorkspaceName(firstWorkspace.name);
await loadWorkspaceData(firstWorkspace.name);
}
} catch (error) {
console.error('Failed to load first available workspace:', error);
@@ -77,9 +77,9 @@ export const WorkspaceProvider = ({ children }) => {
useEffect(() => {
const initializeWorkspace = async () => {
try {
const { lastWorkspaceId } = await fetchLastWorkspaceId();
if (lastWorkspaceId) {
await loadWorkspaceData(lastWorkspaceId);
const { lastWorkspaceName } = await fetchLastWorkspaceName();
if (lastWorkspaceName) {
await loadWorkspaceData(lastWorkspaceName);
} else {
await loadFirstAvailableWorkspace();
}
@@ -95,11 +95,11 @@ export const WorkspaceProvider = ({ children }) => {
initializeWorkspace();
}, []);
const switchWorkspace = useCallback(async (workspaceId) => {
const switchWorkspace = useCallback(async (workspaceName) => {
try {
setLoading(true);
await updateLastWorkspace(workspaceId);
await loadWorkspaceData(workspaceId);
await updateLastWorkspaceName(workspaceName);
await loadWorkspaceData(workspaceName);
await loadWorkspaces();
} catch (error) {
console.error('Failed to switch workspace:', error);
@@ -129,10 +129,10 @@ export const WorkspaceProvider = ({ children }) => {
}
// Delete workspace and get the next workspace ID
const response = await deleteWorkspace(currentWorkspace.id);
const response = await deleteWorkspace(currentWorkspace.name);
// Load the new workspace data
await loadWorkspaceData(response.nextWorkspaceId);
await loadWorkspaceData(response.nextWorkspaceName);
notifications.show({
title: 'Success',
@@ -162,7 +162,7 @@ export const WorkspaceProvider = ({ children }) => {
};
const response = await updateWorkspace(
currentWorkspace.id,
currentWorkspace.name,
updatedWorkspace
);
setCurrentWorkspace(response);

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

@@ -19,7 +19,7 @@ export const useFileContent = (selectedFile) => {
if (filePath === DEFAULT_FILE.path) {
newContent = DEFAULT_FILE.content;
} else if (!isImageFile(filePath)) {
newContent = await fetchFileContent(currentWorkspace.id, filePath);
newContent = await fetchFileContent(currentWorkspace.name, filePath);
} else {
newContent = ''; // Set empty content for image files
}

View File

@@ -10,7 +10,7 @@ export const useFileList = () => {
if (!currentWorkspace || workspaceLoading) return;
try {
const fileList = await fetchFileList(currentWorkspace.id);
const fileList = await fetchFileList(currentWorkspace.name);
if (Array.isArray(fileList)) {
setFiles(fileList);
} else {

View File

@@ -1,6 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { notifications } from '@mantine/notifications';
import { lookupFileByName } from '../services/api';
import { DEFAULT_FILE } from '../utils/constants';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useLastOpenedFile } from './useLastOpenedFile';
@@ -24,36 +22,12 @@ export const useFileNavigation = () => {
[saveLastOpenedFile]
);
const handleLinkClick = useCallback(
async (filename) => {
if (!currentWorkspace) return;
try {
const filePaths = await lookupFileByName(currentWorkspace.id, 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',
});
}
},
[currentWorkspace, handleFileSelect]
);
// 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);
@@ -62,8 +36,10 @@ export const useFileNavigation = () => {
}
};
if (currentWorkspace) {
initializeFile();
}
}, [currentWorkspace, loadLastOpenedFile, handleFileSelect]);
return { handleLinkClick, selectedFile, isNewFile, handleFileSelect };
return { selectedFile, isNewFile, handleFileSelect };
};

View File

@@ -29,7 +29,7 @@ export const useFileOperations = () => {
if (!currentWorkspace) return false;
try {
await saveFileContent(currentWorkspace.id, filePath, content);
await saveFileContent(currentWorkspace.name, filePath, content);
notifications.show({
title: 'Success',
message: 'File saved successfully',
@@ -55,7 +55,7 @@ export const useFileOperations = () => {
if (!currentWorkspace) return false;
try {
await deleteFile(currentWorkspace.id, filePath);
await deleteFile(currentWorkspace.name, filePath);
notifications.show({
title: 'Success',
message: 'File deleted successfully',
@@ -81,7 +81,7 @@ export const useFileOperations = () => {
if (!currentWorkspace) return false;
try {
await saveFileContent(currentWorkspace.id, fileName, initialContent);
await saveFileContent(currentWorkspace.name, fileName, initialContent);
notifications.show({
title: 'Success',
message: 'File created successfully',

View File

@@ -10,7 +10,7 @@ export const useGitOperations = () => {
if (!currentWorkspace || !settings.gitEnabled) return false;
try {
await pullChanges(currentWorkspace.id);
await pullChanges(currentWorkspace.name);
notifications.show({
title: 'Success',
message: 'Successfully pulled latest changes',
@@ -33,7 +33,7 @@ export const useGitOperations = () => {
if (!currentWorkspace || !settings.gitEnabled) return false;
try {
await commitAndPush(currentWorkspace.id, message);
await commitAndPush(currentWorkspace.name, message);
notifications.show({
title: 'Success',
message: 'Successfully committed and pushed changes',

View File

@@ -9,7 +9,7 @@ export const useLastOpenedFile = () => {
if (!currentWorkspace) return null;
try {
const response = await getLastOpenedFile(currentWorkspace.id);
const response = await getLastOpenedFile(currentWorkspace.name);
return response.lastOpenedFilePath || null;
} catch (error) {
console.error('Failed to load last opened file:', error);
@@ -22,7 +22,7 @@ export const useLastOpenedFile = () => {
if (!currentWorkspace) return;
try {
await updateLastOpenedFile(currentWorkspace.id, filePath);
await updateLastOpenedFile(currentWorkspace.name, filePath);
} catch (error) {
console.error('Failed to save last opened file:', error);
}

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,
};
};

View File

@@ -4,7 +4,7 @@
<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>NovaMD</title>
<title>Lemma</title>
</head>
<body>
<div id="root"></div>

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

@@ -47,9 +47,21 @@ export const DEFAULT_WORKSPACE = {
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);
}
}
}
};
}

View File

@@ -52,11 +52,16 @@ export default defineConfig(({ mode }) => ({
// Markdown processing
markdown: [
'react-markdown',
'react-syntax-highlighter',
'rehype-katex',
'rehype-mathjax',
'rehype-prism',
'rehype-react',
'remark',
'remark-math',
'katex',
'remark-parse',
'remark-rehype',
'unified',
'unist-util-visit',
],
// Icons and utilities

View File

@@ -1,69 +0,0 @@
package main
import (
"log"
"net/http"
"os"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"novamd/internal/api"
"novamd/internal/config"
"novamd/internal/db"
"novamd/internal/filesystem"
"novamd/internal/user"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load configuration:", err)
}
// Initialize database
database, err := db.Init(cfg.DBPath, cfg.EncryptionKey)
if err != nil {
log.Fatal(err)
}
defer func() {
if err := database.Close(); err != nil {
log.Printf("Error closing database: %v", err)
}
}()
// Initialize filesystem
fs := filesystem.New(cfg.WorkDir)
// Initialize user service
userService := user.NewUserService(database, fs)
// Create admin user
if _, err := userService.SetupAdminUser(cfg.AdminEmail, cfg.AdminPassword); err != nil {
log.Fatal(err)
}
// Set up router
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// API routes
r.Route("/api/v1", func(r chi.Router) {
api.SetupRoutes(r, database, fs)
})
// Handle all other routes with static file server
r.Get("/*", api.NewStaticHandler(cfg.StaticPath).ServeHTTP)
// 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,171 +0,0 @@
package api
import (
"encoding/json"
"io"
"net/http"
"novamd/internal/db"
"novamd/internal/filesystem"
"github.com/go-chi/chi/v5"
)
func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
files, err := fs.ListFilesRecursively(userID, workspaceID)
if err != nil {
http.Error(w, "Failed to list files", http.StatusInternalServerError)
return
}
respondJSON(w, files)
}
}
func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filename := r.URL.Query().Get("filename")
if filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
filePaths, err := fs.FindFileByName(userID, workspaceID, filename)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
respondJSON(w, map[string][]string{"paths": filePaths})
}
}
func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filePath := chi.URLParam(r, "*")
content, err := fs.GetFileContent(userID, workspaceID, filePath)
if err != nil {
http.Error(w, "Failed to read file", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/plain")
w.Write(content)
}
}
func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filePath := chi.URLParam(r, "*")
content, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
err = fs.SaveFile(userID, workspaceID, filePath, content)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "File saved successfully"})
}
}
func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filePath := chi.URLParam(r, "*")
err = fs.DeleteFile(userID, workspaceID, filePath)
if err != nil {
http.Error(w, "Failed to delete file", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("File deleted successfully"))
}
}
func GetLastOpenedFile(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filePath, err := db.GetLastOpenedFile(workspaceID)
if err != nil {
http.Error(w, "Failed to get last opened file", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"lastOpenedFilePath": filePath})
}
}
func UpdateLastOpenedFile(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var requestBody struct {
FilePath string `json:"filePath"`
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate the file path exists in the workspace
if requestBody.FilePath != "" {
if _, err := fs.ValidatePath(userID, workspaceID, requestBody.FilePath); err != nil {
http.Error(w, "Invalid file path", http.StatusBadRequest)
return
}
}
if err := db.UpdateLastOpenedFile(workspaceID, requestBody.FilePath); err != nil {
http.Error(w, "Failed to update last opened file", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "Last opened file updated successfully"})
}
}

View File

@@ -1,58 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"novamd/internal/filesystem"
)
func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var requestBody struct {
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); 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(userID, workspaceID, requestBody.Message)
if err != nil {
http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "Changes staged, committed, and pushed successfully"})
}
}
func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = fs.Pull(userID, workspaceID)
if err != nil {
http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "Pulled changes from remote"})
}
}

View File

@@ -1,37 +0,0 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
func getUserID(r *http.Request) (int, error) {
userIDStr := chi.URLParam(r, "userId")
return strconv.Atoi(userIDStr)
}
func getUserAndWorkspaceIDs(r *http.Request) (int, int, error) {
userID, err := getUserID(r)
if err != nil {
return 0, 0, errors.New("invalid userId")
}
workspaceIDStr := chi.URLParam(r, "workspaceId")
workspaceID, err := strconv.Atoi(workspaceIDStr)
if err != nil {
return userID, 0, errors.New("invalid workspaceId")
}
return userID, workspaceID, nil
}
func respondJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}

View File

@@ -1,50 +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) {
// User routes
r.Route("/users/{userId}", func(r chi.Router) {
r.Get("/", GetUser(db))
// Workspace routes
r.Route("/workspaces", func(r chi.Router) {
r.Get("/", ListWorkspaces(db))
r.Post("/", CreateWorkspace(db, fs))
r.Get("/last", GetLastWorkspace(db))
r.Put("/last", UpdateLastWorkspace(db))
r.Route("/{workspaceId}", func(r chi.Router) {
r.Get("/", GetWorkspace(db))
r.Put("/", UpdateWorkspace(db, fs))
r.Delete("/", DeleteWorkspace(db))
// File routes
r.Route("/files", func(r chi.Router) {
r.Get("/", ListFiles(fs))
r.Get("/last", GetLastOpenedFile(db))
r.Put("/last", UpdateLastOpenedFile(db, fs))
r.Get("/lookup", LookupFileByName(fs)) // Moved here
r.Post("/*", SaveFile(fs))
r.Get("/*", GetFileContent(fs))
r.Delete("/*", DeleteFile(fs))
})
// Git routes
r.Route("/git", func(r chi.Router) {
r.Post("/commit", StageCommitAndPush(fs))
r.Post("/pull", PullChanges(fs))
})
})
})
})
})
}

View File

@@ -1,25 +0,0 @@
package api
import (
"net/http"
"novamd/internal/db"
)
func GetUser(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := getUserID(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
user, err := db.GetUserByID(userID)
if err != nil {
http.Error(w, "Failed to get user", http.StatusInternalServerError)
return
}
respondJSON(w, user)
}
}

View File

@@ -1,247 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"novamd/internal/db"
"novamd/internal/filesystem"
"novamd/internal/models"
)
func ListWorkspaces(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := getUserID(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
workspaces, err := db.GetWorkspacesByUserID(userID)
if err != nil {
http.Error(w, "Failed to list workspaces", http.StatusInternalServerError)
return
}
respondJSON(w, workspaces)
}
}
func CreateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := getUserID(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var workspace models.Workspace
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
workspace.UserID = userID
if err := db.CreateWorkspace(&workspace); err != nil {
http.Error(w, "Failed to create workspace", http.StatusInternalServerError)
return
}
if err := fs.InitializeUserWorkspace(workspace.UserID, workspace.ID); err != nil {
http.Error(w, "Failed to initialize workspace directory", http.StatusInternalServerError)
return
}
respondJSON(w, workspace)
}
}
func GetWorkspace(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
workspace, err := db.GetWorkspaceByID(workspaceID)
if err != nil {
http.Error(w, "Workspace not found", http.StatusNotFound)
return
}
if workspace.UserID != userID {
http.Error(w, "Unauthorized access to workspace", http.StatusForbidden)
return
}
respondJSON(w, workspace)
}
}
func UpdateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var workspace models.Workspace
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Set IDs from the request
workspace.ID = workspaceID
workspace.UserID = userID
// Validate the workspace
if err := workspace.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Get current workspace for comparison
currentWorkspace, err := db.GetWorkspaceByID(workspaceID)
if err != nil {
http.Error(w, "Workspace not found", http.StatusNotFound)
return
}
if currentWorkspace.UserID != userID {
http.Error(w, "Unauthorized access to workspace", http.StatusForbidden)
return
}
// Handle Git repository setup/teardown if Git settings changed
if workspace.GitEnabled != currentWorkspace.GitEnabled ||
(workspace.GitEnabled && (workspace.GitURL != currentWorkspace.GitURL ||
workspace.GitUser != currentWorkspace.GitUser ||
workspace.GitToken != currentWorkspace.GitToken)) {
if workspace.GitEnabled {
err = fs.SetupGitRepo(userID, workspaceID, workspace.GitURL, workspace.GitUser, workspace.GitToken)
if err != nil {
http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError)
return
}
} else {
fs.DisableGitRepo(userID, workspaceID)
}
}
if err := db.UpdateWorkspace(&workspace); err != nil {
http.Error(w, "Failed to update workspace", http.StatusInternalServerError)
return
}
respondJSON(w, workspace)
}
}
func DeleteWorkspace(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Check if this is the user's last workspace
workspaces, err := db.GetWorkspacesByUserID(userID)
if err != nil {
http.Error(w, "Failed to get workspaces", http.StatusInternalServerError)
return
}
if len(workspaces) <= 1 {
http.Error(w, "Cannot delete the last workspace", http.StatusBadRequest)
return
}
// Find another workspace to set as last
var nextWorkspaceID int
for _, ws := range workspaces {
if ws.ID != workspaceID {
nextWorkspaceID = ws.ID
break
}
}
// Start transaction
tx, err := db.Begin()
if err != nil {
http.Error(w, "Failed to start transaction", http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Update last workspace ID first
err = db.UpdateLastWorkspaceTx(tx, userID, nextWorkspaceID)
if err != nil {
http.Error(w, "Failed to update last workspace", http.StatusInternalServerError)
return
}
// Delete the workspace
err = db.DeleteWorkspaceTx(tx, workspaceID)
if err != nil {
http.Error(w, "Failed to delete workspace", http.StatusInternalServerError)
return
}
// Commit transaction
if err = tx.Commit(); err != nil {
http.Error(w, "Failed to commit transaction", http.StatusInternalServerError)
return
}
// Return the next workspace ID in the response so frontend knows where to redirect
respondJSON(w, map[string]int{"nextWorkspaceId": nextWorkspaceID})
}
}
func GetLastWorkspace(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := getUserID(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
workspaceID, err := db.GetLastWorkspaceID(userID)
if err != nil {
http.Error(w, "Failed to get last workspace", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]int{"lastWorkspaceId": workspaceID})
}
}
func UpdateLastWorkspace(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := getUserID(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var requestBody struct {
WorkspaceID int `json:"workspaceId"`
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := db.UpdateLastWorkspace(userID, requestBody.WorkspaceID); err != nil {
http.Error(w, "Failed to update last workspace", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "Last workspace updated successfully"})
}
}

View File

@@ -1,86 +0,0 @@
package config
import (
"fmt"
"os"
"path/filepath"
"novamd/internal/crypto"
)
type Config struct {
DBPath string
WorkDir string
StaticPath string
Port string
AdminEmail string
AdminPassword string
EncryptionKey string
}
func DefaultConfig() *Config {
return &Config{
DBPath: "./novamd.db",
WorkDir: "./data",
StaticPath: "../frontend/dist",
Port: "8080",
}
}
func (c *Config) Validate() error {
if c.AdminEmail == "" || c.AdminPassword == "" {
return fmt.Errorf("NOVAMD_ADMIN_EMAIL and NOVAMD_ADMIN_PASSWORD must be set")
}
// Validate encryption key
if err := crypto.ValidateKey(c.EncryptionKey); err != nil {
return fmt.Errorf("invalid NOVAMD_ENCRYPTION_KEY: %w", err)
}
return nil
}
// Load creates a new Config instance with values from environment variables
func Load() (*Config, error) {
config := DefaultConfig()
if dbPath := os.Getenv("NOVAMD_DB_PATH"); dbPath != "" {
config.DBPath = dbPath
}
if err := ensureDir(filepath.Dir(config.DBPath)); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
if workDir := os.Getenv("NOVAMD_WORKDIR"); workDir != "" {
config.WorkDir = workDir
}
if err := ensureDir(config.WorkDir); err != nil {
return nil, fmt.Errorf("failed to create work directory: %w", err)
}
if staticPath := os.Getenv("NOVAMD_STATIC_PATH"); staticPath != "" {
config.StaticPath = staticPath
}
if port := os.Getenv("NOVAMD_PORT"); port != "" {
config.Port = port
}
config.AdminEmail = os.Getenv("NOVAMD_ADMIN_EMAIL")
config.AdminPassword = os.Getenv("NOVAMD_ADMIN_PASSWORD")
config.EncryptionKey = os.Getenv("NOVAMD_ENCRYPTION_KEY")
// Validate all settings
if err := config.Validate(); err != nil {
return nil, err
}
return config, nil
}
func ensureDir(dir string) error {
if dir == "" {
return nil
}
return os.MkdirAll(dir, 0755)
}

View File

@@ -1,115 +0,0 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
)
var (
ErrKeyRequired = errors.New("encryption key is required")
ErrInvalidKeySize = errors.New("encryption key must be 32 bytes (256 bits) when decoded")
)
type Crypto struct {
key []byte
}
// ValidateKey checks if the provided key is suitable for AES-256
func ValidateKey(key string) error {
if key == "" {
return ErrKeyRequired
}
// Attempt to decode base64
keyBytes, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return fmt.Errorf("invalid base64 encoding: %w", err)
}
if len(keyBytes) != 32 {
return fmt.Errorf("%w: got %d bytes", ErrInvalidKeySize, len(keyBytes))
}
// Verify the key can be used for AES
_, err = aes.NewCipher(keyBytes)
if err != nil {
return fmt.Errorf("invalid encryption key: %w", err)
}
return nil
}
// New creates a new Crypto instance with the provided base64-encoded key
func New(key string) (*Crypto, error) {
if err := ValidateKey(key); err != nil {
return nil, err
}
keyBytes, _ := base64.StdEncoding.DecodeString(key)
return &Crypto{key: keyBytes}, nil
}
// Encrypt encrypts the plaintext using AES-256-GCM
func (c *Crypto) Encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
block, err := aes.NewCipher(c.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt decrypts the ciphertext using AES-256-GCM
func (c *Crypto) Decrypt(ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
}
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
block, err := aes.NewCipher(c.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}

View File

@@ -1,62 +0,0 @@
package db
import (
"database/sql"
"fmt"
"novamd/internal/crypto"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
*sql.DB
crypto *crypto.Crypto
}
func Init(dbPath string, encryptionKey string) (*DB, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
// Initialize crypto service
cryptoService, err := crypto.New(encryptionKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize encryption: %w", err)
}
database := &DB{
DB: db,
crypto: cryptoService,
}
if err := database.Migrate(); err != nil {
return nil, err
}
return database, nil
}
func (db *DB) Close() error {
return db.DB.Close()
}
// Helper methods for token encryption/decryption
func (db *DB) encryptToken(token string) (string, error) {
if token == "" {
return "", nil
}
return db.crypto.Encrypt(token)
}
func (db *DB) decryptToken(token string) (string, error) {
if token == "" {
return "", nil
}
return db.crypto.Decrypt(token)
}

View File

@@ -1,103 +0,0 @@
package db
import (
"fmt"
"log"
)
type Migration struct {
Version int
SQL string
}
var migrations = []Migration{
{
Version: 1,
SQL: `
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
display_name TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_workspace_id INTEGER
);
-- Create workspaces table with integrated settings
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_opened_file_path TEXT,
-- Settings fields
theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')),
auto_save BOOLEAN NOT NULL DEFAULT 0,
git_enabled BOOLEAN NOT NULL DEFAULT 0,
git_url TEXT,
git_user TEXT,
git_token TEXT,
git_auto_commit BOOLEAN NOT NULL DEFAULT 0,
git_commit_msg_template TEXT DEFAULT '${action} ${filename}',
FOREIGN KEY (user_id) REFERENCES users (id)
);
`,
},
}
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,154 +0,0 @@
package db
import (
"database/sql"
"novamd/internal/models"
)
func (db *DB) CreateUser(user *models.User) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.Exec(`
INSERT INTO users (email, display_name, password_hash, role)
VALUES (?, ?, ?, ?)`,
user.Email, user.DisplayName, user.PasswordHash, user.Role)
if err != nil {
return err
}
userID, err := result.LastInsertId()
if err != nil {
return err
}
user.ID = int(userID)
// Create default workspace with default settings
defaultWorkspace := &models.Workspace{
UserID: user.ID,
Name: "Main",
}
defaultWorkspace.GetDefaultSettings() // Initialize default settings
// Create workspace with settings
err = db.createWorkspaceTx(tx, defaultWorkspace)
if err != nil {
return err
}
// Update user's last workspace ID
_, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", defaultWorkspace.ID, user.ID)
if err != nil {
return err
}
err = tx.Commit()
if err != nil {
return err
}
user.LastWorkspaceID = defaultWorkspace.ID
return nil
}
func (db *DB) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error {
result, err := tx.Exec(`
INSERT INTO workspaces (
user_id, name,
theme, auto_save,
git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
workspace.UserID, workspace.Name,
workspace.Theme, workspace.AutoSave,
workspace.GitEnabled, workspace.GitURL, workspace.GitUser, workspace.GitToken,
workspace.GitAutoCommit, workspace.GitCommitMsgTemplate,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
workspace.ID = int(id)
return nil
}
func (db *DB) GetUserByID(id int) (*models.User, error) {
user := &models.User{}
err := db.QueryRow(`
SELECT
id, email, display_name, role, created_at,
last_workspace_id
FROM users
WHERE id = ?`, id).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &user.CreatedAt,
&user.LastWorkspaceID)
if err != nil {
return nil, err
}
return user, nil
}
func (db *DB) GetUserByEmail(email string) (*models.User, error) {
user := &models.User{}
err := db.QueryRow(`
SELECT
id, email, display_name, password_hash, role, created_at,
last_workspace_id
FROM users
WHERE email = ?`, email).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt,
&user.LastWorkspaceID)
if err != nil {
return nil, err
}
return user, nil
}
func (db *DB) UpdateUser(user *models.User) error {
_, err := db.Exec(`
UPDATE users
SET email = ?, display_name = ?, role = ?, last_workspace_id = ?
WHERE id = ?`,
user.Email, user.DisplayName, user.Role, user.LastWorkspaceID, user.ID)
return err
}
func (db *DB) UpdateLastWorkspace(userID, workspaceID int) error {
_, err := db.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID)
return err
}
func (db *DB) DeleteUser(id int) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Delete all user's workspaces first
_, err = tx.Exec("DELETE FROM workspaces WHERE user_id = ?", id)
if err != nil {
return err
}
// Delete the user
_, err = tx.Exec("DELETE FROM users WHERE id = ?", id)
if err != nil {
return err
}
return tx.Commit()
}
func (db *DB) GetLastWorkspaceID(userID int) (int, error) {
var workspaceID int
err := db.QueryRow("SELECT last_workspace_id FROM users WHERE id = ?", userID).Scan(&workspaceID)
return workspaceID, err
}

View File

@@ -1,209 +0,0 @@
package db
import (
"database/sql"
"fmt"
"novamd/internal/models"
)
func (db *DB) CreateWorkspace(workspace *models.Workspace) error {
// Set default settings if not provided
if workspace.Theme == "" {
workspace.GetDefaultSettings()
}
// Encrypt token if present
encryptedToken, err := db.encryptToken(workspace.GitToken)
if err != nil {
return fmt.Errorf("failed to encrypt token: %w", err)
}
result, err := db.Exec(`
INSERT INTO workspaces (
user_id, name, theme, auto_save,
git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
workspace.UserID, workspace.Name, workspace.Theme, workspace.AutoSave,
workspace.GitEnabled, workspace.GitURL, workspace.GitUser, encryptedToken,
workspace.GitAutoCommit, workspace.GitCommitMsgTemplate,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
workspace.ID = int(id)
return nil
}
func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) {
workspace := &models.Workspace{}
var encryptedToken string
err := db.QueryRow(`
SELECT
id, user_id, name, created_at,
theme, auto_save,
git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template
FROM workspaces
WHERE id = ?`,
id,
).Scan(
&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt,
&workspace.Theme, &workspace.AutoSave,
&workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken,
&workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate,
)
if err != nil {
return nil, err
}
// Decrypt token
workspace.GitToken, err = db.decryptToken(encryptedToken)
if err != nil {
return nil, fmt.Errorf("failed to decrypt token: %w", err)
}
return workspace, nil
}
func (db *DB) UpdateWorkspace(workspace *models.Workspace) error {
// Encrypt token before storing
encryptedToken, err := db.encryptToken(workspace.GitToken)
if err != nil {
return fmt.Errorf("failed to encrypt token: %w", err)
}
_, err = db.Exec(`
UPDATE workspaces
SET
name = ?,
theme = ?,
auto_save = ?,
git_enabled = ?,
git_url = ?,
git_user = ?,
git_token = ?,
git_auto_commit = ?,
git_commit_msg_template = ?
WHERE id = ? AND user_id = ?`,
workspace.Name,
workspace.Theme,
workspace.AutoSave,
workspace.GitEnabled,
workspace.GitURL,
workspace.GitUser,
encryptedToken,
workspace.GitAutoCommit,
workspace.GitCommitMsgTemplate,
workspace.ID,
workspace.UserID,
)
return err
}
func (db *DB) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) {
rows, err := db.Query(`
SELECT
id, user_id, name, created_at,
theme, auto_save,
git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template
FROM workspaces
WHERE user_id = ?`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var workspaces []*models.Workspace
for rows.Next() {
workspace := &models.Workspace{}
var encryptedToken string
err := rows.Scan(
&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt,
&workspace.Theme, &workspace.AutoSave,
&workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken,
&workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate,
)
if err != nil {
return nil, err
}
// Decrypt token
workspace.GitToken, err = db.decryptToken(encryptedToken)
if err != nil {
return nil, fmt.Errorf("failed to decrypt token: %w", err)
}
workspaces = append(workspaces, workspace)
}
return workspaces, nil
}
// UpdateWorkspaceSettings updates only the settings portion of a workspace
// This is useful when you don't want to modify the name or other core workspace properties
func (db *DB) UpdateWorkspaceSettings(workspace *models.Workspace) error {
_, err := db.Exec(`
UPDATE workspaces
SET
theme = ?,
auto_save = ?,
git_enabled = ?,
git_url = ?,
git_user = ?,
git_token = ?,
git_auto_commit = ?,
git_commit_msg_template = ?
WHERE id = ?`,
workspace.Theme,
workspace.AutoSave,
workspace.GitEnabled,
workspace.GitURL,
workspace.GitUser,
workspace.GitToken,
workspace.GitAutoCommit,
workspace.GitCommitMsgTemplate,
workspace.ID,
)
return err
}
func (db *DB) DeleteWorkspace(id int) error {
_, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id)
return err
}
func (db *DB) DeleteWorkspaceTx(tx *sql.Tx, id int) error {
_, err := tx.Exec("DELETE FROM workspaces WHERE id = ?", id)
return err
}
func (db *DB) UpdateLastWorkspaceTx(tx *sql.Tx, userID, workspaceID int) error {
_, err := tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID)
return err
}
func (db *DB) UpdateLastOpenedFile(workspaceID int, filePath string) error {
_, err := db.Exec("UPDATE workspaces SET last_opened_file_path = ? WHERE id = ?", filePath, workspaceID)
return err
}
func (db *DB) GetLastOpenedFile(workspaceID int) (string, error) {
var filePath sql.NullString
err := db.QueryRow("SELECT last_opened_file_path FROM workspaces WHERE id = ?", workspaceID).Scan(&filePath)
if err != nil {
return "", err
}
if !filePath.Valid {
return "", nil
}
return filePath.String, nil
}

View File

@@ -1,249 +0,0 @@
package filesystem
import (
"errors"
"fmt"
"novamd/internal/gitutils"
"os"
"path/filepath"
"sort"
"strings"
)
type FileSystem struct {
RootDir string
GitRepos map[int]map[int]*gitutils.GitRepo // map[userID]map[workspaceID]*gitutils.GitRepo
}
type FileNode struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Children []FileNode `json:"children,omitempty"`
}
func New(rootDir string) *FileSystem {
return &FileSystem{
RootDir: rootDir,
GitRepos: make(map[int]map[int]*gitutils.GitRepo),
}
}
func (fs *FileSystem) GetWorkspacePath(userID, workspaceID int) string {
return filepath.Join(fs.RootDir, fmt.Sprintf("%d", userID), fmt.Sprintf("%d", workspaceID))
}
func (fs *FileSystem) InitializeUserWorkspace(userID, workspaceID int) error {
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
err := os.MkdirAll(workspacePath, 0755)
if err != nil {
return fmt.Errorf("failed to create workspace directory: %w", err)
}
return nil
}
func (fs *FileSystem) DeleteUserWorkspace(userID, workspaceID int) error {
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
err := os.RemoveAll(workspacePath)
if err != nil {
return fmt.Errorf("failed to delete workspace directory: %w", err)
}
return nil
}
func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string, error) {
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
fullPath := filepath.Join(workspacePath, path)
cleanPath := filepath.Clean(fullPath)
if !strings.HasPrefix(cleanPath, workspacePath) {
return "", fmt.Errorf("invalid path: outside of workspace")
}
return cleanPath, nil
}
func (fs *FileSystem) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) {
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
return fs.walkDirectory(workspacePath, "")
}
func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
// Split entries into directories and files
var dirs, files []os.DirEntry
for _, entry := range entries {
if entry.IsDir() {
dirs = append(dirs, entry)
} else {
files = append(files, entry)
}
}
// Sort directories and files separately
sort.Slice(dirs, func(i, j int) bool {
return strings.ToLower(dirs[i].Name()) < strings.ToLower(dirs[j].Name())
})
sort.Slice(files, func(i, j int) bool {
return strings.ToLower(files[i].Name()) < strings.ToLower(files[j].Name())
})
// Create combined slice with directories first, then files
nodes := make([]FileNode, 0, len(entries))
// Add directories first
for _, entry := range dirs {
name := entry.Name()
path := filepath.Join(prefix, name)
fullPath := filepath.Join(dir, name)
children, err := fs.walkDirectory(fullPath, path)
if err != nil {
return nil, err
}
node := FileNode{
ID: path,
Name: name,
Path: path,
Children: children,
}
nodes = append(nodes, node)
}
// Then add files
for _, entry := range files {
name := entry.Name()
path := filepath.Join(prefix, name)
node := FileNode{
ID: path,
Name: name,
Path: path,
}
nodes = append(nodes, node)
}
return nodes, nil
}
func (fs *FileSystem) FindFileByName(userID, workspaceID int, filename string) ([]string, error) {
var foundPaths []string
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
relPath, err := filepath.Rel(workspacePath, path)
if err != nil {
return err
}
if strings.EqualFold(info.Name(), filename) {
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(userID, workspaceID int, filePath string) ([]byte, error) {
fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
if err != nil {
return nil, err
}
return os.ReadFile(fullPath)
}
func (fs *FileSystem) SaveFile(userID, workspaceID int, filePath string, content []byte) error {
fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
if err != nil {
return err
}
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(fullPath, content, 0644)
}
func (fs *FileSystem) DeleteFile(userID, workspaceID int, filePath string) error {
fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
if err != nil {
return err
}
return os.Remove(fullPath)
}
func (fs *FileSystem) CreateWorkspaceDirectory(userID, workspaceID int) error {
dir := fs.GetWorkspacePath(userID, workspaceID)
return os.MkdirAll(dir, 0755)
}
func (fs *FileSystem) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken string) error {
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
if _, ok := fs.GitRepos[userID]; !ok {
fs.GitRepos[userID] = make(map[int]*gitutils.GitRepo)
}
fs.GitRepos[userID][workspaceID] = gitutils.New(gitURL, gitUser, gitToken, workspacePath)
return fs.GitRepos[userID][workspaceID].EnsureRepo()
}
func (fs *FileSystem) DisableGitRepo(userID, workspaceID int) {
if userRepos, ok := fs.GitRepos[userID]; ok {
delete(userRepos, workspaceID)
if len(userRepos) == 0 {
delete(fs.GitRepos, userID)
}
}
}
func (fs *FileSystem) StageCommitAndPush(userID, workspaceID int, message string) error {
repo, ok := fs.getGitRepo(userID, workspaceID)
if !ok {
return errors.New("git settings not configured for this workspace")
}
if err := repo.Commit(message); err != nil {
return err
}
return repo.Push()
}
func (fs *FileSystem) Pull(userID, workspaceID int) error {
repo, ok := fs.getGitRepo(userID, workspaceID)
if !ok {
return errors.New("git settings not configured for this workspace")
}
return repo.Pull()
}
func (fs *FileSystem) getGitRepo(userID, workspaceID int) (*gitutils.GitRepo, bool) {
userRepos, ok := fs.GitRepos[userID]
if !ok {
return nil, false
}
repo, ok := userRepos[workspaceID]
return repo, ok
}

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,115 +0,0 @@
package user
import (
"database/sql"
"fmt"
"log"
"golang.org/x/crypto/bcrypt"
"novamd/internal/db"
"novamd/internal/filesystem"
"novamd/internal/models"
)
type UserService struct {
DB *db.DB
FS *filesystem.FileSystem
}
func NewUserService(database *db.DB, fs *filesystem.FileSystem) *UserService {
return &UserService{
DB: database,
FS: fs,
}
}
func (s *UserService) SetupAdminUser(adminEmail, adminPassword string) (*models.User, error) {
// Check if admin user exists
adminUser, err := s.DB.GetUserByEmail(adminEmail)
if adminUser != nil {
return adminUser, nil // Admin user already exists
} else if err != sql.ErrNoRows {
return nil, err
}
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Create admin user
adminUser = &models.User{
Email: adminEmail,
DisplayName: "Admin",
PasswordHash: string(hashedPassword),
Role: models.RoleAdmin,
}
err = s.DB.CreateUser(adminUser)
if err != nil {
return nil, fmt.Errorf("failed to create admin user: %w", err)
}
// Initialize workspace directory
err = s.FS.InitializeUserWorkspace(adminUser.ID, adminUser.LastWorkspaceID)
if err != nil {
return nil, fmt.Errorf("failed to initialize admin workspace: %w", err)
}
log.Printf("Created admin user with ID: %d and default workspace with ID: %d", adminUser.ID, adminUser.LastWorkspaceID)
return adminUser, nil
}
func (s *UserService) CreateUser(user *models.User) error {
err := s.DB.CreateUser(user)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
err = s.FS.InitializeUserWorkspace(user.ID, user.LastWorkspaceID)
if err != nil {
return fmt.Errorf("failed to initialize user workspace: %w", err)
}
return nil
}
func (s *UserService) GetUserByID(id int) (*models.User, error) {
return s.DB.GetUserByID(id)
}
func (s *UserService) GetUserByEmail(email string) (*models.User, error) {
return s.DB.GetUserByEmail(email)
}
func (s *UserService) UpdateUser(user *models.User) error {
return s.DB.UpdateUser(user)
}
func (s *UserService) DeleteUser(id int) error {
// First, get the user to check if they exist
user, err := s.DB.GetUserByID(id)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
// Get user's workspaces
workspaces, err := s.DB.GetWorkspacesByUserID(id)
if err != nil {
return fmt.Errorf("failed to get user's workspaces: %w", err)
}
// Delete workspace directories
for _, workspace := range workspaces {
err = s.FS.DeleteUserWorkspace(user.ID, workspace.ID)
if err != nil {
return fmt.Errorf("failed to delete workspace files: %w", err)
}
}
// Delete user from database (this will cascade delete workspaces)
return s.DB.DeleteUser(id)
}

View File

@@ -1,21 +0,0 @@
import React from 'react';
import { Group, Text, Avatar } from '@mantine/core';
import WorkspaceSwitcher from './WorkspaceSwitcher';
import Settings from './Settings';
const Header = () => {
return (
<Group justify="space-between" h={60} px="md">
<Text fw={700} size="lg">
NovaMD
</Text>
<Group>
<WorkspaceSwitcher />
<Avatar src="https://via.placeholder.com/40" radius="xl" />
</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,21 +0,0 @@
import React from 'react';
import { Text, Switch, Tooltip, Group, Box, Title } from '@mantine/core';
const EditorSettings = ({ autoSave, onAutoSaveChange }) => {
return (
<Box mb="md">
<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,178 +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 fetchLastWorkspaceId = async () => {
const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`);
return response.json();
};
export const fetchFileList = async (workspaceId) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files`
);
return response.json();
};
export const fetchFileContent = async (workspaceId, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`
);
return response.text();
};
export const saveFileContent = async (workspaceId, filePath, content) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`,
{
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
body: content,
}
);
return response.text();
};
export const deleteFile = async (workspaceId, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`,
{
method: 'DELETE',
}
);
return response.text();
};
export const getWorkspace = async (workspaceId) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}`
);
return response.json();
};
// Combined function to update workspace data including settings
export const updateWorkspace = async (workspaceId, workspaceData) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(workspaceData),
}
);
return response.json();
};
export const pullChanges = async (workspaceId) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/git/pull`,
{
method: 'POST',
}
);
return response.json();
};
export const commitAndPush = async (workspaceId, message) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/git/commit`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message }),
}
);
return response.json();
};
export const getFileUrl = (workspaceId, filePath) => {
return `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`;
};
export const lookupFileByName = async (workspaceId, filename) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/lookup?filename=${encodeURIComponent(
filename
)}`
);
const data = await response.json();
return data.paths;
};
export const updateLastOpenedFile = async (workspaceId, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/last`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filePath }),
}
);
return response.json();
};
export const getLastOpenedFile = async (workspaceId) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/last`
);
return response.json();
};
export const listWorkspaces = async () => {
const response = await apiCall(`${API_BASE_URL}/users/1/workspaces`);
return response.json();
};
export const createWorkspace = async (name) => {
const response = await apiCall(`${API_BASE_URL}/users/1/workspaces`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
});
return response.json();
};
export const deleteWorkspace = async (workspaceId) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}`,
{
method: 'DELETE',
}
);
return response.json();
};
export const updateLastWorkspace = async (workspaceId) => {
const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ workspaceId }),
});
return response.json();
};

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

1164
server/documentation.md Normal file

File diff suppressed because it is too large Load Diff

34
server/gendocs.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
set -euo pipefail
generate_anchor() {
echo "$1" | tr '/' '-'
}
echo "# Lemma Package Documentation"
echo ""
echo "Generated documentation for all packages in the Lemma project."
echo ""
echo "## Table of Contents"
PACKAGES=$(find . -type f -name "*.go" ! -name "*_test.go" -exec dirname {} \; | sort -u | grep -v "/\.")
for PKG in $PACKAGES; do
PKG_PATH=${PKG#./}
[ -z "$PKG_PATH" ] && continue
ANCHOR=$(generate_anchor "$PKG_PATH")
echo "- [$PKG_PATH](#$ANCHOR)"
done
echo ""
for PKG in $PACKAGES; do
PKG_PATH=${PKG#./}
[ -z "$PKG_PATH" ] && continue
echo "## $PKG_PATH"
echo ""
echo '```go'
go doc -all "./$PKG_PATH" | cat
echo '```'
echo ""
done

View File

@@ -1,39 +1,61 @@
module novamd
module lemma
go 1.23.1
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1
github.com/go-chi/httprate v0.14.1
github.com/go-git/go-git/v5 v5.12.0
github.com/go-playground/validator/v10 v10.22.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.23
golang.org/x/crypto v0.21.0
github.com/stretchr/testify v1.9.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
github.com/unrolled/secure v1.17.0
golang.org/x/crypto v0.31.0
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/spec v0.20.6 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.13.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

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