mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-07 08:24:27 +00:00
Compare commits
218 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c450b4ae2f | |||
| eb98f801a1 | |||
| f1f622cda3 | |||
| 5079f3c96d | |||
| 2861b1a719 | |||
| 9bc1b2ecd4 | |||
| 339ab657b8 | |||
| 28d2c053a8 | |||
| b6b4c01f0e | |||
| 5598c0861d | |||
| f0b6aa0d6e | |||
| cf2e1809a4 | |||
| b065938211 | |||
| 0aa67f5cc2 | |||
| f6de4fb839 | |||
| 7ccd36f0e4 | |||
| 54cefac4b7 | |||
| 032ae1354c | |||
| 03e78c3e6b | |||
| b00f01f033 | |||
| 51004d980d | |||
| e7a48fcd27 | |||
| d8528d1b42 | |||
| 3edce8a0b9 | |||
| d6680d8e03 | |||
| 8dd57bdf0b | |||
| a32e0957ed | |||
| ab00276f0d | |||
| d14eae4de4 | |||
| 71df436a93 | |||
| 1ee8d94789 | |||
| 9d82b6426c | |||
|
|
6280586aaf | ||
| ea916c3ecc | |||
| 3d03da221b | |||
| 26a208123a | |||
| 8a62c9e306 | |||
| a7a97caa6a | |||
| 8ebbe1e076 | |||
| ed6ac312ed | |||
| 2268ea48f2 | |||
| 69af630332 | |||
| 2e1ccb45d6 | |||
| 5633406f5c | |||
| ad4af2f82d | |||
| 8a4508e29f | |||
| de9e9102db | |||
| b4528c1561 | |||
| 1c8e16fc80 | |||
| bd90cf3813 | |||
| 836e233954 | |||
| 3d36c67c90 | |||
| dc8fc6c225 | |||
| e413e955c5 | |||
| c400d81c87 | |||
| 08e76671d0 | |||
| 7d74821f29 | |||
| 66415738b6 | |||
| dff66ab1b4 | |||
| 05933f8c22 | |||
| 68ad70b0b7 | |||
| d5d2792689 | |||
| 31d00681a1 | |||
| 4359267d55 | |||
| c07f19bbb3 | |||
| 325cedc235 | |||
| 453c022959 | |||
| de2a9364ab | |||
| cfa048b8de | |||
| 842513f8a5 | |||
| ae48761d34 | |||
| 8bed3614ee | |||
| 2a53be5a6e | |||
| af9ab42969 | |||
| d47b601447 | |||
| 1ddf93a8be | |||
| 6aa3fd6c65 | |||
| 9b4db528ca | |||
| f5d616fe00 | |||
| 51ed9e53a4 | |||
| 3fb40a8817 | |||
| 91489ca633 | |||
| fbb8fa3a60 | |||
| 4ddf1f570f | |||
| e8868dde39 | |||
| 32bd202d6f | |||
| 9ac047d440 | |||
| 1e7cd0934e | |||
| 9d81b1036d | |||
| 9f241271a7 | |||
| 8f2f8b30dd | |||
| 1150c4ba39 | |||
| ebdd7bd741 | |||
| b3ec4e136c | |||
| 807e96a76c | |||
| 2faefb6db5 | |||
| 435dce89d9 | |||
| 6cb5aec372 | |||
| 7396b57a5d | |||
| 53e52bfdb5 | |||
| de2c9a6d0c | |||
| 2fe642ac61 | |||
| 408746187e | |||
| e4510298ed | |||
| 5311d2e144 | |||
| 6a9461d928 | |||
| 93963b1867 | |||
| 52ffb17e2d | |||
| fb1c9a499f | |||
| f4c21edca0 | |||
| 1b58b693d0 | |||
| d11525732d | |||
| 03cdb133e7 | |||
| bac4702771 | |||
| f3f3cb7371 | |||
| d4c671caa7 | |||
| 29b35f6b91 | |||
| e275b45c86 | |||
| 77d9abb691 | |||
| 8cf850a62c | |||
| 5e2d434b4b | |||
| 148001be43 | |||
| 33bc28560f | |||
| 118591df62 | |||
| 7b1da94e8a | |||
| 9688b2d528 | |||
| ebf32e775c | |||
| dd3ea9f65f | |||
| 51751a5af6 | |||
| 0480c165ae | |||
| 24f877e50b | |||
| adf5287db2 | |||
| 0f6dcd3a60 | |||
| 7f8c40c3a2 | |||
| 64029615ea | |||
| 1a14c06be2 | |||
| 48f75b3839 | |||
| e56378f1f0 | |||
| 505b93ff09 | |||
| 8b8bfaa8c8 | |||
| 9581e32e06 | |||
| 771650d66e | |||
| 69afef15ec | |||
| 9cdbf9fec8 | |||
| fae628c02b | |||
| 927d1feb05 | |||
| c8cc854fd6 | |||
| dfd9544fba | |||
| 72680abdf4 | |||
| 46eeb18a31 | |||
| 34868c53eb | |||
| be0f97ab24 | |||
| ce245c980a | |||
| ea91d8d608 | |||
| 0b0a5253f0 | |||
| f8cd11c9ac | |||
| 0ed2813643 | |||
| 3c855fce21 | |||
| a5aa2dd45b | |||
| 43f35b7943 | |||
| f8728923a5 | |||
| 72817aa06a | |||
| a11a6b18bf | |||
| 0fe1186ea6 | |||
| 477ada70e8 | |||
| a73296451e | |||
| bef682e5cb | |||
| 1086c402ec | |||
| a62a6f8966 | |||
| 3ba9d57b11 | |||
| 91f0ebe99c | |||
| ad3fa28bc7 | |||
| 1695b411e1 | |||
| 87a2f12766 | |||
| d82f7e2b1e | |||
| d417b9f0c7 | |||
| 64a86152d6 | |||
| 69506f739c | |||
| 012465fb02 | |||
| 552016f61d | |||
| f1b5027e7f | |||
| 239b441aa6 | |||
| ba4a0dadca | |||
| b679af08e7 | |||
| c5e0937070 | |||
| 17c03c2d14 | |||
| 4544af8f0f | |||
| ab7b018f88 | |||
| eaad78730e | |||
| bbd7358d15 | |||
| 12312137b7 | |||
| fd313c1d7f | |||
| 05dd3a83b0 | |||
| a231fc48c2 | |||
| 4ade504b5b | |||
| 1c59f8da4f | |||
| ffe82b335a | |||
| 749461f11b | |||
| 2f87d87833 | |||
| 6eb3eecb24 | |||
| 3b7bf83073 | |||
| 1df4952300 | |||
| a24f0d637c | |||
| 68ec134bf5 | |||
| 403ded509a | |||
| 6cf141bfd9 | |||
| 071619cfb3 | |||
| d440ac0fd7 | |||
| 4953138154 | |||
| cbdf51db05 | |||
| 18bc50f5b4 | |||
| 97ebf1c08e | |||
| 2d2b596f2c | |||
| b36c5b30c6 | |||
| 903393953a | |||
| b6e8a93d86 | |||
| da5a3ebeee | |||
| 1341881968 |
4
.github/workflows/build-and-release.yml
vendored
4
.github/workflows/build-and-release.yml
vendored
@@ -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
34
.github/workflows/go-test.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Go Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
cache: true
|
||||
|
||||
- name: Run Tests
|
||||
run: go test -tags=test,integration ./... -v
|
||||
|
||||
- name: Run Tests with Race Detector
|
||||
run: go test -tags=test,integration -race ./... -v
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -129,6 +129,9 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Vite
|
||||
.vite
|
||||
|
||||
|
||||
##### Go #####
|
||||
|
||||
@@ -154,7 +157,8 @@ go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
.env.dev
|
||||
|
||||
main
|
||||
*.db
|
||||
data
|
||||
data
|
||||
|
||||
14
.vscode/launch.json
vendored
Normal file
14
.vscode/launch.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -14,14 +14,17 @@
|
||||
"go.lintTool": "golangci-lint",
|
||||
"go.lintOnSave": "package",
|
||||
"go.formatTool": "goimports",
|
||||
"go.testFlags": ["-tags=test,integration"],
|
||||
"[go]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
},
|
||||
"editor.defaultFormatter": "golang.go"
|
||||
},
|
||||
"gopls": {
|
||||
"usePlaceholders": true,
|
||||
"staticcheck": true
|
||||
"staticcheck": true,
|
||||
"buildFlags": ["-tags", "test,integration"]
|
||||
}
|
||||
}
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@@ -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"]
|
||||
83
README.md
83
README.md
@@ -1,4 +1,6 @@
|
||||
# NovaMD
|
||||
# Lemma
|
||||
|
||||
 
|
||||
|
||||
Yet another markdown editor. Work in progress
|
||||
|
||||
@@ -8,7 +10,8 @@ Yet another markdown editor. Work in progress
|
||||
- File tree navigation
|
||||
- Git integration for version control
|
||||
- Dark and light theme support
|
||||
- Math equation support (KaTeX)
|
||||
- Multiple workspaces
|
||||
- Math equation support (MathJax)
|
||||
- Code syntax highlighting
|
||||
|
||||
## Prerequisites
|
||||
@@ -17,23 +20,55 @@ Yet another markdown editor. Work in progress
|
||||
- Node.js 20 or later
|
||||
- gcc (for go-sqlite3 package)
|
||||
|
||||
## Running the Backend
|
||||
## Configuration
|
||||
|
||||
1. Navigate to the `backend` directory
|
||||
2. Set the following environment variables:
|
||||
- `CGO_ENABLED=1`: This is necessary for the go-sqlite3 package
|
||||
- `NOVAMD_DB_PATH`: Path to the SQLite database file (default: "./sqlite.db")
|
||||
- `NOVAMD_WORKDIR`: Directory for storing Markdown files (default: "./data")
|
||||
- `NOVAMD_STATIC_PATH`: Path to the frontend build files (default: "../frontend/dist")
|
||||
- `NOVAMD_PORT`: Port to run the server on (default: "8080")
|
||||
3. Run the server:
|
||||
Lemma can be configured using environment variables. Here are the available configuration options:
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
- `LEMMA_ADMIN_EMAIL`: Email address for the admin account
|
||||
- `LEMMA_ADMIN_PASSWORD`: Password for the admin account
|
||||
- `LEMMA_ENCRYPTION_KEY`: Base64-encoded 32-byte key used for encrypting sensitive data
|
||||
|
||||
### Optional Environment Variables
|
||||
|
||||
- `LEMMA_ENV`: Set to "development" to enable development mode
|
||||
- `LEMMA_DB_PATH`: Path to the SQLite database file (default: "./lemma.db")
|
||||
- `LEMMA_WORKDIR`: Working directory for application data (default: "./data")
|
||||
- `LEMMA_STATIC_PATH`: Path to static files (default: "../app/dist")
|
||||
- `LEMMA_PORT`: Port to run the server on (default: "8080")
|
||||
- `LEMMA_DOMAIN`: Domain name where the application is hosted for cookie authentication
|
||||
- `LEMMA_CORS_ORIGINS`: Comma-separated list of allowed CORS origins
|
||||
- `LEMMA_JWT_SIGNING_KEY`: Key used for signing JWT tokens
|
||||
- `LEMMA_LOG_LEVEL`: Logging level (defaults to DEBUG in development mode, INFO in production)
|
||||
- `LEMMA_RATE_LIMIT_REQUESTS`: Number of allowed requests per window (default: 100)
|
||||
- `LEMMA_RATE_LIMIT_WINDOW`: Duration of the rate limit window (default: 15m)
|
||||
|
||||
### Generating Encryption Keys
|
||||
|
||||
The encryption key must be a base64-encoded 32-byte value. You can generate a secure encryption key using OpenSSL:
|
||||
|
||||
```bash
|
||||
# Generate a random 32-byte key and encode it as base64
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
Store the generated key securely - it will be needed to decrypt any data encrypted by the application. If the key is lost or changed, previously encrypted data will become inaccessible.
|
||||
|
||||
## Running the backend server
|
||||
|
||||
1. Navigate to the `server` directory
|
||||
2. Install dependecies: `go mod tidy`
|
||||
3. Ensure all environment variables are set
|
||||
4. Additionally set `CGO_ENABLED=1` (needed for sqlite3)
|
||||
5. Run the server:
|
||||
```
|
||||
go run cmd/server/main.go
|
||||
```
|
||||
|
||||
## Running the Frontend
|
||||
## Running the frontend app
|
||||
|
||||
1. Navigate to the `frontend` directory
|
||||
1. Navigate to the `app` directory
|
||||
2. Install dependencies:
|
||||
```
|
||||
npm install
|
||||
@@ -44,20 +79,20 @@ Yet another markdown editor. Work in progress
|
||||
```
|
||||
The frontend will be available at `http://localhost:3000`
|
||||
|
||||
## Building for Production
|
||||
## Building for production
|
||||
|
||||
1. Build the frontend:
|
||||
1. Build the frontend app:
|
||||
```
|
||||
cd frontend
|
||||
cd app
|
||||
npm run build
|
||||
```
|
||||
2. Build the backend:
|
||||
```
|
||||
cd backend
|
||||
go build -o novamd ./cmd/server
|
||||
cd server
|
||||
go build -o lemma ./cmd/server
|
||||
```
|
||||
3. Set the `NOVAMD_STATIC_PATH` environment variable to point to the frontend build directory
|
||||
4. Run the `novamd` executable
|
||||
3. Set the `LEMMA_STATIC_PATH` environment variable to point to the frontend build directory
|
||||
4. Run the `lemma` executable
|
||||
|
||||
## Docker Support
|
||||
|
||||
@@ -65,9 +100,13 @@ A Dockerfile is provided for easy deployment. To build and run the Docker image:
|
||||
|
||||
1. Build the image:
|
||||
```
|
||||
docker build -t novamd .
|
||||
docker build -t lemma .
|
||||
```
|
||||
2. Run the container:
|
||||
```
|
||||
docker run -p 8080:8080 -v /path/to/data:/app/data novamd
|
||||
docker run -p 8080:8080 -v /path/to/data:/app/data lemma
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
Before first stable release (1.0.0) there is not upgrade path. You have to delete the database file and start over.
|
||||
|
||||
6060
app/package-lock.json
generated
Normal file
6060
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "novamd-frontend",
|
||||
"name": "lemma-frontend",
|
||||
"version": "0.1.0",
|
||||
"description": "Yet another markdown editor",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "webpack serve --mode development --open",
|
||||
"build": "webpack --mode production"
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/LordMathis/NovaMD.git"
|
||||
"url": "git+https://github.com/LordMathis/Lemma.git"
|
||||
},
|
||||
"keywords": [
|
||||
"markdown",
|
||||
@@ -18,9 +19,9 @@
|
||||
"author": "Matúš Námešný",
|
||||
"license": "Apache-2.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/LordMathis/NovaMD/issues"
|
||||
"url": "https://github.com/LordMathis/Lemma/issues"
|
||||
},
|
||||
"homepage": "https://github.com/LordMathis/NovaMD#readme",
|
||||
"homepage": "https://github.com/LordMathis/Lemma#readme",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.6.2",
|
||||
"@codemirror/lang-markdown": "^6.2.5",
|
||||
@@ -34,20 +35,30 @@
|
||||
"@react-hook/resize-observer": "^2.0.2",
|
||||
"@tabler/icons-react": "^3.19.0",
|
||||
"codemirror": "^6.0.1",
|
||||
"katex": "^0.16.11",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "^3.4.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-math": "^6.0.0"
|
||||
"rehype-mathjax": "^6.0.0",
|
||||
"rehype-prism": "^2.3.3",
|
||||
"rehype-react": "^8.0.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.67",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"sass": "^1.80.4",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-compression2": "^1.3.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -60,26 +71,5 @@
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.7",
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.7",
|
||||
"@babel/plugin-transform-runtime": "^7.25.7",
|
||||
"@babel/preset-env": "^7.25.7",
|
||||
"@babel/preset-react": "^7.25.7",
|
||||
"babel-loader": "^9.2.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"sass": "^1.79.4",
|
||||
"sass-loader": "^16.0.2",
|
||||
"style-loader": "^4.0.0",
|
||||
"webpack": "^5.94.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,37 @@ import React from 'react';
|
||||
import { MantineProvider, ColorSchemeScript } from '@mantine/core';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import Layout from './components/Layout';
|
||||
import { SettingsProvider, useSettings } from './contexts/SettingsContext';
|
||||
import Layout from './components/layout/Layout';
|
||||
import LoginPage from './components/auth/LoginPage';
|
||||
import { WorkspaceProvider } from './contexts/WorkspaceContext';
|
||||
import { ModalProvider } from './contexts/ModalContext';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import './App.scss';
|
||||
|
||||
function AppContent() {
|
||||
const { loading } = useSettings();
|
||||
function AuthenticatedContent() {
|
||||
const { user, loading, initialized } = useAuth();
|
||||
|
||||
if (!initialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return <Layout />;
|
||||
if (!user) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceProvider>
|
||||
<ModalProvider>
|
||||
<Layout />
|
||||
</ModalProvider>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
@@ -26,11 +42,9 @@ function App() {
|
||||
<MantineProvider defaultColorScheme="light">
|
||||
<Notifications />
|
||||
<ModalsProvider>
|
||||
<SettingsProvider>
|
||||
<ModalProvider>
|
||||
<AppContent />
|
||||
</ModalProvider>
|
||||
</SettingsProvider>
|
||||
<AuthProvider>
|
||||
<AuthenticatedContent />
|
||||
</AuthProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</>
|
||||
66
app/src/components/auth/LoginPage.jsx
Normal file
66
app/src/components/auth/LoginPage.jsx
Normal 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;
|
||||
@@ -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} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,10 +5,10 @@ import { EditorView, keymap } from '@codemirror/view';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
|
||||
const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
||||
const { settings } = useSettings();
|
||||
const { colorScheme } = useWorkspace();
|
||||
const editorRef = useRef();
|
||||
const viewRef = useRef();
|
||||
|
||||
@@ -27,12 +27,12 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: settings.theme === 'dark' ? '#1e1e1e' : '#f5f5f5',
|
||||
color: settings.theme === 'dark' ? '#858585' : '#999',
|
||||
backgroundColor: colorScheme === 'dark' ? '#1e1e1e' : '#f5f5f5',
|
||||
color: colorScheme === 'dark' ? '#858585' : '#999',
|
||||
border: 'none',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: settings.theme === 'dark' ? '#2c313a' : '#e8e8e8',
|
||||
backgroundColor: colorScheme === 'dark' ? '#2c313a' : '#e8e8e8',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
||||
}
|
||||
}),
|
||||
theme,
|
||||
settings.theme === 'dark' ? oneDark : [],
|
||||
colorScheme === 'dark' ? oneDark : [],
|
||||
],
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
||||
return () => {
|
||||
view.destroy();
|
||||
};
|
||||
}, [settings.theme, handleContentChange]);
|
||||
}, [colorScheme, handleContentChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {
|
||||
111
app/src/components/editor/MarkdownPreview.jsx
Normal file
111
app/src/components/editor/MarkdownPreview.jsx
Normal 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;
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
IconGitPullRequest,
|
||||
IconGitCommit,
|
||||
} from '@tabler/icons-react';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
import { useModalContext } from '../contexts/ModalContext';
|
||||
import { useModalContext } from '../../contexts/ModalContext';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
|
||||
const FileActions = ({ handlePullChanges, selectedFile }) => {
|
||||
const { settings } = useSettings();
|
||||
const { settings } = useWorkspace();
|
||||
const {
|
||||
setNewFileModalVisible,
|
||||
setDeleteFileModalVisible,
|
||||
@@ -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}
|
||||
22
app/src/components/layout/Header.jsx
Normal file
22
app/src/components/layout/Header.jsx
Normal 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;
|
||||
@@ -1,13 +1,28 @@
|
||||
import React from 'react';
|
||||
import { AppShell, Container } from '@mantine/core';
|
||||
import { AppShell, Container, Loader, Center } from '@mantine/core';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
import MainContent from './MainContent';
|
||||
import { useFileNavigation } from '../hooks/useFileNavigation';
|
||||
import { useFileNavigation } from '../../hooks/useFileNavigation';
|
||||
import { useFileList } from '../../hooks/useFileList';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
|
||||
const Layout = () => {
|
||||
const { selectedFile, handleFileSelect, handleLinkClick } =
|
||||
useFileNavigation();
|
||||
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
|
||||
const { selectedFile, handleFileSelect } = useFileNavigation();
|
||||
const { files, loadFileList } = useFileList();
|
||||
|
||||
if (workspaceLoading) {
|
||||
return (
|
||||
<Center style={{ height: '100vh' }}>
|
||||
<Loader size="xl" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentWorkspace) {
|
||||
return <div>No workspace found. Please create a workspace.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell header={{ height: 60 }} padding="md">
|
||||
@@ -27,11 +42,13 @@ const Layout = () => {
|
||||
<Sidebar
|
||||
selectedFile={selectedFile}
|
||||
handleFileSelect={handleFileSelect}
|
||||
files={files}
|
||||
loadFileList={loadFileList}
|
||||
/>
|
||||
<MainContent
|
||||
selectedFile={selectedFile}
|
||||
handleFileSelect={handleFileSelect}
|
||||
handleLinkClick={handleLinkClick}
|
||||
loadFileList={loadFileList}
|
||||
/>
|
||||
</Container>
|
||||
</AppShell.Main>
|
||||
@@ -2,19 +2,19 @@ import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Tabs, Breadcrumbs, Group, Box, Text, Flex } from '@mantine/core';
|
||||
import { IconCode, IconEye, IconPointFilled } from '@tabler/icons-react';
|
||||
|
||||
import ContentView from './ContentView';
|
||||
import CreateFileModal from './modals/CreateFileModal';
|
||||
import DeleteFileModal from './modals/DeleteFileModal';
|
||||
import CommitMessageModal from './modals/CommitMessageModal';
|
||||
import ContentView from '../editor/ContentView';
|
||||
import CreateFileModal from '../modals/file/CreateFileModal';
|
||||
import DeleteFileModal from '../modals/file/DeleteFileModal';
|
||||
import CommitMessageModal from '../modals/git/CommitMessageModal';
|
||||
|
||||
import { useFileContent } from '../hooks/useFileContent';
|
||||
import { useFileOperations } from '../hooks/useFileOperations';
|
||||
import { useGitOperations } from '../hooks/useGitOperations';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
import { useFileContent } from '../../hooks/useFileContent';
|
||||
import { useFileOperations } from '../../hooks/useFileOperations';
|
||||
import { useGitOperations } from '../../hooks/useGitOperations';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
|
||||
const MainContent = ({ selectedFile, handleFileSelect, handleLinkClick }) => {
|
||||
const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
|
||||
const [activeTab, setActiveTab] = useState('source');
|
||||
const { settings } = useSettings();
|
||||
const { settings } = useWorkspace();
|
||||
const {
|
||||
content,
|
||||
hasUnsavedChanges,
|
||||
@@ -33,38 +33,32 @@ const MainContent = ({ selectedFile, handleFileSelect, handleLinkClick }) => {
|
||||
let success = await handleSave(filePath, content);
|
||||
if (success) {
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
if (settings.gitAutoCommit && settings.gitEnabled) {
|
||||
const commitMessage = settings.gitCommitMsgTemplate.replace(
|
||||
'${filename}',
|
||||
filePath
|
||||
);
|
||||
success = await handleCommitAndPush(commitMessage);
|
||||
}
|
||||
}
|
||||
return success;
|
||||
},
|
||||
[handleSave, setHasUnsavedChanges, settings, handleCommitAndPush]
|
||||
[handleSave, setHasUnsavedChanges]
|
||||
);
|
||||
|
||||
const handleCreateFile = useCallback(
|
||||
async (fileName) => {
|
||||
const success = await handleCreate(fileName);
|
||||
if (success) {
|
||||
loadFileList();
|
||||
handleFileSelect(fileName);
|
||||
}
|
||||
},
|
||||
[handleCreate, handleFileSelect]
|
||||
[handleCreate, handleFileSelect, loadFileList]
|
||||
);
|
||||
|
||||
const handleDeleteFile = useCallback(
|
||||
async (filePath) => {
|
||||
const success = await handleDelete(filePath);
|
||||
if (success) {
|
||||
loadFileList();
|
||||
handleFileSelect(null);
|
||||
}
|
||||
},
|
||||
[handleDelete, handleFileSelect]
|
||||
[handleDelete, handleFileSelect, loadFileList]
|
||||
);
|
||||
|
||||
const renderBreadcrumbs = useMemo(() => {
|
||||
@@ -114,7 +108,7 @@ const MainContent = ({ selectedFile, handleFileSelect, handleLinkClick }) => {
|
||||
content={content}
|
||||
handleContentChange={handleContentChange}
|
||||
handleSave={handleSaveFile}
|
||||
handleLinkClick={handleLinkClick}
|
||||
handleFileSelect={handleFileSelect}
|
||||
/>
|
||||
</Box>
|
||||
<CreateFileModal onCreateFile={handleCreateFile} />
|
||||
@@ -1,19 +1,17 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import FileActions from './FileActions';
|
||||
import FileTree from './FileTree';
|
||||
import { useFileList } from '../hooks/useFileList';
|
||||
import { useGitOperations } from '../hooks/useGitOperations';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
import FileActions from '../files/FileActions';
|
||||
import FileTree from '../files/FileTree';
|
||||
import { useGitOperations } from '../../hooks/useGitOperations';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
|
||||
const Sidebar = ({ selectedFile, handleFileSelect }) => {
|
||||
const { settings } = useSettings();
|
||||
const { files, loadFileList } = useFileList();
|
||||
const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
|
||||
const { settings } = useWorkspace();
|
||||
const { handlePull } = useGitOperations(settings.gitEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
loadFileList();
|
||||
}, [settings.gitEnabled, loadFileList]);
|
||||
}, [loadFileList]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -30,7 +28,7 @@ const Sidebar = ({ selectedFile, handleFileSelect }) => {
|
||||
<FileTree
|
||||
files={files}
|
||||
handleFileSelect={handleFileSelect}
|
||||
selectedFile={selectedFile}
|
||||
showHiddenFiles={settings.showHiddenFiles}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
55
app/src/components/modals/account/DeleteAccountModal.jsx
Normal file
55
app/src/components/modals/account/DeleteAccountModal.jsx
Normal 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;
|
||||
51
app/src/components/modals/account/EmailPasswordModal.jsx
Normal file
51
app/src/components/modals/account/EmailPasswordModal.jsx
Normal 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;
|
||||
@@ -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('');
|
||||
@@ -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 } =
|
||||
@@ -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('');
|
||||
76
app/src/components/modals/user/CreateUserModal.jsx
Normal file
76
app/src/components/modals/user/CreateUserModal.jsx
Normal 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;
|
||||
29
app/src/components/modals/user/DeleteUserModal.jsx
Normal file
29
app/src/components/modals/user/DeleteUserModal.jsx
Normal 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;
|
||||
105
app/src/components/modals/user/EditUserModal.jsx
Normal file
105
app/src/components/modals/user/EditUserModal.jsx
Normal 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;
|
||||
82
app/src/components/modals/workspace/CreateWorkspaceModal.jsx
Normal file
82
app/src/components/modals/workspace/CreateWorkspaceModal.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
|
||||
import { useModalContext } from '../../../contexts/ModalContext';
|
||||
import { createWorkspace } from '../../../services/api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { createWorkspaceModalVisible, setCreateWorkspaceModalVisible } =
|
||||
useModalContext();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Workspace name is required',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const workspace = await createWorkspace(name);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Workspace created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
setName('');
|
||||
setCreateWorkspaceModalVisible(false);
|
||||
if (onWorkspaceCreated) {
|
||||
onWorkspaceCreated(workspace);
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to create workspace',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={createWorkspaceModalVisible}
|
||||
onClose={() => setCreateWorkspaceModalVisible(false)}
|
||||
title="Create New Workspace"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Box maw={400} mx="auto">
|
||||
<TextInput
|
||||
label="Workspace Name"
|
||||
placeholder="Enter workspace name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
mb="md"
|
||||
w="100%"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setCreateWorkspaceModalVisible(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={loading}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateWorkspaceModal;
|
||||
35
app/src/components/modals/workspace/DeleteWorkspaceModal.jsx
Normal file
35
app/src/components/modals/workspace/DeleteWorkspaceModal.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||
|
||||
const DeleteWorkspaceModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
workspaceName,
|
||||
}) => (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Delete Workspace"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack>
|
||||
<Text>
|
||||
Are you sure you want to delete workspace "{workspaceName}"? This action
|
||||
cannot be undone and all files in this workspace will be permanently
|
||||
deleted.
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={onConfirm}>
|
||||
Delete Workspace
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default DeleteWorkspaceModal;
|
||||
155
app/src/components/navigation/UserMenu.jsx
Normal file
155
app/src/components/navigation/UserMenu.jsx
Normal 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;
|
||||
197
app/src/components/navigation/WorkspaceSwitcher.jsx
Normal file
197
app/src/components/navigation/WorkspaceSwitcher.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Popover,
|
||||
Stack,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
Loader,
|
||||
Center,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
import { useModalContext } from '../../contexts/ModalContext';
|
||||
import { listWorkspaces } from '../../services/api';
|
||||
import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal';
|
||||
|
||||
const WorkspaceSwitcher = () => {
|
||||
const { currentWorkspace, switchWorkspace } = useWorkspace();
|
||||
const { setSettingsModalVisible, setCreateWorkspaceModalVisible } =
|
||||
useModalContext();
|
||||
const [workspaces, setWorkspaces] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [popoverOpened, setPopoverOpened] = useState(false);
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const loadWorkspaces = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await listWorkspaces();
|
||||
setWorkspaces(list);
|
||||
} catch (error) {
|
||||
console.error('Failed to load workspaces:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
setPopoverOpened(false);
|
||||
setCreateWorkspaceModalVisible(true);
|
||||
};
|
||||
|
||||
const handleWorkspaceCreated = async (newWorkspace) => {
|
||||
await loadWorkspaces();
|
||||
switchWorkspace(newWorkspace.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
width={300}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
opened={popoverOpened}
|
||||
onChange={setPopoverOpened}
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
setPopoverOpened((o) => !o);
|
||||
if (!popoverOpened) {
|
||||
loadWorkspaces();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconFolders size={20} />
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{currentWorkspace?.name || 'No workspace'}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown p="xs">
|
||||
<Group justify="space-between" mb="md" px="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Workspaces
|
||||
</Text>
|
||||
<Tooltip label="Create New Workspace">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="md"
|
||||
onClick={handleCreateWorkspace}
|
||||
>
|
||||
<IconFolderPlus size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={400} offsetScrollbars>
|
||||
<Stack gap="xs">
|
||||
{loading ? (
|
||||
<Center p="md">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
workspaces.map((workspace) => {
|
||||
const isSelected = workspace.name === currentWorkspace?.name;
|
||||
return (
|
||||
<Paper
|
||||
key={workspace.name}
|
||||
p="xs"
|
||||
withBorder
|
||||
style={{
|
||||
backgroundColor: isSelected
|
||||
? theme.colors.blue[
|
||||
theme.colorScheme === 'dark' ? 8 : 1
|
||||
]
|
||||
: undefined,
|
||||
borderColor: isSelected
|
||||
? theme.colors.blue[
|
||||
theme.colorScheme === 'dark' ? 7 : 5
|
||||
]
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<UnstyledButton
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => {
|
||||
switchWorkspace(workspace.name);
|
||||
setPopoverOpened(false);
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
truncate
|
||||
c={
|
||||
isSelected
|
||||
? theme.colors.blue[
|
||||
theme.colorScheme === 'dark' ? 0 : 9
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c={
|
||||
isSelected
|
||||
? theme.colorScheme === 'dark'
|
||||
? theme.colors.blue[2]
|
||||
: theme.colors.blue[7]
|
||||
: 'dimmed'
|
||||
}
|
||||
>
|
||||
{new Date(
|
||||
workspace.createdAt
|
||||
).toLocaleDateString()}
|
||||
</Text>
|
||||
</Box>
|
||||
</UnstyledButton>
|
||||
{isSelected && (
|
||||
<Tooltip label="Workspace Settings">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
color={
|
||||
theme.colorScheme === 'dark'
|
||||
? 'blue.2'
|
||||
: 'blue.7'
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSettingsModalVisible(true);
|
||||
setPopoverOpened(false);
|
||||
}}
|
||||
>
|
||||
<IconSettings size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<CreateWorkspaceModal onWorkspaceCreated={handleWorkspaceCreated} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceSwitcher;
|
||||
10
app/src/components/settings/AccordionControl.jsx
Normal file
10
app/src/components/settings/AccordionControl.jsx
Normal 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;
|
||||
248
app/src/components/settings/account/AccountSettings.jsx
Normal file
248
app/src/components/settings/account/AccountSettings.jsx
Normal 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;
|
||||
43
app/src/components/settings/account/DangerZoneSettings.jsx
Normal file
43
app/src/components/settings/account/DangerZoneSettings.jsx
Normal 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;
|
||||
23
app/src/components/settings/account/ProfileSettings.jsx
Normal file
23
app/src/components/settings/account/ProfileSettings.jsx
Normal 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;
|
||||
65
app/src/components/settings/account/SecuritySettings.jsx
Normal file
65
app/src/components/settings/account/SecuritySettings.jsx
Normal 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;
|
||||
44
app/src/components/settings/admin/AdminDashboard.jsx
Normal file
44
app/src/components/settings/admin/AdminDashboard.jsx
Normal 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;
|
||||
56
app/src/components/settings/admin/AdminStatsTab.jsx
Normal file
56
app/src/components/settings/admin/AdminStatsTab.jsx
Normal 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;
|
||||
162
app/src/components/settings/admin/AdminUsersTab.jsx
Normal file
162
app/src/components/settings/admin/AdminUsersTab.jsx
Normal 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;
|
||||
67
app/src/components/settings/admin/AdminWorkspacesTab.jsx
Normal file
67
app/src/components/settings/admin/AdminWorkspacesTab.jsx
Normal 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;
|
||||
@@ -1,20 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Text, Switch, Group, Box, Title } from '@mantine/core';
|
||||
import { useSettings } from '../../contexts/SettingsContext';
|
||||
import { useWorkspace } from '../../../contexts/WorkspaceContext';
|
||||
|
||||
const AppearanceSettings = ({ onThemeChange }) => {
|
||||
const { colorScheme, toggleColorScheme } = useSettings();
|
||||
const AppearanceSettings = ({ themeSettings, onThemeChange }) => {
|
||||
const { colorScheme, updateColorScheme } = useWorkspace();
|
||||
|
||||
const handleThemeChange = () => {
|
||||
toggleColorScheme();
|
||||
onThemeChange(colorScheme === 'dark' ? 'light' : 'dark');
|
||||
const newTheme = colorScheme === 'dark' ? 'light' : 'dark';
|
||||
updateColorScheme(newTheme);
|
||||
onThemeChange(newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box mb="md">
|
||||
<Title order={3} mb="md">
|
||||
Appearance
|
||||
</Title>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm">Dark Mode</Text>
|
||||
<Switch checked={colorScheme === 'dark'} onChange={handleThemeChange} />
|
||||
46
app/src/components/settings/workspace/DangerZoneSettings.jsx
Normal file
46
app/src/components/settings/workspace/DangerZoneSettings.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Button, Title } from '@mantine/core';
|
||||
import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal';
|
||||
import { useWorkspace } from '../../../contexts/WorkspaceContext';
|
||||
import { useModalContext } from '../../../contexts/ModalContext';
|
||||
|
||||
const DangerZoneSettings = () => {
|
||||
const { currentWorkspace, workspaces, deleteCurrentWorkspace } =
|
||||
useWorkspace();
|
||||
const { setSettingsModalVisible } = useModalContext();
|
||||
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteCurrentWorkspace();
|
||||
setDeleteModalOpened(false);
|
||||
setSettingsModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box mb="md">
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={() => setDeleteModalOpened(true)}
|
||||
fullWidth
|
||||
disabled={workspaces.length <= 1}
|
||||
title={
|
||||
workspaces.length <= 1
|
||||
? 'Cannot delete the last workspace'
|
||||
: 'Delete this workspace'
|
||||
}
|
||||
>
|
||||
Delete Workspace
|
||||
</Button>
|
||||
|
||||
<DeleteWorkspaceModal
|
||||
opened={deleteModalOpened}
|
||||
onClose={() => setDeleteModalOpened(false)}
|
||||
onConfirm={handleDelete}
|
||||
workspaceName={currentWorkspace?.name}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DangerZoneSettings;
|
||||
36
app/src/components/settings/workspace/EditorSettings.jsx
Normal file
36
app/src/components/settings/workspace/EditorSettings.jsx
Normal 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;
|
||||
26
app/src/components/settings/workspace/GeneralSettings.jsx
Normal file
26
app/src/components/settings/workspace/GeneralSettings.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Title, Box, TextInput, Text, Grid } from '@mantine/core';
|
||||
|
||||
const GeneralSettings = ({ name, onInputChange }) => {
|
||||
return (
|
||||
<Box mb="md">
|
||||
<Grid gutter="md" align="center">
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Workspace Name</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
value={name || ''}
|
||||
onChange={(event) =>
|
||||
onInputChange('name', event.currentTarget.value)
|
||||
}
|
||||
placeholder="Enter workspace name"
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSettings;
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Stack,
|
||||
PasswordInput,
|
||||
Group,
|
||||
Title,
|
||||
Grid,
|
||||
} from '@mantine/core';
|
||||
|
||||
@@ -17,14 +16,15 @@ const GitSettings = ({
|
||||
gitToken,
|
||||
gitAutoCommit,
|
||||
gitCommitMsgTemplate,
|
||||
gitCommitName,
|
||||
gitCommitEmail,
|
||||
onInputChange,
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing="md">
|
||||
<Title order={3}>Git Integration</Title>
|
||||
<Grid gutter="md" align="center">
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Enable Git</Text>
|
||||
<Text size="sm">Enable Git Repository</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Group justify="flex-end">
|
||||
@@ -43,6 +43,7 @@ const GitSettings = ({
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
value={gitUrl}
|
||||
description="The URL of your Git repository"
|
||||
onChange={(event) =>
|
||||
onInputChange('gitUrl', event.currentTarget.value)
|
||||
}
|
||||
@@ -52,11 +53,12 @@ const GitSettings = ({
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Git Username</Text>
|
||||
<Text size="sm">Username</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
value={gitUser}
|
||||
description="The username used to authenticate with the repository"
|
||||
onChange={(event) =>
|
||||
onInputChange('gitUser', event.currentTarget.value)
|
||||
}
|
||||
@@ -66,11 +68,12 @@ const GitSettings = ({
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Git Token</Text>
|
||||
<Text size="sm">Access Token</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<PasswordInput
|
||||
value={gitToken}
|
||||
description="Personal access token with repository read/write permissions"
|
||||
onChange={(event) =>
|
||||
onInputChange('gitToken', event.currentTarget.value)
|
||||
}
|
||||
@@ -80,7 +83,7 @@ const GitSettings = ({
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Auto Commit</Text>
|
||||
<Text size="sm">Commit on Save</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Group justify="flex-end">
|
||||
@@ -100,6 +103,7 @@ const GitSettings = ({
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
value={gitCommitMsgTemplate}
|
||||
description="Template for automated commit messages. Use ${filename} and ${action} as a placeholder."
|
||||
onChange={(event) =>
|
||||
onInputChange('gitCommitMsgTemplate', event.currentTarget.value)
|
||||
}
|
||||
@@ -107,6 +111,36 @@ const GitSettings = ({
|
||||
placeholder="Enter commit message template"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Commit Author</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
value={gitCommitName}
|
||||
description="Name to appear in commit history. Leave empty to use Git username."
|
||||
onChange={(event) =>
|
||||
onInputChange('gitCommitName', event.currentTarget.value)
|
||||
}
|
||||
disabled={!gitEnabled}
|
||||
placeholder="Enter commit author name."
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Commit Author Email</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
value={gitCommitEmail}
|
||||
description="Email address to associate with your commits"
|
||||
onChange={(event) =>
|
||||
onInputChange('gitCommitEmail', event.currentTarget.value)
|
||||
}
|
||||
disabled={!gitEnabled}
|
||||
placeholder="Enter commit author email."
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
235
app/src/components/settings/workspace/WorkspaceSettings.jsx
Normal file
235
app/src/components/settings/workspace/WorkspaceSettings.jsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useReducer, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Title,
|
||||
Stack,
|
||||
Accordion,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useWorkspace } from '../../../contexts/WorkspaceContext';
|
||||
import AppearanceSettings from './AppearanceSettings';
|
||||
import EditorSettings from './EditorSettings';
|
||||
import GitSettings from './GitSettings';
|
||||
import GeneralSettings from './GeneralSettings';
|
||||
import { useModalContext } from '../../../contexts/ModalContext';
|
||||
import DangerZoneSettings from './DangerZoneSettings';
|
||||
import AccordionControl from '../AccordionControl';
|
||||
|
||||
const initialState = {
|
||||
localSettings: {},
|
||||
initialSettings: {},
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
|
||||
function settingsReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'INIT_SETTINGS':
|
||||
return {
|
||||
...state,
|
||||
localSettings: action.payload,
|
||||
initialSettings: action.payload,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
case 'UPDATE_LOCAL_SETTINGS':
|
||||
const newLocalSettings = { ...state.localSettings, ...action.payload };
|
||||
const hasChanges =
|
||||
JSON.stringify(newLocalSettings) !==
|
||||
JSON.stringify(state.initialSettings);
|
||||
return {
|
||||
...state,
|
||||
localSettings: newLocalSettings,
|
||||
hasUnsavedChanges: hasChanges,
|
||||
};
|
||||
case 'MARK_SAVED':
|
||||
return {
|
||||
...state,
|
||||
initialSettings: state.localSettings,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const WorkspaceSettings = () => {
|
||||
const { currentWorkspace, updateSettings } = useWorkspace();
|
||||
const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
|
||||
const [state, dispatch] = useReducer(settingsReducer, initialState);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
const settings = {
|
||||
name: currentWorkspace.name,
|
||||
theme: currentWorkspace.theme,
|
||||
autoSave: currentWorkspace.autoSave,
|
||||
showHiddenFiles: currentWorkspace.showHiddenFiles,
|
||||
gitEnabled: currentWorkspace.gitEnabled,
|
||||
gitUrl: currentWorkspace.gitUrl,
|
||||
gitUser: currentWorkspace.gitUser,
|
||||
gitToken: currentWorkspace.gitToken,
|
||||
gitAutoCommit: currentWorkspace.gitAutoCommit,
|
||||
gitCommitMsgTemplate: currentWorkspace.gitCommitMsgTemplate,
|
||||
gitCommitName: currentWorkspace.gitCommitName,
|
||||
gitCommitEmail: currentWorkspace.gitCommitEmail,
|
||||
};
|
||||
dispatch({ type: 'INIT_SETTINGS', payload: settings });
|
||||
}
|
||||
}, [currentWorkspace]);
|
||||
|
||||
const handleInputChange = useCallback((key, value) => {
|
||||
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
if (!state.localSettings.name?.trim()) {
|
||||
notifications.show({
|
||||
message: 'Workspace name cannot be empty',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSettings(state.localSettings);
|
||||
dispatch({ type: 'MARK_SAVED' });
|
||||
notifications.show({
|
||||
message: 'Settings saved successfully',
|
||||
color: 'green',
|
||||
});
|
||||
setSettingsModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
notifications.show({
|
||||
message: 'Failed to save settings: ' + error.message,
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSettingsModalVisible(false);
|
||||
}, [setSettingsModalVisible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={settingsModalVisible}
|
||||
onClose={handleClose}
|
||||
title={<Title order={2}>Workspace Settings</Title>}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Stack spacing="xl">
|
||||
{state.hasUnsavedChanges && (
|
||||
<Badge color="yellow" variant="light">
|
||||
Unsaved Changes
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Accordion
|
||||
defaultValue={['general', 'appearance', 'editor', 'git', 'danger']}
|
||||
multiple
|
||||
styles={(theme) => ({
|
||||
control: {
|
||||
paddingTop: theme.spacing.md,
|
||||
paddingBottom: theme.spacing.md,
|
||||
},
|
||||
item: {
|
||||
borderBottom: `1px solid ${
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[4]
|
||||
: theme.colors.gray[3]
|
||||
}`,
|
||||
'&[data-active]': {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[7]
|
||||
: theme.colors.gray[0],
|
||||
},
|
||||
},
|
||||
chevron: {
|
||||
'&[data-rotate]': {
|
||||
transform: 'rotate(180deg)',
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Accordion.Item value="general">
|
||||
<AccordionControl>General</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<GeneralSettings
|
||||
name={state.localSettings.name}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="appearance">
|
||||
<AccordionControl>Appearance</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<AppearanceSettings
|
||||
themeSettings={state.localSettings.theme}
|
||||
onThemeChange={(newTheme) =>
|
||||
handleInputChange('theme', newTheme)
|
||||
}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="editor">
|
||||
<AccordionControl>Editor</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<EditorSettings
|
||||
autoSave={state.localSettings.autoSave}
|
||||
onAutoSaveChange={(value) =>
|
||||
handleInputChange('autoSave', value)
|
||||
}
|
||||
showHiddenFiles={state.localSettings.showHiddenFiles}
|
||||
onShowHiddenFilesChange={(value) =>
|
||||
handleInputChange('showHiddenFiles', value)
|
||||
}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="git">
|
||||
<AccordionControl>Git Integration</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<GitSettings
|
||||
gitEnabled={state.localSettings.gitEnabled}
|
||||
gitUrl={state.localSettings.gitUrl}
|
||||
gitUser={state.localSettings.gitUser}
|
||||
gitToken={state.localSettings.gitToken}
|
||||
gitAutoCommit={state.localSettings.gitAutoCommit}
|
||||
gitCommitMsgTemplate={state.localSettings.gitCommitMsgTemplate}
|
||||
gitCommitName={state.localSettings.gitCommitName}
|
||||
gitCommitEmail={state.localSettings.gitCommitEmail}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="danger">
|
||||
<AccordionControl>Danger Zone</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<DangerZoneSettings />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>Save Changes</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceSettings;
|
||||
108
app/src/contexts/AuthContext.jsx
Normal file
108
app/src/contexts/AuthContext.jsx
Normal 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;
|
||||
};
|
||||
@@ -8,6 +8,10 @@ export const ModalProvider = ({ children }) => {
|
||||
const [commitMessageModalVisible, setCommitMessageModalVisible] =
|
||||
useState(false);
|
||||
const [settingsModalVisible, setSettingsModalVisible] = useState(false);
|
||||
const [switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible] =
|
||||
useState(false);
|
||||
const [createWorkspaceModalVisible, setCreateWorkspaceModalVisible] =
|
||||
useState(false);
|
||||
|
||||
const value = {
|
||||
newFileModalVisible,
|
||||
@@ -18,6 +22,10 @@ export const ModalProvider = ({ children }) => {
|
||||
setCommitMessageModalVisible,
|
||||
settingsModalVisible,
|
||||
setSettingsModalVisible,
|
||||
switchWorkspaceModalVisible,
|
||||
setSwitchWorkspaceModalVisible,
|
||||
createWorkspaceModalVisible,
|
||||
setCreateWorkspaceModalVisible,
|
||||
};
|
||||
|
||||
return (
|
||||
211
app/src/contexts/WorkspaceContext.jsx
Normal file
211
app/src/contexts/WorkspaceContext.jsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { useMantineColorScheme } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
fetchLastWorkspaceName,
|
||||
getWorkspace,
|
||||
updateWorkspace,
|
||||
updateLastWorkspaceName,
|
||||
deleteWorkspace,
|
||||
listWorkspaces,
|
||||
} from '../services/api';
|
||||
import { DEFAULT_WORKSPACE_SETTINGS } from '../utils/constants';
|
||||
|
||||
const WorkspaceContext = createContext();
|
||||
|
||||
export const WorkspaceProvider = ({ children }) => {
|
||||
const [currentWorkspace, setCurrentWorkspace] = useState(null);
|
||||
const [workspaces, setWorkspaces] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
|
||||
const loadWorkspaces = useCallback(async () => {
|
||||
try {
|
||||
const workspaceList = await listWorkspaces();
|
||||
setWorkspaces(workspaceList);
|
||||
return workspaceList;
|
||||
} catch (error) {
|
||||
console.error('Failed to load workspaces:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to load workspaces list',
|
||||
color: 'red',
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadWorkspaceData = useCallback(async (workspaceName) => {
|
||||
try {
|
||||
const workspace = await getWorkspace(workspaceName);
|
||||
setCurrentWorkspace(workspace);
|
||||
setColorScheme(workspace.theme);
|
||||
} catch (error) {
|
||||
console.error('Failed to load workspace data:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to load workspace data',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadFirstAvailableWorkspace = useCallback(async () => {
|
||||
try {
|
||||
const allWorkspaces = await listWorkspaces();
|
||||
if (allWorkspaces.length > 0) {
|
||||
const firstWorkspace = allWorkspaces[0];
|
||||
await updateLastWorkspaceName(firstWorkspace.name);
|
||||
await loadWorkspaceData(firstWorkspace.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load first available workspace:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to load workspace',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeWorkspace = async () => {
|
||||
try {
|
||||
const { lastWorkspaceName } = await fetchLastWorkspaceName();
|
||||
if (lastWorkspaceName) {
|
||||
await loadWorkspaceData(lastWorkspaceName);
|
||||
} else {
|
||||
await loadFirstAvailableWorkspace();
|
||||
}
|
||||
await loadWorkspaces();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize workspace:', error);
|
||||
await loadFirstAvailableWorkspace();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializeWorkspace();
|
||||
}, []);
|
||||
|
||||
const switchWorkspace = useCallback(async (workspaceName) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await updateLastWorkspaceName(workspaceName);
|
||||
await loadWorkspaceData(workspaceName);
|
||||
await loadWorkspaces();
|
||||
} catch (error) {
|
||||
console.error('Failed to switch workspace:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to switch workspace',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteCurrentWorkspace = useCallback(async () => {
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
try {
|
||||
const allWorkspaces = await loadWorkspaces();
|
||||
if (allWorkspaces.length <= 1) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message:
|
||||
'Cannot delete the last workspace. At least one workspace must exist.',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete workspace and get the next workspace ID
|
||||
const response = await deleteWorkspace(currentWorkspace.name);
|
||||
|
||||
// Load the new workspace data
|
||||
await loadWorkspaceData(response.nextWorkspaceName);
|
||||
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Workspace deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
await loadWorkspaces();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete workspace:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete workspace',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, [currentWorkspace]);
|
||||
|
||||
const updateSettings = useCallback(
|
||||
async (newSettings) => {
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
try {
|
||||
const updatedWorkspace = {
|
||||
...currentWorkspace,
|
||||
...newSettings,
|
||||
};
|
||||
|
||||
const response = await updateWorkspace(
|
||||
currentWorkspace.name,
|
||||
updatedWorkspace
|
||||
);
|
||||
setCurrentWorkspace(response);
|
||||
setColorScheme(response.theme);
|
||||
await loadWorkspaces();
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[currentWorkspace, setColorScheme]
|
||||
);
|
||||
|
||||
const updateColorScheme = useCallback(
|
||||
(newTheme) => {
|
||||
setColorScheme(newTheme);
|
||||
},
|
||||
[setColorScheme]
|
||||
);
|
||||
|
||||
const value = {
|
||||
currentWorkspace,
|
||||
workspaces,
|
||||
settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS,
|
||||
updateSettings,
|
||||
loading,
|
||||
colorScheme,
|
||||
updateColorScheme,
|
||||
switchWorkspace,
|
||||
deleteCurrentWorkspace,
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceContext.Provider value={value}>
|
||||
{children}
|
||||
</WorkspaceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useWorkspace = () => {
|
||||
const context = useContext(WorkspaceContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useWorkspace must be used within a WorkspaceProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
48
app/src/hooks/useAdminData.js
Normal file
48
app/src/hooks/useAdminData.js
Normal 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 };
|
||||
};
|
||||
61
app/src/hooks/useFileContent.js
Normal file
61
app/src/hooks/useFileContent.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { fetchFileContent } from '../services/api';
|
||||
import { isImageFile } from '../utils/fileHelpers';
|
||||
import { DEFAULT_FILE } from '../utils/constants';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
|
||||
export const useFileContent = (selectedFile) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const [content, setContent] = useState(DEFAULT_FILE.content);
|
||||
const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
const loadFileContent = useCallback(
|
||||
async (filePath) => {
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
try {
|
||||
let newContent;
|
||||
if (filePath === DEFAULT_FILE.path) {
|
||||
newContent = DEFAULT_FILE.content;
|
||||
} else if (!isImageFile(filePath)) {
|
||||
newContent = await fetchFileContent(currentWorkspace.name, filePath);
|
||||
} else {
|
||||
newContent = ''; // Set empty content for image files
|
||||
}
|
||||
setContent(newContent);
|
||||
setOriginalContent(newContent);
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (err) {
|
||||
console.error('Error loading file content:', err);
|
||||
setContent(''); // Set empty content on error
|
||||
setOriginalContent('');
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
},
|
||||
[currentWorkspace]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFile && currentWorkspace) {
|
||||
loadFileContent(selectedFile);
|
||||
}
|
||||
}, [selectedFile, currentWorkspace, loadFileContent]);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(newContent) => {
|
||||
setContent(newContent);
|
||||
setHasUnsavedChanges(newContent !== originalContent);
|
||||
},
|
||||
[originalContent]
|
||||
);
|
||||
|
||||
return {
|
||||
content,
|
||||
setContent,
|
||||
hasUnsavedChanges,
|
||||
setHasUnsavedChanges,
|
||||
loadFileContent,
|
||||
handleContentChange,
|
||||
};
|
||||
};
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fetchFileList } from '../services/api';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
|
||||
export const useFileList = () => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
|
||||
|
||||
const loadFileList = useCallback(async () => {
|
||||
if (!currentWorkspace || workspaceLoading) return;
|
||||
|
||||
try {
|
||||
const fileList = await fetchFileList();
|
||||
const fileList = await fetchFileList(currentWorkspace.name);
|
||||
if (Array.isArray(fileList)) {
|
||||
setFiles(fileList);
|
||||
} else {
|
||||
@@ -14,8 +18,9 @@ export const useFileList = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load file list:', error);
|
||||
setFiles([]);
|
||||
}
|
||||
}, []);
|
||||
}, [currentWorkspace, workspaceLoading]);
|
||||
|
||||
return { files, loadFileList };
|
||||
};
|
||||
45
app/src/hooks/useFileNavigation.js
Normal file
45
app/src/hooks/useFileNavigation.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { DEFAULT_FILE } from '../utils/constants';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
import { useLastOpenedFile } from './useLastOpenedFile';
|
||||
|
||||
export const useFileNavigation = () => {
|
||||
const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path);
|
||||
const [isNewFile, setIsNewFile] = useState(true);
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { loadLastOpenedFile, saveLastOpenedFile } = useLastOpenedFile();
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
async (filePath) => {
|
||||
const newPath = filePath || DEFAULT_FILE.path;
|
||||
setSelectedFile(newPath);
|
||||
setIsNewFile(!filePath);
|
||||
|
||||
if (filePath) {
|
||||
await saveLastOpenedFile(filePath);
|
||||
}
|
||||
},
|
||||
[saveLastOpenedFile]
|
||||
);
|
||||
|
||||
// Load last opened file when workspace changes
|
||||
useEffect(() => {
|
||||
const initializeFile = async () => {
|
||||
setSelectedFile(DEFAULT_FILE.path);
|
||||
setIsNewFile(true);
|
||||
|
||||
const lastFile = await loadLastOpenedFile();
|
||||
if (lastFile) {
|
||||
handleFileSelect(lastFile);
|
||||
} else {
|
||||
handleFileSelect(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (currentWorkspace) {
|
||||
initializeFile();
|
||||
}
|
||||
}, [currentWorkspace, loadLastOpenedFile, handleFileSelect]);
|
||||
|
||||
return { selectedFile, isNewFile, handleFileSelect };
|
||||
};
|
||||
106
app/src/hooks/useFileOperations.js
Normal file
106
app/src/hooks/useFileOperations.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useCallback } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { saveFileContent, deleteFile } from '../services/api';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
import { useGitOperations } from './useGitOperations';
|
||||
|
||||
export const useFileOperations = () => {
|
||||
const { currentWorkspace, settings } = useWorkspace();
|
||||
const { handleCommitAndPush } = useGitOperations();
|
||||
|
||||
const autoCommit = useCallback(
|
||||
async (filePath, action) => {
|
||||
if (settings.gitAutoCommit && settings.gitEnabled) {
|
||||
let commitMessage = settings.gitCommitMsgTemplate
|
||||
.replace('${filename}', filePath)
|
||||
.replace('${action}', action);
|
||||
|
||||
commitMessage =
|
||||
commitMessage.charAt(0).toUpperCase() + commitMessage.slice(1);
|
||||
|
||||
await handleCommitAndPush(commitMessage);
|
||||
}
|
||||
},
|
||||
[settings]
|
||||
);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (filePath, content) => {
|
||||
if (!currentWorkspace) return false;
|
||||
|
||||
try {
|
||||
await saveFileContent(currentWorkspace.name, filePath, content);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'File saved successfully',
|
||||
color: 'green',
|
||||
});
|
||||
autoCommit(filePath, 'update');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving file:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to save file',
|
||||
color: 'red',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[currentWorkspace, autoCommit]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (filePath) => {
|
||||
if (!currentWorkspace) return false;
|
||||
|
||||
try {
|
||||
await deleteFile(currentWorkspace.name, filePath);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'File deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
autoCommit(filePath, 'delete');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete file',
|
||||
color: 'red',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[currentWorkspace, autoCommit]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (fileName, initialContent = '') => {
|
||||
if (!currentWorkspace) return false;
|
||||
|
||||
try {
|
||||
await saveFileContent(currentWorkspace.name, fileName, initialContent);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'File created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
autoCommit(fileName, 'create');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error creating new file:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to create new file',
|
||||
color: 'red',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[currentWorkspace, autoCommit]
|
||||
);
|
||||
|
||||
return { handleSave, handleDelete, handleCreate };
|
||||
};
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useCallback } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { pullChanges, commitAndPush } from '../services/api';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
|
||||
export const useGitOperations = () => {
|
||||
const { currentWorkspace, settings } = useWorkspace();
|
||||
|
||||
export const useGitOperations = (gitEnabled) => {
|
||||
const handlePull = useCallback(async () => {
|
||||
if (!gitEnabled) return false;
|
||||
if (!currentWorkspace || !settings.gitEnabled) return false;
|
||||
|
||||
try {
|
||||
await pullChanges();
|
||||
await pullChanges(currentWorkspace.name);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Successfully pulled latest changes',
|
||||
@@ -22,13 +26,14 @@ export const useGitOperations = (gitEnabled) => {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, [gitEnabled]);
|
||||
}, [currentWorkspace, settings.gitEnabled]);
|
||||
|
||||
const handleCommitAndPush = useCallback(
|
||||
async (message) => {
|
||||
if (!gitEnabled) return false;
|
||||
if (!currentWorkspace || !settings.gitEnabled) return false;
|
||||
|
||||
try {
|
||||
await commitAndPush(message);
|
||||
await commitAndPush(currentWorkspace.name, message);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Successfully committed and pushed changes',
|
||||
@@ -45,7 +50,7 @@ export const useGitOperations = (gitEnabled) => {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gitEnabled]
|
||||
[currentWorkspace, settings.gitEnabled]
|
||||
);
|
||||
|
||||
return { handlePull, handleCommitAndPush };
|
||||
37
app/src/hooks/useLastOpenedFile.js
Normal file
37
app/src/hooks/useLastOpenedFile.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback } from 'react';
|
||||
import { getLastOpenedFile, updateLastOpenedFile } from '../services/api';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
|
||||
export const useLastOpenedFile = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const loadLastOpenedFile = useCallback(async () => {
|
||||
if (!currentWorkspace) return null;
|
||||
|
||||
try {
|
||||
const response = await getLastOpenedFile(currentWorkspace.name);
|
||||
return response.lastOpenedFilePath || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to load last opened file:', error);
|
||||
return null;
|
||||
}
|
||||
}, [currentWorkspace]);
|
||||
|
||||
const saveLastOpenedFile = useCallback(
|
||||
async (filePath) => {
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
try {
|
||||
await updateLastOpenedFile(currentWorkspace.name, filePath);
|
||||
} catch (error) {
|
||||
console.error('Failed to save last opened file:', error);
|
||||
}
|
||||
},
|
||||
[currentWorkspace]
|
||||
);
|
||||
|
||||
return {
|
||||
loadLastOpenedFile,
|
||||
saveLastOpenedFile,
|
||||
};
|
||||
};
|
||||
71
app/src/hooks/useProfileSettings.js
Normal file
71
app/src/hooks/useProfileSettings.js
Normal 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,
|
||||
};
|
||||
}
|
||||
79
app/src/hooks/useUserAdmin.js
Normal file
79
app/src/hooks/useUserAdmin.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useAdminData } from './useAdminData';
|
||||
import { createUser, updateUser, deleteUser } from '../services/adminApi';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
export const useUserAdmin = () => {
|
||||
const { data: users, loading, error, reload } = useAdminData('users');
|
||||
|
||||
const handleCreate = async (userData) => {
|
||||
try {
|
||||
await createUser(userData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
reload();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message;
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to create user: ${message}`,
|
||||
color: 'red',
|
||||
});
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (userId, userData) => {
|
||||
try {
|
||||
await updateUser(userId, userData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
reload();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message;
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to update user: ${message}`,
|
||||
color: 'red',
|
||||
});
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (userId) => {
|
||||
try {
|
||||
await deleteUser(userId);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
reload();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message;
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to delete user: ${message}`,
|
||||
color: 'red',
|
||||
});
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
create: handleCreate,
|
||||
update: handleUpdate,
|
||||
delete: handleDelete,
|
||||
};
|
||||
};
|
||||
13
app/src/index.html
Normal file
13
app/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lemma</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
49
app/src/services/adminApi.js
Normal file
49
app/src/services/adminApi.js
Normal 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
172
app/src/services/api.js
Normal 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();
|
||||
};
|
||||
98
app/src/services/authApi.js
Normal file
98
app/src/services/authApi.js
Normal 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();
|
||||
};
|
||||
@@ -26,7 +26,8 @@ export const IMAGE_EXTENSIONS = [
|
||||
'.svg',
|
||||
];
|
||||
|
||||
export const DEFAULT_SETTINGS = {
|
||||
// Renamed from DEFAULT_SETTINGS to be more specific
|
||||
export const DEFAULT_WORKSPACE_SETTINGS = {
|
||||
theme: THEMES.LIGHT,
|
||||
autoSave: false,
|
||||
gitEnabled: false,
|
||||
@@ -34,15 +35,33 @@ export const DEFAULT_SETTINGS = {
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: 'Update ${filename}',
|
||||
gitCommitMsgTemplate: '${action} ${filename}',
|
||||
};
|
||||
|
||||
// Template for creating new workspaces
|
||||
export const DEFAULT_WORKSPACE = {
|
||||
name: '',
|
||||
...DEFAULT_WORKSPACE_SETTINGS,
|
||||
};
|
||||
|
||||
export const DEFAULT_FILE = {
|
||||
name: 'New File.md',
|
||||
path: 'New File.md',
|
||||
content: '# Welcome to NovaMD\n\nStart editing here!',
|
||||
content: '# Welcome to Lemma\n\nStart editing here!',
|
||||
};
|
||||
|
||||
export const MARKDOWN_REGEX = {
|
||||
WIKILINK: /(!?)\[\[(.*?)\]\]/g,
|
||||
};
|
||||
|
||||
// List of element types that can contain inline content
|
||||
export const INLINE_CONTAINER_TYPES = new Set([
|
||||
'paragraph',
|
||||
'listItem',
|
||||
'tableCell',
|
||||
'blockquote',
|
||||
'heading',
|
||||
'emphasis',
|
||||
'strong',
|
||||
'delete',
|
||||
]);
|
||||
10
app/src/utils/formatBytes.js
Normal file
10
app/src/utils/formatBytes.js
Normal 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]}`;
|
||||
};
|
||||
177
app/src/utils/remarkWikiLinks.js
Normal file
177
app/src/utils/remarkWikiLinks.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { lookupFileByName, getFileUrl } from '../services/api';
|
||||
import { INLINE_CONTAINER_TYPES, MARKDOWN_REGEX } from './constants';
|
||||
|
||||
function createNotFoundLink(fileName, displayText, baseUrl) {
|
||||
return {
|
||||
type: 'link',
|
||||
url: `${baseUrl}/notfound/${encodeURIComponent(fileName)}`,
|
||||
children: [{ type: 'text', value: displayText }],
|
||||
data: {
|
||||
hProperties: { style: { color: 'red', textDecoration: 'underline' } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFileLink(filePath, displayText, heading, baseUrl) {
|
||||
const url = heading
|
||||
? `${baseUrl}/internal/${encodeURIComponent(filePath)}#${encodeURIComponent(
|
||||
heading
|
||||
)}`
|
||||
: `${baseUrl}/internal/${encodeURIComponent(filePath)}`;
|
||||
|
||||
return {
|
||||
type: 'link',
|
||||
url,
|
||||
children: [{ type: 'text', value: displayText }],
|
||||
};
|
||||
}
|
||||
|
||||
function createImageNode(workspaceName, filePath, displayText) {
|
||||
return {
|
||||
type: 'image',
|
||||
url: getFileUrl(workspaceName, filePath),
|
||||
alt: displayText,
|
||||
title: displayText,
|
||||
};
|
||||
}
|
||||
|
||||
function addMarkdownExtension(fileName) {
|
||||
if (fileName.includes('.')) {
|
||||
return fileName;
|
||||
}
|
||||
return `${fileName}.md`;
|
||||
}
|
||||
|
||||
export function remarkWikiLinks(workspaceName) {
|
||||
return async function transformer(tree) {
|
||||
if (!workspaceName) {
|
||||
console.warn('No workspace ID provided to remarkWikiLinks plugin');
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = window.API_BASE_URL;
|
||||
const replacements = new Map();
|
||||
|
||||
// Find all wiki links
|
||||
visit(tree, 'text', function (node, index, parent) {
|
||||
const regex = MARKDOWN_REGEX.WIKILINK;
|
||||
let match;
|
||||
const matches = [];
|
||||
|
||||
while ((match = regex.exec(node.value)) !== null) {
|
||||
const [fullMatch, isImage, innerContent] = match;
|
||||
let fileName, displayText, heading;
|
||||
|
||||
const pipeIndex = innerContent.indexOf('|');
|
||||
const hashIndex = innerContent.indexOf('#');
|
||||
|
||||
if (pipeIndex !== -1) {
|
||||
displayText = innerContent.slice(pipeIndex + 1).trim();
|
||||
fileName = innerContent.slice(0, pipeIndex).trim();
|
||||
} else {
|
||||
displayText = innerContent;
|
||||
fileName = innerContent;
|
||||
}
|
||||
|
||||
if (hashIndex !== -1 && (pipeIndex === -1 || hashIndex < pipeIndex)) {
|
||||
heading = fileName.slice(hashIndex + 1).trim();
|
||||
fileName = fileName.slice(0, hashIndex).trim();
|
||||
}
|
||||
|
||||
matches.push({
|
||||
fullMatch,
|
||||
isImage,
|
||||
fileName,
|
||||
displayText,
|
||||
heading,
|
||||
index: match.index,
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
replacements.set(node, { matches, parent, index });
|
||||
}
|
||||
});
|
||||
|
||||
// Process all matches
|
||||
for (const [node, { matches, parent }] of replacements) {
|
||||
const newNodes = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of matches) {
|
||||
// Add text before the match
|
||||
if (match.index > lastIndex) {
|
||||
newNodes.push({
|
||||
type: 'text',
|
||||
value: node.value.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const lookupFileName = match.isImage
|
||||
? match.fileName
|
||||
: addMarkdownExtension(match.fileName);
|
||||
|
||||
const paths = await lookupFileByName(workspaceName, lookupFileName);
|
||||
|
||||
if (paths && paths.length > 0) {
|
||||
const filePath = paths[0];
|
||||
if (match.isImage) {
|
||||
newNodes.push(
|
||||
createImageNode(workspaceName, filePath, match.displayText)
|
||||
);
|
||||
} else {
|
||||
newNodes.push(
|
||||
createFileLink(
|
||||
filePath,
|
||||
match.displayText,
|
||||
match.heading,
|
||||
baseUrl
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
newNodes.push(
|
||||
createNotFoundLink(match.fileName, match.displayText, baseUrl)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('File lookup failed:', match.fileName, error);
|
||||
newNodes.push(
|
||||
createNotFoundLink(match.fileName, match.displayText, baseUrl)
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match.fullMatch.length;
|
||||
}
|
||||
|
||||
// Add any remaining text
|
||||
if (lastIndex < node.value.length) {
|
||||
newNodes.push({
|
||||
type: 'text',
|
||||
value: node.value.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// If the parent is a container that can have inline content,
|
||||
// replace the text node directly with the new nodes
|
||||
if (parent && INLINE_CONTAINER_TYPES.has(parent.type)) {
|
||||
const nodeIndex = parent.children.indexOf(node);
|
||||
if (nodeIndex !== -1) {
|
||||
parent.children.splice(nodeIndex, 1, ...newNodes);
|
||||
}
|
||||
} else {
|
||||
// For other types of parents, wrap the nodes in a paragraph
|
||||
const paragraph = {
|
||||
type: 'paragraph',
|
||||
children: newNodes,
|
||||
};
|
||||
const nodeIndex = parent.children.indexOf(node);
|
||||
if (nodeIndex !== -1) {
|
||||
parent.children.splice(nodeIndex, 1, paragraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
141
app/vite.config.js
Normal file
141
app/vite.config.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import postcssPresetMantine from 'postcss-preset-mantine';
|
||||
import postcssSimpleVars from 'postcss-simple-vars';
|
||||
import { compression } from 'vite-plugin-compression2';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [
|
||||
react({
|
||||
include: ['**/*.jsx', '**/*.js'],
|
||||
}),
|
||||
compression(),
|
||||
],
|
||||
|
||||
root: 'src',
|
||||
publicDir: '../public',
|
||||
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
assetsDir: 'assets',
|
||||
sourcemap: mode === 'development',
|
||||
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: path.resolve(__dirname, 'src/index.html'),
|
||||
},
|
||||
output: {
|
||||
manualChunks: {
|
||||
// React core libraries
|
||||
'react-core': ['react', 'react-dom'],
|
||||
|
||||
// Mantine UI components and related
|
||||
mantine: [
|
||||
'@mantine/core',
|
||||
'@mantine/hooks',
|
||||
'@mantine/modals',
|
||||
'@mantine/notifications',
|
||||
],
|
||||
|
||||
// Editor related packages
|
||||
editor: [
|
||||
'codemirror',
|
||||
'@codemirror/commands',
|
||||
'@codemirror/lang-markdown',
|
||||
'@codemirror/state',
|
||||
'@codemirror/theme-one-dark',
|
||||
'@codemirror/view',
|
||||
],
|
||||
|
||||
// Markdown processing
|
||||
markdown: [
|
||||
'react-syntax-highlighter',
|
||||
'rehype-mathjax',
|
||||
'rehype-prism',
|
||||
'rehype-react',
|
||||
'remark',
|
||||
'remark-math',
|
||||
'remark-parse',
|
||||
'remark-rehype',
|
||||
'unified',
|
||||
'unist-util-visit',
|
||||
],
|
||||
|
||||
// Icons and utilities
|
||||
utils: [
|
||||
'@tabler/icons-react',
|
||||
'@react-hook/resize-observer',
|
||||
'react-arborist',
|
||||
],
|
||||
},
|
||||
// Optimize chunk naming for better caching
|
||||
chunkFileNames: (chunkInfo) => {
|
||||
const name = chunkInfo.name;
|
||||
if (name === 'react-core') return 'assets/react.[hash].js';
|
||||
if (name === 'mantine') return 'assets/mantine.[hash].js';
|
||||
if (name === 'editor') return 'assets/editor.[hash].js';
|
||||
if (name === 'markdown') return 'assets/markdown.[hash].js';
|
||||
if (name === 'utils') return 'assets/utils.[hash].js';
|
||||
return 'assets/[name].[hash].js';
|
||||
},
|
||||
// Optimize asset naming
|
||||
assetFileNames: 'assets/[name].[hash][extname]',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
},
|
||||
|
||||
define: {
|
||||
'window.API_BASE_URL': JSON.stringify(
|
||||
mode === 'production' ? '/api/v1' : 'http://localhost:8080/api/v1'
|
||||
),
|
||||
},
|
||||
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: 'modern',
|
||||
},
|
||||
},
|
||||
postcss: {
|
||||
plugins: [
|
||||
postcssPresetMantine(),
|
||||
postcssSimpleVars({
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '36em',
|
||||
'mantine-breakpoint-sm': '48em',
|
||||
'mantine-breakpoint-md': '62em',
|
||||
'mantine-breakpoint-lg': '75em',
|
||||
'mantine-breakpoint-xl': '88em',
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
extensions: ['.js', '.jsx', '.json'],
|
||||
},
|
||||
|
||||
// Add performance optimization options
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'@mantine/core',
|
||||
'@mantine/hooks',
|
||||
'codemirror',
|
||||
'react-markdown',
|
||||
],
|
||||
},
|
||||
}));
|
||||
@@ -1,90 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"novamd/internal/api"
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/filesystem"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize database
|
||||
dbPath := os.Getenv("NOVAMD_DB_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "./sqlite.db"
|
||||
}
|
||||
database, err := db.Init(dbPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := database.Close(); err != nil {
|
||||
log.Printf("Error closing database: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Workdir
|
||||
workdir := os.Getenv("NOVAMD_WORKDIR")
|
||||
if workdir == "" {
|
||||
workdir = "./data"
|
||||
}
|
||||
|
||||
settings, err := database.GetSettings(1) // Assuming user ID 1 for now
|
||||
if err != nil {
|
||||
log.Print("Settings not found, using default settings")
|
||||
}
|
||||
fs := filesystem.New(workdir, &settings)
|
||||
|
||||
if settings.Settings.GitEnabled {
|
||||
if err := fs.InitializeGitRepo(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up router
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
// Set up API routes
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
api.SetupRoutes(r, database, fs)
|
||||
})
|
||||
|
||||
// Set up static file server with path validation
|
||||
staticPath := os.Getenv("NOVAMD_STATIC_PATH")
|
||||
if staticPath == "" {
|
||||
staticPath = "../frontend/dist"
|
||||
}
|
||||
fileServer := http.FileServer(http.Dir(staticPath))
|
||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPath := r.URL.Path
|
||||
validatedPath, err := filesystem.ValidatePath(staticPath, requestedPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = os.Stat(validatedPath)
|
||||
if os.IsNotExist(err) {
|
||||
http.ServeFile(w, r, filepath.Join(staticPath, "index.html"))
|
||||
return
|
||||
}
|
||||
http.StripPrefix("/", fileServer).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
// Start server
|
||||
port := os.Getenv("NOVAMD_PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
log.Printf("Server starting on port %s", port)
|
||||
log.Fatal(http.ListenAndServe(":"+port, r))
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/filesystem"
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
files, err := fs.ListFilesRecursively()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to list files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(files); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
filenameOrPath := r.URL.Query().Get("filename")
|
||||
if filenameOrPath == "" {
|
||||
http.Error(w, "Filename or path is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filePaths, err := fs.FindFileByName(filenameOrPath)
|
||||
if err != nil {
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string][]string{"paths": filePaths}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")
|
||||
content, err := fs.GetFileContent(filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read file", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine content type based on file extension
|
||||
contentType := "text/plain"
|
||||
switch filepath.Ext(filePath) {
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
contentType = "image/jpeg"
|
||||
case ".webp":
|
||||
contentType = "image/webp"
|
||||
case ".gif":
|
||||
contentType = "image/gif"
|
||||
case ".svg":
|
||||
contentType = "image/svg+xml"
|
||||
case ".md":
|
||||
contentType = "text/markdown"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
if _, err := w.Write(content); err != nil {
|
||||
http.Error(w, "Failed to write response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = fs.SaveFile(filePath, content)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"message": "File saved successfully"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")
|
||||
err := fs.DeleteFile(filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to delete file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write([]byte("File deleted successfully")); err != nil {
|
||||
http.Error(w, "Failed to write response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetSettings(db *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userIDStr := r.URL.Query().Get("userId")
|
||||
userID, err := strconv.Atoi(userIDStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid userId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := db.GetSettings(userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
settings.SetDefaults()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(settings); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateSettings(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var settings models.Settings
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
settings.SetDefaults()
|
||||
|
||||
if err := settings.Validate(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := db.SaveSettings(settings)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if settings.Settings.GitEnabled {
|
||||
err := fs.SetupGitRepo(settings.Settings.GitURL, settings.Settings.GitUser, settings.Settings.GitToken)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to setup git repo", http.StatusInternalServerError)
|
||||
}
|
||||
} else {
|
||||
fs.DisableGitRepo()
|
||||
}
|
||||
|
||||
// Fetch the saved settings to return
|
||||
savedSettings, err := db.GetSettings(settings.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, "Settings saved but could not be retrieved", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(savedSettings); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var requestBody struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(&requestBody)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if requestBody.Message == "" {
|
||||
http.Error(w, "Commit message is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = fs.StageCommitAndPush(requestBody.Message)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"message": "Changes staged, committed, and pushed successfully"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
err := fs.Pull()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"message": "Pulled changes from remote"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/filesystem"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) {
|
||||
r.Route("/", func(r chi.Router) {
|
||||
r.Route("/settings", func(r chi.Router) {
|
||||
r.Get("/", GetSettings(db))
|
||||
r.Post("/", UpdateSettings(db, fs))
|
||||
})
|
||||
r.Route("/files", func(r chi.Router) {
|
||||
r.Get("/", ListFiles(fs))
|
||||
r.Get("/*", GetFileContent(fs))
|
||||
r.Post("/*", SaveFile(fs))
|
||||
r.Delete("/*", DeleteFile(fs))
|
||||
r.Get("/lookup", LookupFileByName(fs))
|
||||
})
|
||||
r.Route("/git", func(r chi.Router) {
|
||||
r.Post("/commit", StageCommitAndPush(fs))
|
||||
r.Post("/pull", PullChanges(fs))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
func Init(dbPath string) (*DB, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
database := &DB{db}
|
||||
if err := database.Migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() error {
|
||||
return db.DB.Close()
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
type Migration struct {
|
||||
Version int
|
||||
SQL string
|
||||
}
|
||||
|
||||
var migrations = []Migration{
|
||||
{
|
||||
Version: 1,
|
||||
SQL: `CREATE TABLE IF NOT EXISTS settings (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
settings JSON NOT NULL
|
||||
)`,
|
||||
},
|
||||
}
|
||||
|
||||
func (db *DB) Migrate() error {
|
||||
// Create migrations table if it doesn't exist
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS migrations (
|
||||
version INTEGER PRIMARY KEY
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get current version
|
||||
var currentVersion int
|
||||
err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM migrations").Scan(¤tVersion)
|
||||
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
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
func (db *DB) GetSettings(userID int) (models.Settings, error) {
|
||||
var settings models.Settings
|
||||
var settingsJSON []byte
|
||||
|
||||
err := db.QueryRow("SELECT user_id, settings FROM settings WHERE user_id = ?", userID).Scan(&settings.UserID, &settingsJSON)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// If no settings found, return default settings
|
||||
settings.UserID = userID
|
||||
settings.Settings = models.UserSettings{} // This will be filled with defaults later
|
||||
return settings, nil
|
||||
}
|
||||
return settings, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(settingsJSON, &settings.Settings)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (db *DB) SaveSettings(settings models.Settings) error {
|
||||
if err := settings.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settingsJSON, err := json.Marshal(settings.Settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec("INSERT OR REPLACE INTO settings (user_id, settings) VALUES (?, json(?))", settings.UserID, string(settingsJSON))
|
||||
return err
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"novamd/internal/gitutils"
|
||||
"novamd/internal/models"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FileSystem struct {
|
||||
RootDir string
|
||||
GitRepo *gitutils.GitRepo
|
||||
Settings *models.Settings
|
||||
}
|
||||
|
||||
type FileNode struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Children []FileNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func New(rootDir string, settings *models.Settings) *FileSystem {
|
||||
fs := &FileSystem{
|
||||
RootDir: rootDir,
|
||||
Settings: settings,
|
||||
}
|
||||
|
||||
if settings.Settings.GitEnabled {
|
||||
fs.GitRepo = gitutils.New(
|
||||
settings.Settings.GitURL,
|
||||
settings.Settings.GitUser,
|
||||
settings.Settings.GitToken,
|
||||
rootDir,
|
||||
)
|
||||
}
|
||||
|
||||
return fs
|
||||
}
|
||||
|
||||
func (fs *FileSystem) SetupGitRepo(gitURL string, gitUser string, gitToken string) error {
|
||||
fs.GitRepo = gitutils.New(gitURL, gitUser, gitToken, fs.RootDir)
|
||||
return fs.InitializeGitRepo()
|
||||
}
|
||||
|
||||
func (fs *FileSystem) DisableGitRepo() {
|
||||
fs.GitRepo = nil;
|
||||
}
|
||||
|
||||
func (fs *FileSystem) InitializeGitRepo() error {
|
||||
if fs.GitRepo == nil {
|
||||
return errors.New("git settings not configured")
|
||||
}
|
||||
|
||||
return fs.GitRepo.EnsureRepo()
|
||||
}
|
||||
|
||||
func ValidatePath(rootDir, path string) (string, error) {
|
||||
fullPath := filepath.Join(rootDir, path)
|
||||
cleanPath := filepath.Clean(fullPath)
|
||||
|
||||
if !strings.HasPrefix(cleanPath, filepath.Clean(rootDir)) {
|
||||
return "", fmt.Errorf("invalid path: outside of root directory")
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(rootDir, cleanPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if strings.HasPrefix(relPath, "..") {
|
||||
return "", fmt.Errorf("invalid path: outside of root directory")
|
||||
}
|
||||
|
||||
return cleanPath, nil
|
||||
}
|
||||
|
||||
func (fs *FileSystem) validatePath(path string) (string, error) {
|
||||
return ValidatePath(fs.RootDir, path)
|
||||
}
|
||||
|
||||
func (fs *FileSystem) ListFilesRecursively() ([]FileNode, error) {
|
||||
return fs.walkDirectory(fs.RootDir, "")
|
||||
}
|
||||
|
||||
func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var folders []FileNode
|
||||
var files []FileNode
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
path := filepath.Join(prefix, name)
|
||||
fullPath := filepath.Join(dir, name)
|
||||
|
||||
if entry.IsDir() {
|
||||
children, err := fs.walkDirectory(fullPath, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
folders = append(folders, FileNode{
|
||||
ID: path, // Using path as ID ensures uniqueness
|
||||
Name: name,
|
||||
Path: path,
|
||||
Children: children,
|
||||
})
|
||||
} else {
|
||||
files = append(files, FileNode{
|
||||
ID: path, // Using path as ID ensures uniqueness
|
||||
Name: name,
|
||||
Path: path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort folders and files alphabetically
|
||||
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[i].Name })
|
||||
|
||||
// Combine folders and files, with folders first
|
||||
return append(folders, files...), nil
|
||||
}
|
||||
|
||||
|
||||
func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) {
|
||||
var foundPaths []string
|
||||
var searchPattern string
|
||||
|
||||
// If no extension is provided, assume .md
|
||||
if !strings.Contains(filenameOrPath, ".") {
|
||||
searchPattern = filenameOrPath + ".md"
|
||||
} else {
|
||||
searchPattern = filenameOrPath
|
||||
}
|
||||
|
||||
err := filepath.Walk(fs.RootDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
relPath, err := filepath.Rel(fs.RootDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if the file matches the search pattern
|
||||
if strings.HasSuffix(relPath, searchPattern) ||
|
||||
strings.EqualFold(info.Name(), searchPattern) {
|
||||
foundPaths = append(foundPaths, relPath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(foundPaths) == 0 {
|
||||
return nil, errors.New("file not found")
|
||||
}
|
||||
|
||||
return foundPaths, nil
|
||||
}
|
||||
|
||||
func (fs *FileSystem) GetFileContent(filePath string) ([]byte, error) {
|
||||
fullPath, err := fs.validatePath(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(fullPath)
|
||||
}
|
||||
|
||||
func (fs *FileSystem) SaveFile(filePath string, content []byte) error {
|
||||
fullPath, err := fs.validatePath(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if fs.Settings.Settings.GitAutoCommit && fs.GitRepo != nil {
|
||||
message := strings.Replace(fs.Settings.Settings.GitCommitMsgTemplate, "${filename}", filePath, -1)
|
||||
return fs.StageCommitAndPush(message)
|
||||
}
|
||||
|
||||
return os.WriteFile(fullPath, content, 0644)
|
||||
}
|
||||
|
||||
func (fs *FileSystem) DeleteFile(filePath string) error {
|
||||
fullPath, err := fs.validatePath(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(fullPath)
|
||||
}
|
||||
|
||||
func (fs *FileSystem) StageCommitAndPush(message string) error {
|
||||
if fs.GitRepo == nil {
|
||||
return errors.New("git settings not configured")
|
||||
}
|
||||
|
||||
if err := fs.GitRepo.Commit(message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fs.GitRepo.Push()
|
||||
}
|
||||
|
||||
func (fs *FileSystem) Pull() error {
|
||||
if fs.GitRepo == nil {
|
||||
return errors.New("git settings not configured")
|
||||
}
|
||||
|
||||
return fs.GitRepo.Pull()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
type UserSettings struct {
|
||||
Theme string `json:"theme" validate:"oneof=light dark"`
|
||||
AutoSave bool `json:"autoSave"`
|
||||
GitEnabled bool `json:"gitEnabled"`
|
||||
GitURL string `json:"gitUrl" validate:"required_with=GitEnabled"`
|
||||
GitUser string `json:"gitUser" validate:"required_with=GitEnabled"`
|
||||
GitToken string `json:"gitToken" validate:"required_with=GitEnabled"`
|
||||
GitAutoCommit bool `json:"gitAutoCommit"`
|
||||
GitCommitMsgTemplate string `json:"gitCommitMsgTemplate"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
UserID int `json:"userId" validate:"required,min=1"`
|
||||
Settings UserSettings `json:"settings" validate:"required"`
|
||||
}
|
||||
|
||||
var defaultUserSettings = UserSettings{
|
||||
Theme: "light",
|
||||
AutoSave: false,
|
||||
GitEnabled: false,
|
||||
GitCommitMsgTemplate: "Update ${filename}",
|
||||
}
|
||||
|
||||
var validate = validator.New()
|
||||
|
||||
func (s *Settings) Validate() error {
|
||||
return validate.Struct(s)
|
||||
}
|
||||
|
||||
func (s *Settings) SetDefaults() {
|
||||
if s.Settings.Theme == "" {
|
||||
s.Settings.Theme = defaultUserSettings.Theme
|
||||
}
|
||||
if s.Settings.GitCommitMsgTemplate == "" {
|
||||
s.Settings.GitCommitMsgTemplate = defaultUserSettings.GitCommitMsgTemplate
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Settings) UnmarshalJSON(data []byte) error {
|
||||
type Alias Settings
|
||||
aux := &struct {
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(s),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Validate()
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
["@babel/preset-react", { "runtime": "automatic" }]
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-transform-class-properties",
|
||||
"@babel/plugin-transform-runtime"
|
||||
]
|
||||
}
|
||||
10313
frontend/package-lock.json
generated
10313
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '36em',
|
||||
'mantine-breakpoint-sm': '48em',
|
||||
'mantine-breakpoint-md': '62em',
|
||||
'mantine-breakpoint-lg': '75em',
|
||||
'mantine-breakpoint-xl': '88em',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>NovaMD</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Group, Text, ActionIcon, Avatar } from '@mantine/core';
|
||||
import { IconSettings } from '@tabler/icons-react';
|
||||
import Settings from './Settings';
|
||||
import { useModalContext } from '../contexts/ModalContext';
|
||||
|
||||
const Header = () => {
|
||||
const { setSettingsModalVisible } = useModalContext();
|
||||
|
||||
const openSettings = () => setSettingsModalVisible(true);
|
||||
|
||||
return (
|
||||
<Group justify="space-between" h={60} px="md">
|
||||
<Text fw={700} size="lg">
|
||||
NovaMD
|
||||
</Text>
|
||||
<Group>
|
||||
<Avatar src="https://via.placeholder.com/40" radius="xl" />
|
||||
<ActionIcon variant="subtle" onClick={openSettings} size="lg">
|
||||
<IconSettings size={24} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Settings />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -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,
|
||||
``
|
||||
);
|
||||
} 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;
|
||||
@@ -1,141 +0,0 @@
|
||||
import React, { useReducer, useEffect, useCallback, useRef } from 'react';
|
||||
import { Modal, Badge, Button, Group, Title } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
import AppearanceSettings from './settings/AppearanceSettings';
|
||||
import EditorSettings from './settings/EditorSettings';
|
||||
import GitSettings from './settings/GitSettings';
|
||||
import { useModalContext } from '../contexts/ModalContext';
|
||||
|
||||
const initialState = {
|
||||
localSettings: {},
|
||||
initialSettings: {},
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
|
||||
function settingsReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'INIT_SETTINGS':
|
||||
return {
|
||||
...state,
|
||||
localSettings: action.payload,
|
||||
initialSettings: action.payload,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
case 'UPDATE_LOCAL_SETTINGS':
|
||||
const newLocalSettings = { ...state.localSettings, ...action.payload };
|
||||
const hasChanges =
|
||||
JSON.stringify(newLocalSettings) !==
|
||||
JSON.stringify(state.initialSettings);
|
||||
return {
|
||||
...state,
|
||||
localSettings: newLocalSettings,
|
||||
hasUnsavedChanges: hasChanges,
|
||||
};
|
||||
case 'MARK_SAVED':
|
||||
return {
|
||||
...state,
|
||||
initialSettings: state.localSettings,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
case 'RESET':
|
||||
return {
|
||||
...state,
|
||||
localSettings: state.initialSettings,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const Settings = () => {
|
||||
const { settings, updateSettings, colorScheme } = useSettings();
|
||||
const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
|
||||
const [state, dispatch] = useReducer(settingsReducer, initialState);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
dispatch({ type: 'INIT_SETTINGS', payload: settings });
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({
|
||||
type: 'UPDATE_LOCAL_SETTINGS',
|
||||
payload: { theme: colorScheme },
|
||||
});
|
||||
}, [colorScheme]);
|
||||
|
||||
const handleInputChange = useCallback((key, value) => {
|
||||
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await updateSettings(state.localSettings);
|
||||
dispatch({ type: 'MARK_SAVED' });
|
||||
notifications.show({
|
||||
message: 'Settings saved successfully',
|
||||
color: 'green',
|
||||
});
|
||||
setSettingsModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
notifications.show({
|
||||
message: 'Failed to save settings: ' + error.message,
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (state.hasUnsavedChanges) {
|
||||
dispatch({ type: 'RESET' });
|
||||
}
|
||||
setSettingsModalVisible(false);
|
||||
}, [state.hasUnsavedChanges, setSettingsModalVisible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={settingsModalVisible}
|
||||
onClose={handleClose}
|
||||
title={<Title order={2}>Settings</Title>}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
{state.hasUnsavedChanges && (
|
||||
<Badge color="yellow" variant="light" mb="md">
|
||||
Unsaved Changes
|
||||
</Badge>
|
||||
)}
|
||||
<AppearanceSettings
|
||||
themeSettings={state.localSettings.theme}
|
||||
onThemeChange={(newTheme) => handleInputChange('theme', newTheme)}
|
||||
/>
|
||||
<EditorSettings
|
||||
autoSave={state.localSettings.autoSave}
|
||||
onAutoSaveChange={(value) => handleInputChange('autoSave', value)}
|
||||
/>
|
||||
<GitSettings
|
||||
gitEnabled={state.localSettings.gitEnabled}
|
||||
gitUrl={state.localSettings.gitUrl}
|
||||
gitUser={state.localSettings.gitUser}
|
||||
gitToken={state.localSettings.gitToken}
|
||||
gitAutoCommit={state.localSettings.gitAutoCommit}
|
||||
gitCommitMsgTemplate={state.localSettings.gitCommitMsgTemplate}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="default" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>Save Changes</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Text, Switch, Tooltip, Group, Box, Title } from '@mantine/core';
|
||||
|
||||
const EditorSettings = ({ autoSave, onAutoSaveChange }) => {
|
||||
return (
|
||||
<Box mb="md">
|
||||
<Title order={3} mb="md">
|
||||
Editor
|
||||
</Title>
|
||||
<Tooltip label="Auto Save feature is coming soon!" position="left">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm">Auto Save</Text>
|
||||
<Switch
|
||||
checked={autoSave}
|
||||
onChange={(event) => onAutoSaveChange(event.currentTarget.checked)}
|
||||
disabled
|
||||
/>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorSettings;
|
||||
@@ -1,79 +0,0 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useMantineColorScheme } from '@mantine/core';
|
||||
import { fetchUserSettings, saveUserSettings } from '../services/api';
|
||||
import { DEFAULT_SETTINGS } from '../utils/constants';
|
||||
|
||||
const SettingsContext = createContext();
|
||||
|
||||
export const useSettings = () => useContext(SettingsContext);
|
||||
|
||||
export const SettingsProvider = ({ children }) => {
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const userSettings = await fetchUserSettings(1);
|
||||
setSettings(userSettings.settings);
|
||||
setColorScheme(userSettings.settings.theme);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const updateSettings = useCallback(
|
||||
async (newSettings) => {
|
||||
try {
|
||||
await saveUserSettings({
|
||||
userId: 1,
|
||||
settings: newSettings,
|
||||
});
|
||||
setSettings(newSettings);
|
||||
if (newSettings.theme) {
|
||||
setColorScheme(newSettings.theme);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[setColorScheme]
|
||||
);
|
||||
|
||||
const toggleColorScheme = useCallback(() => {
|
||||
const newTheme = colorScheme === 'dark' ? 'light' : 'dark';
|
||||
setColorScheme(newTheme);
|
||||
updateSettings({ ...settings, theme: newTheme });
|
||||
}, [colorScheme, settings, setColorScheme, updateSettings]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
settings,
|
||||
updateSettings,
|
||||
toggleColorScheme,
|
||||
loading,
|
||||
colorScheme,
|
||||
}),
|
||||
[settings, updateSettings, toggleColorScheme, loading, colorScheme]
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { fetchFileContent } from '../services/api';
|
||||
import { isImageFile } from '../utils/fileHelpers';
|
||||
import { DEFAULT_FILE } from '../utils/constants';
|
||||
|
||||
export const useFileContent = (selectedFile) => {
|
||||
const [content, setContent] = useState(DEFAULT_FILE.content);
|
||||
const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
const loadFileContent = useCallback(async (filePath) => {
|
||||
try {
|
||||
let newContent;
|
||||
if (filePath === DEFAULT_FILE.path) {
|
||||
newContent = DEFAULT_FILE.content;
|
||||
} else if (!isImageFile(filePath)) {
|
||||
newContent = await fetchFileContent(filePath);
|
||||
} else {
|
||||
newContent = ''; // Set empty content for image files
|
||||
}
|
||||
setContent(newContent);
|
||||
setOriginalContent(newContent);
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (err) {
|
||||
console.error('Error loading file content:', err);
|
||||
setContent(''); // Set empty content on error
|
||||
setOriginalContent('');
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFile) {
|
||||
loadFileContent(selectedFile);
|
||||
}
|
||||
}, [selectedFile, loadFileContent]);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(newContent) => {
|
||||
setContent(newContent);
|
||||
setHasUnsavedChanges(newContent !== originalContent);
|
||||
},
|
||||
[originalContent]
|
||||
);
|
||||
|
||||
return {
|
||||
content,
|
||||
setContent,
|
||||
hasUnsavedChanges,
|
||||
setHasUnsavedChanges,
|
||||
loadFileContent,
|
||||
handleContentChange,
|
||||
};
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { lookupFileByName } from '../services/api';
|
||||
import { DEFAULT_FILE } from '../utils/constants';
|
||||
|
||||
export const useFileNavigation = () => {
|
||||
const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path);
|
||||
const [isNewFile, setIsNewFile] = useState(true);
|
||||
|
||||
const handleFileSelect = useCallback((filePath) => {
|
||||
setSelectedFile(filePath);
|
||||
setIsNewFile(filePath === DEFAULT_FILE.path);
|
||||
}, []);
|
||||
|
||||
const handleLinkClick = useCallback(
|
||||
async (filename) => {
|
||||
try {
|
||||
const filePaths = await lookupFileByName(filename);
|
||||
if (filePaths.length >= 1) {
|
||||
handleFileSelect(filePaths[0]);
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'File Not Found',
|
||||
message: `File "${filename}" not found`,
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error looking up file:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to lookup file.',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
},
|
||||
[handleFileSelect]
|
||||
);
|
||||
|
||||
return { handleLinkClick, selectedFile, isNewFile, handleFileSelect };
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { saveFileContent, deleteFile } from '../services/api';
|
||||
|
||||
export const useFileOperations = () => {
|
||||
const handleSave = useCallback(async (filePath, content) => {
|
||||
try {
|
||||
await saveFileContent(filePath, content);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'File saved successfully',
|
||||
color: 'green',
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving file:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to save file',
|
||||
color: 'red',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(async (filePath) => {
|
||||
try {
|
||||
await deleteFile(filePath);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'File deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete file',
|
||||
color: 'red',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCreate = useCallback(async (fileName, initialContent = '') => {
|
||||
try {
|
||||
await saveFileContent(fileName, initialContent);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'File created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error creating new file:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to create new file',
|
||||
color: 'red',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { handleSave, handleDelete, handleCreate };
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
const API_BASE_URL = window.API_BASE_URL;
|
||||
|
||||
const apiCall = async (url, options = {}) => {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
errorData?.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`API call failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchFileList = async () => {
|
||||
const response = await apiCall(`${API_BASE_URL}/files`);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const fetchFileContent = async (filePath) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/files/${filePath}`);
|
||||
return response.text();
|
||||
};
|
||||
|
||||
export const saveFileContent = async (filePath, content) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/files/${filePath}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
return response.text();
|
||||
};
|
||||
|
||||
export const deleteFile = async (filePath) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/files/${filePath}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return response.text();
|
||||
};
|
||||
|
||||
export const fetchUserSettings = async (userId) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/settings?userId=${userId}`);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const saveUserSettings = async (settings) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/settings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const pullChanges = async () => {
|
||||
const response = await apiCall(`${API_BASE_URL}/git/pull`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const commitAndPush = async (message) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/git/commit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getFileUrl = (filePath) => {
|
||||
return `${API_BASE_URL}/files/${filePath}`;
|
||||
};
|
||||
|
||||
export const lookupFileByName = async (filename) => {
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/files/lookup?filename=${encodeURIComponent(filename)}`
|
||||
);
|
||||
const data = await response.json();
|
||||
return data.paths;
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const isProduction = argv.mode === 'production';
|
||||
|
||||
return {
|
||||
entry: './src/index.js',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'bundle.js',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|jsx)$/,
|
||||
exclude: /node_modules/,
|
||||
use: ['babel-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.scss$/,
|
||||
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader', 'postcss-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
template: './public/index.html',
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'window.API_BASE_URL': JSON.stringify(
|
||||
isProduction ? '/api/v1' : 'http://localhost:8080/api/v1'
|
||||
),
|
||||
}),
|
||||
],
|
||||
devServer: {
|
||||
static: {
|
||||
directory: path.join(__dirname, 'public'),
|
||||
},
|
||||
port: 3000,
|
||||
open: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
48
server/cmd/server/main.go
Normal file
48
server/cmd/server/main.go
Normal 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
1790
server/docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
1765
server/docs/swagger.json
Normal file
1765
server/docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
1143
server/docs/swagger.yaml
Normal file
1143
server/docs/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user