chore: import upstream snapshot with attribution
@@ -0,0 +1,22 @@
|
||||
# https://github.com/docker/docker.github.io/blob/master/.dockerignore
|
||||
|
||||
.dockerignore
|
||||
.DS_Store
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
.gitmodules
|
||||
.idea
|
||||
.jekyll-metadata
|
||||
.sass-cache
|
||||
tests
|
||||
_site
|
||||
CONTRIBUTING.md
|
||||
Dockerfile
|
||||
Dockerfile.archive
|
||||
docker-compose.yml
|
||||
Gemfile
|
||||
Gemfile.lock
|
||||
_website*.json
|
||||
|
||||
node_modules
|
||||
@@ -0,0 +1,5 @@
|
||||
# use heroku api server
|
||||
REACT_APP_PROXY_API_URL=https://batnoter-staging.herokuapp.com
|
||||
|
||||
# uncomment below line to use localhost api server
|
||||
# REACT_APP_PROXY_API_URL=http://localhost:8080
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"react",
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: vivekweb2013
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: 'bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the bug:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Please provide browser information:**
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
@@ -0,0 +1,8 @@
|
||||
## Describe your changes
|
||||
|
||||
## Issue ticket number and link
|
||||
|
||||
## Checklist before requesting a review
|
||||
* [ ] My code follows the contribution guidelines of this project.
|
||||
* [ ] I have performed a self-review of my code.
|
||||
* [ ] If it is a core feature, I have added thorough tests.
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_REPO_REGISTRY: ghcr.io/${{ github.repository_owner }}/batnoter
|
||||
IMAGE_NAME: batnoter-ui
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: login to github docker registry
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: build & publish docker image
|
||||
env:
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
docker build \
|
||||
-t $GITHUB_REPO_REGISTRY/$IMAGE_NAME:${GITHUB_REF##*/} \
|
||||
-t $GITHUB_REPO_REGISTRY/$IMAGE_NAME:$COMMIT_SHA \
|
||||
-t $GITHUB_REPO_REGISTRY/$IMAGE_NAME:latest .
|
||||
docker push -a $GITHUB_REPO_REGISTRY/$IMAGE_NAME
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
environment:
|
||||
name: Production
|
||||
url: 'https://batnoter.com'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: build react app
|
||||
env:
|
||||
# REACT_APP_BASENAME is the dir where app is deployed. It is used to specify the basename for react router.
|
||||
# it is useful in case you are deploying to github pages & app is served from https://username.github.io/repository
|
||||
# in this case you can simply specify the repository name as the value of this variable.
|
||||
# set this empty if the app is being deployed to the root location.
|
||||
REACT_APP_BASENAME: ""
|
||||
|
||||
# REACT_APP_API_SERVER is the url of batnoter api server
|
||||
REACT_APP_API_SERVER: https://api.batnoter.com
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
# patch the build to support routing on github pages
|
||||
# since we are deploying the application on github pages and github pages only acts as the static file server,
|
||||
# the page reload action on a custom route will cause 404 failure. reference - https://create-react-app.dev/docs/deployment/#serving-apps-with-client-side-routing
|
||||
# this patch resolves 404 issue on page reload. read more at - https://github.com/rafgraph/spa-github-pages
|
||||
echo '<!DOCTYPE html><html><head><meta charset="utf-8"><script>var s=!!"'"$REACT_APP_BASENAME"'"?"'"$REACT_APP_BASENAME"'".split("/").length:0,l=window.location;l.replace(l.protocol+"//"+l.hostname+(l.port?":"+l.port:"")+l.pathname.split("/").slice(0,1+s).join("/")+"/?/"+l.pathname.slice(1).split("/").slice(s).join("/").replace(/&/g,"~and~")+(l.search?"&"+l.search.slice(1).replace(/&/g,"~and~"):"")+l.hash);</script></head><body></body></html>' >> ./build/404.html
|
||||
sed -i 's/<\/head>/<script>var a=window.location;if("\/"===a.search[1]){var b=a.search.slice(1).split("\&").map(function(a){return a.replace(\/~and~\/g,"\&")}).join("?");window.history.replaceState(null,null,a.pathname.slice(0,-1)+b+a.hash)}<\/script><\/head>/' ./build/index.html
|
||||
|
||||
- name: deploy to gh-pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./build
|
||||
cname: batnoter.com
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
# Note: GitHub does not pass secrets(for security reasons) to PR workflows created with forked repos
|
||||
# So do not use any actions that require secrets
|
||||
# Tee GITHUB_TOKEN secret is allowed with readonly access for PR workflows created with forked repos
|
||||
|
||||
jobs:
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.x
|
||||
|
||||
- name: run tests
|
||||
run: |
|
||||
npm ci
|
||||
npm test -- --env=jsdom --coverage --watchAll=false --reporters=default
|
||||
|
||||
- name: upload coverage to codecov
|
||||
uses: codecov/codecov-action@v2
|
||||
@@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -0,0 +1,136 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
|
||||
- Focusing on what is best not just for us as individuals,
|
||||
but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
|
||||
- Public or private harassment
|
||||
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
vivekweb2013@gmail.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
--Community Impact--: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
--Consequence--: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
--Community Impact--: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
--Consequence--: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
--Community Impact--: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
--Consequence--: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
--Community Impact--: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
--Consequence--: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
@@ -0,0 +1,30 @@
|
||||
# Contributing to BatNoter
|
||||
|
||||
First off, thanks for taking the time to contribute! :tada::+1:
|
||||
|
||||
The following is a set of guidelines for contributing to BatNoter, which is hosted in the [BatNoter Repository](https://github.com/batnoter/batnoter) on GitHub. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request.
|
||||
|
||||
## Development Process
|
||||
We use GitHub to track issues and feature requests, as well as accept pull requests.
|
||||
|
||||
## Commit Message Guidelines
|
||||
- Use the imperative, present tense: "Change" not "Changed" nor "Changes".
|
||||
- Capitalize first letter.
|
||||
- Do not place a period . at the end.
|
||||
- Length of the commit message should not exceed 50 characters.
|
||||
|
||||
## Pull Requests
|
||||
Feel free to submit pull requests.
|
||||
|
||||
1. Fork the repo and create your branch from `main`.
|
||||
2. If you've added code that should be tested, add tests.
|
||||
4. Ensure the test suite passes.
|
||||
5. Make sure your code lints.
|
||||
6. Create pull request.
|
||||
|
||||
## Issues
|
||||
GitHub Issues is used to track ideas, feedback, tasks, and bugs.
|
||||
Please ensure your issue description is clear and has sufficient instructions.
|
||||
|
||||
## License
|
||||
By contributing to BatNoter project, you agree that your contributions will be licensed under its MIT License.
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM node:16-alpine as build
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN npm i
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:stable-alpine
|
||||
EXPOSE 3000
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
COPY --from=build /app/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 batnoter
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,64 @@
|
||||
<p align="center">
|
||||
<a href="https://batnoter.com">
|
||||
<img src="https://raw.githubusercontent.com/batnoter/batnoter/main/public/logo.svg" width="100">
|
||||
</a>
|
||||
|
||||
<p align="center">
|
||||
Create and store notes to your git repository!
|
||||
<br>
|
||||
<a href="https://batnoter.com"><strong>https://batnoter.com</strong></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
## BatNoter
|
||||
|
||||
[](https://github.com/batnoter/batnoter/actions?query=branch%3Amain)
|
||||
[](https://codecov.io/gh/batnoter/batnoter)
|
||||
[](https://www.codacy.com/gh/batnoter/batnoter/dashboard?utm_source=github.com&utm_medium=referral&utm_content=batnoter/batnoter&utm_campaign=Badge_Grade)
|
||||
|
||||
[BatNoter](https://batnoter.com) is a web application that allows users to store notes in their git repository. This is a frontend project built using mainly react (typescript), redux-toolkit & mui components. [BatNoter API](https://github.com/batnoter/batnoter-api) is the backend implementation of REST APIs which are used by this react app.
|
||||
|
||||
<p align="center">
|
||||
<kbd><img src="https://raw.githubusercontent.com/batnoter/batnoter/main/public/demo.gif" alt="BatNoter Demo"/></kbd>
|
||||
</p>
|
||||
|
||||
### Features
|
||||
- Login with GitHub.
|
||||
- Create, edit, delete, organize & explore notes easily with a nice & clean user interface.
|
||||
- Markdown format supported allowing users to add hyperlink, table, headings, code blocks, blockquote... etc inside notes.
|
||||
- Editor allows preview of markdown.
|
||||
- Quickly copy code from the code section using copy to clipboard button.
|
||||
- Store notes directly at the root or use folders to organize them (nesting supported).
|
||||
- Explore all the notes from a specific directory with single click.
|
||||
- All the notes are stored inside user's github repository.
|
||||
- Notes are cached to avoid additional API calls.
|
||||
- URLs can be bookmarked.
|
||||
- Dark/Light mode supported.
|
||||
|
||||
### Local Development Setup
|
||||
|
||||
#### Prerequisites
|
||||
* Node.js version `18` or above
|
||||
|
||||
#### Start the server
|
||||
```shell
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
This will start the react app in the development mode. Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
#### Run tests
|
||||
```shell
|
||||
npm test
|
||||
```
|
||||
This will execute all the tests and also prints the code coverage percentage.
|
||||
|
||||
### Contribution Guidelines
|
||||
> Every Contribution Makes a Difference
|
||||
|
||||
Read the [Contribution Guidelines](CONTRIBUTING.md) before you contribute.
|
||||
|
||||
### Contributors
|
||||
Thanks goes to these wonderful people 🎉
|
||||
|
||||
[](https://github.com/batnoter/batnoter/graphs/contributors)
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`batnoter/batnoter`
|
||||
- 原始仓库:https://github.com/batnoter/batnoter
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,9 @@
|
||||
server {
|
||||
listen 3000;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.8.2",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@mui/icons-material": "^5.5.1",
|
||||
"@mui/lab": "^5.0.0-alpha.75",
|
||||
"@mui/material": "^5.5.3",
|
||||
"@reduxjs/toolkit": "^1.8.0",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.5.0",
|
||||
"@testing-library/user-event": "^7.2.1",
|
||||
"@types/jest": "^24.9.1",
|
||||
"@types/node": "^12.20.47",
|
||||
"@types/react": "^16.14.24",
|
||||
"@types/react-dom": "^16.9.14",
|
||||
"@types/react-redux": "^7.1.23",
|
||||
"http-proxy-middleware": "^2.0.6",
|
||||
"mdi-material-ui": "^7.2.0",
|
||||
"mui-modal-provider": "^2.0.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-markdown": "^8.0.2",
|
||||
"react-markdown-editor-lite": "^1.3.2",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^6.2.2",
|
||||
"react-scripts": "5.0.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sass": "^1.49.9",
|
||||
"typescript": "~4.1.5"
|
||||
},
|
||||
"scripts": {
|
||||
"analyze": "react-scripts build && source-map-explorer 'build/static/js/*.js'",
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"lint": "eslint src/**/*.tsx"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^5.15.0",
|
||||
"@typescript-eslint/parser": "^5.15.0",
|
||||
"eslint": "^8.11.0",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"source-map-explorer": "^2.5.2"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="BatNoter - A markdown-based note taking web application that stores notes to git repository" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
|
||||
<title>BatNoter</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="261" stroke="#000" stroke-linecap="round" stroke-linejoin="round" fill="#fff" fill-rule="evenodd"><path fill="#0078d7" d="M237.8 260.4l-216.3-.1C9.8 261 .1 251.2.1 239L0 22.6C-.6 11 9.2 1.2 21.4 1.2l149-1.2v56.2c-2.4 1.2-5.5 3-8 5.5-8 8-10.4 20.2-5.5 30L88.7 160c-10.4-4.3-22-2.4-30 5.5a27 27 0 0 0 0 37.9 27 27 0 0 0 37.9 0 27 27 0 0 0 6.1-26.9l67.2-67.2v51.3c-2.5 1.3-5.5 3-8 5.5a27 27 0 0 0 0 37.9 27 27 0 0 0 37.9 0 27 27 0 0 0 0-37.9c-2.4-2.4-5.5-4.2-8-5.5v-55c2.5-1.2 5.5-3 8-5.5a27 27 0 0 0 0-37.9c-2.5-2.4-5.5-4.2-8-5.5V.6l47 .6c11.6-.6 21.4 9.2 21.4 21.4l-.6 217c.6 11.6-9.2 21.4-22 20.8zM15.2 23.2a5 5 0 0 1 5-5h130a5 5 0 0 1 5 5v7.5c0 2.8-2.2 5-5 5h-130c-2.8 0-5-2.2-5-5zm0 35a5 5 0 0 1 5-5h115a5 5 0 0 1 5 5v7.5c0 2.8-2.2 5-5 5h-115c-2.8 0-5-2.2-5-5zm0 35a5 5 0 0 1 5-5h95a5 5 0 0 1 5 5v7.5c0 2.8-2.2 5-5 5h-95c-2.8 0-5-2.2-5-5zm0 35a5 5 0 0 1 5-5h65a5 5 0 0 1 5 5v7.5c0 2.8-2.2 5-5 5h-65c-2.8 0-5-2.2-5-5zm0 35a5 5 0 0 1 5-5h15a5 5 0 0 1 5 5v7.5c0 2.8-2.2 5-5 5h-15c-2.8 0-5-2.2-5-5z" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,17 @@
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.App-header {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: rgb(112, 76, 182);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import App from './App';
|
||||
import { store } from './app/store';
|
||||
|
||||
test('renders login react link', () => {
|
||||
const { getByText } = render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
expect(getByText(/BATNOTER/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { Box, createTheme, CssBaseline, useMediaQuery } from '@mui/material';
|
||||
import ModalProvider from 'mui-modal-provider';
|
||||
import React from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import './App.scss';
|
||||
import Main from './components/Main';
|
||||
import { useAppDispatch, useAppSelector } from './app/hooks';
|
||||
import { selectThemeMode } from './reducer/preferenceSlice';
|
||||
import { setThemeMode } from './reducer/preferenceSlice';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||
|
||||
const themeMode = useAppSelector(selectThemeMode);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(setThemeMode(prefersDarkMode ? 'dark' : 'light'));
|
||||
}, [prefersDarkMode]);
|
||||
|
||||
const theme = createTheme({
|
||||
palette: { mode: themeMode === 'dark' ? 'dark' : 'light' }
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<BrowserRouter basename={process.env.REACT_APP_BASENAME}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ModalProvider>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
<Main />
|
||||
</Box>
|
||||
</ModalProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { NotePage, NoteResponsePayload } from "../reducer/noteSlice";
|
||||
import { Repo } from "../reducer/preferenceSlice";
|
||||
import { User } from "../reducer/userSlice";
|
||||
|
||||
export const API_URL = (process.env.REACT_APP_API_SERVER || "") + "/api/v1";
|
||||
|
||||
const getHeaders = (): HeadersInit => {
|
||||
const headers: HeadersInit = new Headers();
|
||||
headers.set("Accept", "application/json");
|
||||
headers.set("Authorization", "Bearer " + localStorage.getItem("token"));
|
||||
headers.set("Content-Type", "application/json");
|
||||
return headers;
|
||||
}
|
||||
|
||||
export const getToken = (): Promise<undefined> => {
|
||||
return fetch(`${API_URL}/auth/token`, { credentials: 'include' }).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
return Promise.reject("retrieving token failed");
|
||||
}
|
||||
const token = await res.text();
|
||||
localStorage.setItem("token", token);
|
||||
})
|
||||
}
|
||||
|
||||
export const getUserProfile = (): Promise<User> => {
|
||||
// there is no point in calling profile api without a token, since it will fail anyway due to missing token
|
||||
// so in case of page reload etc we call this endpoint only when there is token in local-storage, which implies
|
||||
// that user has already logged-in
|
||||
return localStorage.getItem("token") ? fetch(`${API_URL}/user/me`, { headers: getHeaders() }).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
return Promise.reject(await res.json());
|
||||
}
|
||||
return await res.json();
|
||||
}) : Promise.reject("token missing. fetching user profile failed");
|
||||
}
|
||||
|
||||
export const getUserRepos = (): Promise<Repo[]> => {
|
||||
return fetch(`${API_URL}/user/preference/repo`, { headers: getHeaders() }).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
return Promise.reject(await res.json());
|
||||
}
|
||||
return await res.json();
|
||||
})
|
||||
}
|
||||
|
||||
export const autoSetupRepo = (repoName: string): Promise<undefined> => {
|
||||
return fetch(`${API_URL}/user/preference/auto/repo?repoName=${repoName}`, {
|
||||
method: "POST",
|
||||
headers: getHeaders()
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
return Promise.reject(await res.json());
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const saveDefaultRepo = (defaultRepo: Repo): Promise<undefined> => {
|
||||
return fetch(`${API_URL}/user/preference/repo`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(defaultRepo),
|
||||
headers: getHeaders()
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
return Promise.reject(await res.json());
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const searchNotes = (page?: number, path?: string, query?: string): Promise<NotePage> => {
|
||||
return fetch(`${API_URL}/search/notes?page=` + (page || 1) + (path ? `path=${path}` : "") + (query ? `query=${query}` : ""),
|
||||
{ headers: getHeaders() }).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
return Promise.reject(await res.json());
|
||||
}
|
||||
return await res.json();
|
||||
})
|
||||
}
|
||||
|
||||
export const getNotesTree = (): Promise<NoteResponsePayload[]> => {
|
||||
return fetch(`${API_URL}/tree/notes`, {
|
||||
headers: getHeaders()
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
return Promise.reject(await res.json());
|
||||
}
|
||||
return await res.json();
|
||||
})
|
||||
}
|
||||
|
||||
export const getAllNotes = (path: string): Promise<NoteResponsePayload[]> => {
|
||||
return fetch(`${API_URL}/notes` + (path && "?path=" + encodeURIComponent(path)), {
|
||||
headers: getHeaders()
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
return Promise.reject(await res.json());
|
||||
}
|
||||
return await res.json();
|
||||
})
|
||||
}
|
||||
|
||||
export const getNote = (path: string): Promise<NoteResponsePayload> => {
|
||||
return fetch(`${API_URL}/notes/` + encodeURIComponent(path), {
|
||||
headers: getHeaders()
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
return Promise.reject(await res.json());
|
||||
}
|
||||
return await res.json();
|
||||
})
|
||||
}
|
||||
|
||||
export const saveNote = (path: string, content: string, sha?: string): Promise<NoteResponsePayload> => {
|
||||
return fetch(`${API_URL}/notes/` + encodeURIComponent(path), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sha: sha, content: content }),
|
||||
headers: getHeaders()
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
return Promise.reject(await res.json());
|
||||
}
|
||||
return await res.json();
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteNote = (path: string, sha?: string): Promise<undefined> => {
|
||||
return fetch(`${API_URL}/notes/` + encodeURIComponent(path), {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ sha: sha }),
|
||||
headers: getHeaders()
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
return Promise.reject(await res.json());
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from './store';
|
||||
|
||||
// Use throughout your app instead of plain `useDispatch` and `useSelector`
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Action, configureStore, ThunkAction } from '@reduxjs/toolkit';
|
||||
import noteReducer from '../reducer/noteSlice';
|
||||
import preferenceReducer from '../reducer/preferenceSlice';
|
||||
import userReducer from '../reducer/userSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
user: userReducer,
|
||||
notes: noteReducer,
|
||||
preference: preferenceReducer
|
||||
},
|
||||
});
|
||||
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppThunk<ReturnType = void> = ThunkAction<
|
||||
ReturnType,
|
||||
RootState,
|
||||
unknown,
|
||||
Action<string>
|
||||
>;
|
||||
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import ErrorImage from "../assets/404.png";
|
||||
import { Grid, Typography, Button } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const ErrorPage: React.FC = (): React.ReactElement => {
|
||||
const history = useNavigate();
|
||||
const handleClick = () => history("/");
|
||||
return (
|
||||
<Grid container columns={12} minHeight="100vh">
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={5}
|
||||
lg={5}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h1" sx={{ fontWeight: "bold" }}>
|
||||
Whooops!
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
Sorry, the Page you are looking for does not exist.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ mt: 2, width: "20em", alignSelf: "left" }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Go back to Home
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={7}
|
||||
lg={7}
|
||||
sx={{ display: "grid", placeContent: "center" }}
|
||||
>
|
||||
<img
|
||||
src={ErrorImage}
|
||||
alt="404"
|
||||
width="100%"
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Login as LoginIcon } from '@mui/icons-material';
|
||||
import ThemeToggleIconDark from '@mui/icons-material/DarkMode';
|
||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import ThemeToggleIconLight from '@mui/icons-material/LightMode';
|
||||
import TwitterIcon from '@mui/icons-material/Twitter';
|
||||
import { Avatar, Box, Button, CircularProgress, IconButton, Link, LinkProps, LinkTypeMap, Menu, MenuItem, SvgIconTypeMap, Toolbar } from '@mui/material';
|
||||
import AppBarComponent from '@mui/material/AppBar';
|
||||
import { OverridableComponent } from '@mui/material/OverridableComponent';
|
||||
import { Ladybug, MessageQuestion, PlusBox } from 'mdi-material-ui';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useAppDispatch, useAppSelector } from '../app/hooks';
|
||||
import { RootState } from '../app/store';
|
||||
import { APIStatusType } from '../reducer/common';
|
||||
import { setThemeMode } from '../reducer/preferenceSlice';
|
||||
import { User } from '../reducer/userSlice';
|
||||
import { URL_FAQ, URL_GITHUB, URL_ISSUES, URL_SPONSOR, URL_TWITTER_HANDLE } from '../util/util';
|
||||
|
||||
interface Props {
|
||||
user: User | null
|
||||
userAPIStatus: APIStatusType
|
||||
handleLogin: () => void
|
||||
handleLogout: () => void
|
||||
onDrawerToggle: () => void
|
||||
}
|
||||
|
||||
export type AppBarLinkProps = {
|
||||
label: string
|
||||
icon: OverridableComponent<SvgIconTypeMap>
|
||||
iconColor?: string
|
||||
}
|
||||
|
||||
const AppBarLink = <D extends React.ElementType = LinkTypeMap["defaultComponent"], P = AppBarLinkProps>
|
||||
({ label, icon: Icon, iconColor, children, ...rest }: LinkProps<D, P> & AppBarLinkProps) =>
|
||||
<Link {...rest} sx={{
|
||||
p: 0.2, mx: 0.5, borderRadius: '50%', color: 'inherit', display: 'flex',
|
||||
bgcolor: { xs: 'action.disabled', lg: 'unset' }
|
||||
}} {...(rest.href ? { target: "_blank", rel: "noopener" } : {})}>
|
||||
<Icon sx={{ m: 0.5, verticalAlign: 'middle', color: iconColor }} fontSize="inherit" />
|
||||
<Box sx={{ display: { xs: 'none', lg: 'block' } }}>{label}</Box>
|
||||
{children}
|
||||
</Link>
|
||||
|
||||
const isLoading = (apiStatus: APIStatusType): boolean => {
|
||||
return apiStatus === APIStatusType.LOADING;
|
||||
}
|
||||
|
||||
const AppBar: React.FC<Props> = ({ user, userAPIStatus, handleLogin, handleLogout, onDrawerToggle }): ReactElement => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const themeMode = useAppSelector((state: RootState) => state.preference.themeMode);
|
||||
|
||||
|
||||
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleThemeModeToggle = () => {
|
||||
if (themeMode === 'light') { dispatch(setThemeMode('dark')) }
|
||||
else if (themeMode === 'dark') { dispatch(setThemeMode('light')) }
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBarComponent position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
|
||||
<Toolbar variant="dense" sx={{ justifyContent: "space-between" }}>
|
||||
<IconButton
|
||||
size="large"
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="menu"
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
onClick={onDrawerToggle}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Link variant="h6" noWrap component={NavLink} to={"/"} sx={{ flexGrow: 1, display: "flex", color: 'inherit' }}>BATNOTER</Link>
|
||||
<Button sx={{ mx: 1, color: 'inherit' }} onClick={handleThemeModeToggle}>
|
||||
{themeMode === 'dark' ? <ThemeToggleIconLight /> : <ThemeToggleIconDark />}
|
||||
</Button>
|
||||
<AppBarLink href={URL_SPONSOR} label="sponsor" icon={FavoriteIcon} iconColor="#d489b5" />
|
||||
<AppBarLink href={URL_TWITTER_HANDLE} label="@batnoter" icon={TwitterIcon} iconColor="#b1d5ff" />
|
||||
<AppBarLink href={URL_FAQ} label="faq" icon={MessageQuestion} iconColor="#c7d097" />
|
||||
<AppBarLink href={URL_ISSUES} label="bug report" icon={Ladybug} iconColor="#eeb082" />
|
||||
<AppBarLink href={URL_GITHUB} label="github" icon={GitHubIcon} iconColor="#dadada" />
|
||||
{user && <AppBarLink component={NavLink} to="/new" label="create note" icon={PlusBox} iconColor="#c1f497" />}
|
||||
|
||||
<Box sx={{ ml: 1 }}>
|
||||
{user == null ?
|
||||
(isLoading(userAPIStatus) ? <CircularProgress color="inherit" /> :
|
||||
<Button color="inherit" endIcon={<LoginIcon />} onClick={() => handleLogin()}>Login</Button>)
|
||||
:
|
||||
<>
|
||||
<Avatar onClick={handleMenu} alt={user.name} src={user.avatar_url} sx={{ cursor: "pointer" }} />
|
||||
<Menu autoFocus={false} sx={{ mt: '5px' }} id="menu-appbar" anchorEl={anchorEl} anchorOrigin={{
|
||||
vertical: 'bottom', horizontal: 'right'
|
||||
}} transformOrigin={{ vertical: 'top', horizontal: 'right', }} open={Boolean(anchorEl)} onClose={handleClose}>
|
||||
<MenuItem component={NavLink} to="/settings" onClick={handleClose}>Setting</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>Logout</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBarComponent>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppBar;
|
||||
@@ -0,0 +1,107 @@
|
||||
import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined';
|
||||
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
||||
import { TreeView } from '@mui/lab';
|
||||
import { Drawer, Toolbar } from '@mui/material';
|
||||
import { useModal } from 'mui-modal-provider';
|
||||
import React, { ReactElement, SyntheticEvent, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAppDispatch, useAppSelector } from '../app/hooks';
|
||||
import { deleteNoteAsync, selectNotesTree, TreeNode } from '../reducer/noteSlice';
|
||||
import { User } from '../reducer/userSlice';
|
||||
import TreeUtil from '../util/TreeUtil';
|
||||
import { confirmDeleteNote, getTitleFromFilename, isFilePath, splitPath } from '../util/util';
|
||||
import StyledTreeItem from './StyledTreeItem';
|
||||
|
||||
export interface Props {
|
||||
user: User | null
|
||||
mobileDrawerOpen?: boolean
|
||||
onDrawerClose?: () => void
|
||||
variant: 'temporary' | 'permanent'
|
||||
}
|
||||
|
||||
const DRAWER_WIDTH = 240;
|
||||
|
||||
const AppDrawer: React.FC<Props> = (props): ReactElement => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { showModal } = useModal();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const getAllSubpath = (path: string): string[] => {
|
||||
const subpath = splitPath(path).map((s, i) => path.split('/').slice(0, i + 1).join('/'));
|
||||
subpath.push('/'); // add root path
|
||||
return subpath;
|
||||
}
|
||||
const path = decodeURIComponent(searchParams.get('path') || "%2F");
|
||||
const [expanded, setExpanded] = React.useState<string[]>(getAllSubpath(path));
|
||||
const tree = useAppSelector(selectNotesTree);
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(getAllSubpath(path));
|
||||
}, [tree, path])
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
if (props.onDrawerClose) props.onDrawerClose();
|
||||
}
|
||||
|
||||
const handleNodeSelect = (e: React.SyntheticEvent, path: string) => {
|
||||
isFilePath(path) ? navigate(`/view?path=${encodeURIComponent(path)}`)
|
||||
: navigate(`/?path=${encodeURIComponent(path)}`);
|
||||
}
|
||||
|
||||
const handleCreate = (e: SyntheticEvent, dirPath: string) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/new?path=${encodeURIComponent(dirPath)}`);
|
||||
}
|
||||
|
||||
const handleEdit = (e: SyntheticEvent, filepath: string) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/edit?path=${encodeURIComponent(filepath)}`);
|
||||
}
|
||||
|
||||
const handleDelete = (e: SyntheticEvent, filepath: string) => {
|
||||
e.stopPropagation();
|
||||
const note = TreeUtil.searchNode(tree, filepath);
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
confirmDeleteNote(showModal, () => dispatch(deleteNoteAsync(note as TreeNode)));
|
||||
}
|
||||
|
||||
const renderTree = (t: TreeNode) => {
|
||||
return (
|
||||
<StyledTreeItem key={t.path} nodeId={t.path || "/"} label={getTitleFromFilename(t.name)} isDir={t.is_dir}
|
||||
endIcon={<ArticleOutlinedIcon />} expandIcon={<FolderOutlinedIcon />} collapseIcon={<FolderOpenOutlinedIcon />}
|
||||
handleEdit={handleEdit} handleDelete={handleDelete} handleCreate={handleCreate}>
|
||||
{Array.isArray(t.children) ? t.children.map((c) => renderTree(c)) : null}
|
||||
</StyledTreeItem>
|
||||
)
|
||||
}
|
||||
const treeJSX = renderTree(tree);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
variant={props.variant}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
open={props.mobileDrawerOpen}
|
||||
onClose={handleDrawerClose}
|
||||
sx={{
|
||||
width: DRAWER_WIDTH,
|
||||
flexShrink: 0,
|
||||
[`& .MuiDrawer-paper`]: { width: DRAWER_WIDTH, boxSizing: 'border-box' },
|
||||
display: { xs: props.variant === 'temporary' ? 'block' : 'none', sm: props.variant === 'temporary' ? 'none' : 'block' }
|
||||
}}>
|
||||
<Toolbar variant="dense" />
|
||||
<TreeView defaultCollapseIcon={<ExpandMoreIcon />} defaultExpandIcon={<ChevronRightIcon />}
|
||||
expanded={expanded} selected={path} onNodeSelect={handleNodeSelect}
|
||||
onNodeToggle={(e, ids) => setExpanded(ids)} sx={{ flexGrow: 1, minWidth: "max-content", width: "100%" }}>
|
||||
{treeJSX}
|
||||
</TreeView>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppDrawer;
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from '@mui/material';
|
||||
import React from 'react';
|
||||
|
||||
type Props = DialogProps & {
|
||||
desc: string
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
const ConfirmDialog: React.FC<Props> = (props: Props) => {
|
||||
const { desc, onConfirm, ...otherProps } = props;
|
||||
return (
|
||||
<Dialog {...otherProps}>
|
||||
<DialogTitle id="confirm-dialog">Please Confirm</DialogTitle>
|
||||
<DialogContent>{desc}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="outlined" onClick={(e) => otherProps.onClose?.(e, "backdropClick")}>CANCEL</Button>
|
||||
<Button variant="contained" onClick={(e) => { onConfirm(); otherProps.onClose?.(e, "backdropClick") }}>YES</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDialog;
|
||||
@@ -0,0 +1,218 @@
|
||||
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import { LoadingButton } from '@mui/lab';
|
||||
import { Alert, Autocomplete, Breadcrumbs, Button, CircularProgress, Container, Link, styled, TextField, Theme } from '@mui/material';
|
||||
import { unwrapResult } from '@reduxjs/toolkit';
|
||||
import React, { FormEvent, ReactElement, useEffect, useState } from 'react';
|
||||
import MDEditor from 'react-markdown-editor-lite';
|
||||
import 'react-markdown-editor-lite/lib/index.css';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAppDispatch, useAppSelector } from '../app/hooks';
|
||||
import { APIStatus, APIStatusType } from '../reducer/common';
|
||||
import { getNoteAsync, resetStatus, saveNoteAsync, selectNoteAPIStatus, selectNotesTree } from '../reducer/noteSlice';
|
||||
import TreeUtil from '../util/TreeUtil';
|
||||
import { appendPath, getDecodedPath, getFilenameFromTitle, getSanitizedErrorMessage, getTitleFromFilename, splitPath, URL_ISSUES } from '../util/util';
|
||||
import CustomReactMarkdown from './lib/CustomReactMarkdown';
|
||||
|
||||
const VALID_DIR_PATH_REGEX = /^((?!\/)([a-zA-Z0-9-]([/]|[^\S\r\n])?)*)([a-zA-Z0-9-])$/gm;
|
||||
const VALID_FILENAME_REGEX = /^([a-zA-Z0-9-]|[^\S\r\n])+(\.md)$/gm;
|
||||
|
||||
const StyledMDEditor = styled(MDEditor)(({ theme }: { theme: Theme }) => ({
|
||||
"&.rc-md-editor.batnoter-md-editor": {
|
||||
margin: "16px 0",
|
||||
height: 375,
|
||||
borderColor: theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
background: 'unset',
|
||||
"& > .rc-md-navigation": {
|
||||
minHeight: 56,
|
||||
background: theme.palette.background.default,
|
||||
borderColor: theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)',
|
||||
borderRadius: `${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0 0`,
|
||||
".button-wrap": {
|
||||
".button": {
|
||||
color: theme.palette.text.disabled,
|
||||
"&:hover": {
|
||||
color: theme.palette.action.active
|
||||
},
|
||||
".drop-wrap": {
|
||||
background: theme.palette.background.default
|
||||
},
|
||||
"& .header-list .list-item:hover": {
|
||||
background: theme.palette.action.hover
|
||||
},
|
||||
margin: "0 5px",
|
||||
},
|
||||
".rmel-iconfont": {
|
||||
fontSize: theme.typography.fontSize + 8
|
||||
}
|
||||
}
|
||||
},
|
||||
"&.batnoter-md-editor .editor-container .sec-md textarea.input": {
|
||||
color: theme.palette.text.primary,
|
||||
background: theme.palette.background.default
|
||||
},
|
||||
"&.error": {
|
||||
borderColor: theme.palette.error.main
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const isLoading = (apiStatus: APIStatus): boolean => {
|
||||
const { getNoteAsync, saveNoteAsync } = apiStatus;
|
||||
return getNoteAsync === APIStatusType.LOADING || saveNoteAsync === APIStatusType.LOADING;
|
||||
}
|
||||
|
||||
const isGetNoteLoading = (apiStatus: APIStatus): boolean => {
|
||||
const { getNoteAsync } = apiStatus;
|
||||
return getNoteAsync === APIStatusType.LOADING;
|
||||
}
|
||||
|
||||
const isFailed = (apiStatus: APIStatus): boolean => {
|
||||
const { getNoteAsync, saveNoteAsync } = apiStatus;
|
||||
return getNoteAsync === APIStatusType.FAIL || saveNoteAsync === APIStatusType.FAIL;
|
||||
}
|
||||
|
||||
const Editor: React.FC = (): ReactElement => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const editMode = pathname.startsWith('/edit');
|
||||
const path = getDecodedPath(searchParams.get('path'));
|
||||
const tree = useAppSelector(selectNotesTree);
|
||||
const apiStatus = useAppSelector(selectNoteAPIStatus);
|
||||
const [errorMessage, setErrorMessage] = React.useState("");
|
||||
|
||||
const [sha, setSHA] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [titleError, setTitleError] = useState(false);
|
||||
const [content, setContent] = useState('');
|
||||
const [contentError, setContentError] = useState(false);
|
||||
const [endDir, setEndDir] = useState('');
|
||||
const [dirPathArray, setDirPathArray] = useState([] as string[]);
|
||||
const [dirPathError, setDirPathError] = useState(false);
|
||||
const [pathAutoCompleteOptions, setPathAutoCompleteOptions] = useState(TreeUtil.getChildDirs(tree, path));
|
||||
|
||||
useEffect(() => {
|
||||
// This should be the first useEffect hook. Declare other useEffect hooks below this one.
|
||||
dispatch(resetStatus());
|
||||
}, [path])
|
||||
|
||||
useEffect(() => {
|
||||
const treeNode = TreeUtil.searchNode(tree, path);
|
||||
const dirPathArray = splitPath(path);
|
||||
editMode && dirPathArray.pop(); // remove the filename from path
|
||||
setDirPathArray(dirPathArray);
|
||||
setPathAutoCompleteOptions(TreeUtil.getChildDirs(tree, path));
|
||||
|
||||
if (treeNode == null || treeNode.is_dir) {
|
||||
return;
|
||||
}
|
||||
dispatch(getNoteAsync(treeNode.path)).then(unwrapResult)
|
||||
.catch(err => setErrorMessage(getSanitizedErrorMessage(err)));
|
||||
|
||||
setSHA(treeNode?.sha || '');
|
||||
setTitle(getTitleFromFilename(treeNode.name));
|
||||
setContent(treeNode?.content || '');
|
||||
}, [tree, path, editMode])
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDirPathError(false);
|
||||
setTitleError(false);
|
||||
setContentError(false);
|
||||
|
||||
const autoSelectedDirPath = dirPathArray.join('/');
|
||||
const dirPath = appendPath(autoSelectedDirPath, endDir);
|
||||
if (dirPath !== "" && !dirPath.match(VALID_DIR_PATH_REGEX)) {
|
||||
setDirPathError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = getFilenameFromTitle(title);
|
||||
if (!filename.match(VALID_FILENAME_REGEX)) {
|
||||
setTitleError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (content === "") {
|
||||
setContentError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = appendPath(dirPath, filename);
|
||||
await dispatch(saveNoteAsync({ path: fullPath, content: content, sha: sha }))
|
||||
.then(unwrapResult).then(() => navigate(`/?path=${encodeURIComponent(dirPath)}`))
|
||||
.catch(err => setErrorMessage(getSanitizedErrorMessage(err)));
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
{isGetNoteLoading(apiStatus) ? <CircularProgress sx={{ position: "relative", top: "50%", left: "50%" }} /> :
|
||||
<form noValidate autoComplete="off" onSubmit={handleSubmit}>
|
||||
{isFailed(apiStatus) && errorMessage &&
|
||||
<Alert severity="error" sx={{ width: "100%" }}>
|
||||
{errorMessage} <span>please try again or <Link href={URL_ISSUES} target="_blank" rel="noopener">create an issue</Link></span>
|
||||
</Alert>}
|
||||
<Autocomplete freeSolo fullWidth multiple openOnFocus value={dirPathArray} options={pathAutoCompleteOptions}
|
||||
disabled={editMode}
|
||||
onChange={(e, newPath) => {
|
||||
setDirPathArray([...newPath]);
|
||||
setPathAutoCompleteOptions(TreeUtil.getChildDirs(tree, newPath.join("/")));
|
||||
}}
|
||||
|
||||
renderTags={(tagValue) => (
|
||||
<Breadcrumbs itemsAfterCollapse={2}>
|
||||
{tagValue.map((option) => (<Link key={option} underline="hover" color="inherit"> {option} </Link>))}
|
||||
<span>{/* just a placeholder to show a / at the end */}</span>
|
||||
</Breadcrumbs>
|
||||
)}
|
||||
|
||||
inputValue={endDir}
|
||||
onInputChange={(e, newInputValue) => {
|
||||
setDirPathError(false);
|
||||
if (newInputValue.indexOf('/') > -1) {
|
||||
const trimmedPath = newInputValue.trim().replace(/^\/+|\/+$/g, '');
|
||||
const pathArray = [...dirPathArray, ...splitPath(trimmedPath)];
|
||||
if (trimmedPath) {
|
||||
setDirPathArray(pathArray);
|
||||
setPathAutoCompleteOptions(TreeUtil.getChildDirs(tree, pathArray.join("/")));
|
||||
}
|
||||
setEndDir('');
|
||||
return;
|
||||
}
|
||||
setEndDir(newInputValue);
|
||||
}}
|
||||
|
||||
renderInput={(params) => (
|
||||
<TextField {...params}
|
||||
helperText="Only alphanumeric characters, space, hyphen (-) and forward slash (/) are allowed."
|
||||
label="Path" variant="outlined" fullWidth error={dirPathError} placeholder="Select Path..." sx={{ my: 2, display: "block" }} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField sx={{ my: 2, display: "block" }}
|
||||
helperText="Only alphanumeric characters, space and hyphen (-) are allowed."
|
||||
value={title} disabled={editMode}
|
||||
onChange={(e) => { setTitleError(false); setTitle(e.target.value) }} label="Note Title"
|
||||
variant="outlined" fullWidth required error={titleError}
|
||||
/>
|
||||
|
||||
<StyledMDEditor view={{ menu: true, md: true, html: false }} canView={{ menu: true, md: true, html: true, fullScreen: false, hideMenu: false, both: true }}
|
||||
value={content}
|
||||
renderHTML={(text: string) => <CustomReactMarkdown>{text}</CustomReactMarkdown>}
|
||||
placeholder="Note Content*" className={"batnoter-md-editor " + (contentError ? "error" : "")}
|
||||
onChange={({ text }: { text: string }) => { setContentError(false); setContent(text) }} />
|
||||
|
||||
<LoadingButton loading={isLoading(apiStatus)} type="submit" variant="contained" startIcon={<SaveIcon />} sx={{ float: 'right' }}>SAVE</LoadingButton>
|
||||
<Button onClick={() => navigate('/')} variant="outlined" sx={{ float: 'right', mx: 1 }} >CANCEL</Button>
|
||||
</form>
|
||||
}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default Editor;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Masonry } from '@mui/lab';
|
||||
import { Alert, CircularProgress, Container, Link } from '@mui/material';
|
||||
import { unwrapResult } from '@reduxjs/toolkit';
|
||||
import { useModal } from 'mui-modal-provider';
|
||||
import React, { ReactElement, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAppDispatch, useAppSelector } from '../app/hooks';
|
||||
import { APIStatus, APIStatusType } from '../reducer/common';
|
||||
import { deleteNoteAsync, getNotesAsync, resetStatus, selectNoteAPIStatus, selectNotesTree, TreeNode } from '../reducer/noteSlice';
|
||||
import TreeUtil from '../util/TreeUtil';
|
||||
import { confirmDeleteNote, getDecodedPath, getSanitizedErrorMessage, URL_ISSUES } from '../util/util';
|
||||
import NoteCard from './NoteCard';
|
||||
|
||||
const isGetNotesLoading = (apiStatus: APIStatus): boolean => {
|
||||
const { getNotesAsync } = apiStatus;
|
||||
return getNotesAsync === APIStatusType.LOADING;
|
||||
}
|
||||
|
||||
const isGetNotesFailed = (apiStatus: APIStatus): boolean => {
|
||||
const { getNotesAsync } = apiStatus;
|
||||
return getNotesAsync === APIStatusType.FAIL;
|
||||
}
|
||||
|
||||
const Finder = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { showModal } = useModal();
|
||||
const tree = useAppSelector(selectNotesTree);
|
||||
const apiStatus = useAppSelector(selectNoteAPIStatus);
|
||||
const [searchParams] = useSearchParams();
|
||||
const path = getDecodedPath(searchParams.get('path'));
|
||||
const [errorMessage, setErrorMessage] = React.useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// This should be the first useEffect hook. Declare other useEffect hooks below this one.
|
||||
dispatch(resetStatus());
|
||||
}, [path])
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getNotesAsync(path)).then(unwrapResult)
|
||||
.catch(err => setErrorMessage(getSanitizedErrorMessage(err)));
|
||||
}, [tree, path])
|
||||
|
||||
const handleDelete = (note: TreeNode) => {
|
||||
confirmDeleteNote(showModal, () => dispatch(deleteNoteAsync(note as TreeNode)));
|
||||
}
|
||||
|
||||
const handleView = (note: TreeNode) => {
|
||||
navigate(`/view?path=${encodeURIComponent(note.path)}`);
|
||||
}
|
||||
|
||||
const handleEdit = (note: TreeNode) => {
|
||||
navigate(`/edit?path=${encodeURIComponent(note.path)}`);
|
||||
}
|
||||
|
||||
const getChildren = (path: string): TreeNode[] | undefined => {
|
||||
const node = TreeUtil.searchNode(tree, path);
|
||||
if (node?.cached) {
|
||||
return node.children;
|
||||
}
|
||||
}
|
||||
|
||||
const notes = getChildren(path) || [] as TreeNode[];
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{isGetNotesFailed(apiStatus) && errorMessage && <Alert severity="error" sx={{ width: "100%", mb: 2 }}>{errorMessage} <span>please try again or <Link href={URL_ISSUES} target="_blank" rel="noopener">create an issue</Link></span></Alert>}
|
||||
|
||||
<Masonry columns={{ xs: 1, md: 3, xl: 4 }} spacing={2}>
|
||||
{isGetNotesLoading(apiStatus) ? <CircularProgress sx={{ position: "relative", top: "50%", left: "50%" }} /> :
|
||||
|
||||
notes.filter(n => !n.is_dir).map(note => (
|
||||
<div key={note.path}> <NoteCard note={note} handleView={handleView} handleEdit={handleEdit} handleDelete={handleDelete} /> </div>
|
||||
))}
|
||||
</Masonry>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Finder;
|
||||
@@ -0,0 +1,59 @@
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
import { LoadingButton } from '@mui/lab';
|
||||
import { Box, Container, Toolbar, Typography } from '@mui/material';
|
||||
import React, { ReactElement, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { getToken } from '../api/api';
|
||||
import { useAppDispatch } from '../app/hooks';
|
||||
import { APIStatusType } from '../reducer/common';
|
||||
import { getUserProfileAsync, User } from '../reducer/userSlice';
|
||||
|
||||
interface Props {
|
||||
user: User | null
|
||||
userAPIStatus: APIStatusType
|
||||
handleLogin: () => void
|
||||
}
|
||||
|
||||
const isLoading = (apiStatus: APIStatusType, user: User | null): boolean => {
|
||||
return apiStatus === APIStatusType.LOADING || user != null;
|
||||
}
|
||||
|
||||
const Login: React.FC<Props> = ({ user, handleLogin, userAPIStatus }): ReactElement => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const loginSuccess = searchParams.get('success') === "true";
|
||||
|
||||
useEffect(() => {
|
||||
user != null && navigate("/", { replace: true });
|
||||
if (loginSuccess) {
|
||||
// user has just completed the oauth login
|
||||
// call getToken api to get the app token and store it in localStorage
|
||||
getToken().then(() => {
|
||||
dispatch(getUserProfileAsync());
|
||||
navigate("/", { replace: true });
|
||||
})
|
||||
}
|
||||
}, [user, loginSuccess]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl">
|
||||
<Toolbar variant="dense" />
|
||||
|
||||
<Box display="flex" sx={{ my: 2 }} alignItems="center" justifyContent={'space-around'}>
|
||||
<Box flexGrow={1} sx={{ mx: 0, my: 2 }} display={{ xs: "none", md: "block" }}>
|
||||
<img style={{ width: '100%', border: "1px solid #80808080", borderRadius: "8px" }} src="/demo.gif" />
|
||||
</Box>
|
||||
<Box flexShrink={0} sx={{ my: 6, ml: 4, p: 2, width: '400px', height: '100%', border: '1px solid grey', borderRadius: 2 }}>
|
||||
<Typography variant="h5" align="center">GET STARTED</Typography>
|
||||
<p>Welcome to BatNoter 🎉. Please login with your github account to start using the application</p>
|
||||
<LoadingButton onClick={() => handleLogin()}
|
||||
loading={isLoading(userAPIStatus, user)} fullWidth sx={{ my: 2 }}
|
||||
variant="contained" startIcon={<GitHubIcon />}>Login with Github</LoadingButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,105 @@
|
||||
|
||||
import { CircularProgress, Toolbar } from '@mui/material';
|
||||
import Box from '@mui/material/Box';
|
||||
import Container from '@mui/material/Container';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import { Outlet, Route, Routes } from 'react-router-dom';
|
||||
import { API_URL } from '../api/api';
|
||||
import { useAppDispatch, useAppSelector } from '../app/hooks';
|
||||
import { APIStatusType } from '../reducer/common';
|
||||
import { getNotesAsync, getNotesTreeAsync } from '../reducer/noteSlice';
|
||||
import { getUserProfileAsync, selectUser, selectUserAPIStatus, userLoading, userLogout } from '../reducer/userSlice';
|
||||
import ErrorPage from "./404";
|
||||
import AppBar from './AppBar';
|
||||
import AppDrawer, { Props } from './AppDrawer';
|
||||
import Editor from './Editor';
|
||||
import Finder from './Finder';
|
||||
import RequireAuth from './lib/RequireAuth';
|
||||
import Login from './Login';
|
||||
import RepoSetupDialog from './RepoSetupDialog';
|
||||
import Settings from './Settings';
|
||||
import Viewer from './Viewer';
|
||||
const DrawerLayout: React.FC<Omit<Props, 'variant'>> = (props): ReactElement => {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexGrow: 1 }}>
|
||||
<AppDrawer user={props.user} variant={'permanent'} />
|
||||
<AppDrawer user={props.user} mobileDrawerOpen={props.mobileDrawerOpen} onDrawerClose={props.onDrawerClose} variant={'temporary'} />
|
||||
<Box component="main" sx={{
|
||||
flexGrow: 1, height: '100vh', overflow: 'auto'
|
||||
}}>
|
||||
<Toolbar variant="dense" />
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Outlet />
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isUserAPILoading = (userAPIStatus: APIStatusType): boolean => {
|
||||
return userAPIStatus === APIStatusType.LOADING;
|
||||
}
|
||||
|
||||
const Main: React.FC = (): ReactElement => {
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector(selectUser);
|
||||
const userAPIStatus = useAppSelector(selectUserAPIStatus);
|
||||
const [apiTriggered, setAPITriggered] = useState(false);
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = React.useState(false);
|
||||
|
||||
const handleLogin = () => {
|
||||
dispatch(userLoading());
|
||||
window.location.href = API_URL + "/oauth2/login/github";
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
dispatch(userLogout());
|
||||
}
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setMobileDrawerOpen(false);
|
||||
}
|
||||
|
||||
const handleDrawerToggle= () => {
|
||||
setMobileDrawerOpen(!mobileDrawerOpen);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getUserProfileAsync());
|
||||
setAPITriggered(true);
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (userAPIStatus == APIStatusType.IDLE && user != null) {
|
||||
await dispatch(getNotesTreeAsync());
|
||||
dispatch(getNotesAsync(""));
|
||||
}
|
||||
})()
|
||||
}, [userAPIStatus, user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar userAPIStatus={userAPIStatus} handleLogin={handleLogin} handleLogout={handleLogout} user={user} onDrawerToggle={handleDrawerToggle} />
|
||||
<Container maxWidth="xl">
|
||||
{user != null && !user?.default_repo?.name && <RepoSetupDialog open={true}></RepoSetupDialog>}
|
||||
{
|
||||
!apiTriggered || isUserAPILoading(userAPIStatus) ? <CircularProgress color="inherit" sx={{ ml: '50%', mt: 10 }} /> :
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login userAPIStatus={userAPIStatus} handleLogin={handleLogin} user={user} />} />
|
||||
<Route path="/" element={<DrawerLayout user={user} mobileDrawerOpen={mobileDrawerOpen} onDrawerClose={handleDrawerClose} />} >
|
||||
<Route index element={<RequireAuth user={user}><Finder /></RequireAuth>} />
|
||||
<Route path="/new" element={<RequireAuth user={user}><Editor key={'new'} /></RequireAuth>} />
|
||||
<Route path="/edit" element={<RequireAuth user={user}><Editor key={'edit'} /></RequireAuth>} />
|
||||
<Route path="/view" element={<RequireAuth user={user}><Viewer key={'view'} /></RequireAuth>} />
|
||||
<Route path="/settings" element={<RequireAuth user={user}><Settings user={user} /></RequireAuth>} />
|
||||
</Route>
|
||||
<Route path="*" element={<ErrorPage />} />
|
||||
</Routes>
|
||||
}
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Main;
|
||||
@@ -0,0 +1,40 @@
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { Button, Card, CardActions, CardContent, CardHeader, IconButton } from "@mui/material";
|
||||
import React, { ReactElement } from 'react';
|
||||
import { TreeNode } from "../reducer/noteSlice";
|
||||
import { getTitleFromFilename } from "../util/util";
|
||||
import CustomReactMarkdown from "./lib/CustomReactMarkdown";
|
||||
|
||||
interface Props {
|
||||
note: TreeNode
|
||||
handleView: (note: TreeNode) => void
|
||||
handleEdit: (note: TreeNode) => void
|
||||
handleDelete: (note: TreeNode) => void
|
||||
}
|
||||
|
||||
const MAX_CARD_TEXT_LENGTH = 300;
|
||||
|
||||
const NoteCard: React.FC<Props> = ({ note, handleView, handleEdit, handleDelete }): ReactElement => {
|
||||
const getCardText = (text?: string): string => {
|
||||
if (text == null) return '';
|
||||
return text.substring(0, MAX_CARD_TEXT_LENGTH) + (text.length > MAX_CARD_TEXT_LENGTH ? '...' : '');
|
||||
}
|
||||
return (
|
||||
<Card elevation={1}>
|
||||
<CardHeader action={
|
||||
<>
|
||||
<IconButton sx={{ "&:hover": { color: "red" } }} onClick={() => handleDelete(note)}> <DeleteIcon /> </IconButton>
|
||||
</>
|
||||
} title={getTitleFromFilename(note.name)} />
|
||||
<CardContent>
|
||||
<CustomReactMarkdown className='custom-html-style'>{getCardText(note.content)}</CustomReactMarkdown>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button onClick={() => handleView(note)} size="small">VIEW</Button>
|
||||
<Button onClick={() => handleEdit(note)} size="small">EDIT</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default NoteCard;
|
||||
@@ -0,0 +1,86 @@
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||
import { SourceBranch } from 'mdi-material-ui';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../app/hooks';
|
||||
import { APIStatus, APIStatusType } from '../reducer/common';
|
||||
import { getUserReposAsync, saveDefaultRepoAsync, selectPreferenceAPIStatus, selectUserRepos } from '../reducer/preferenceSlice';
|
||||
import { getUserProfileAsync } from '../reducer/userSlice';
|
||||
|
||||
interface Props {
|
||||
defaultRepo?: string
|
||||
open: boolean
|
||||
setOpen?: (isOpen: boolean) => void
|
||||
}
|
||||
|
||||
const isLoading = (apiStatus: APIStatus): boolean => {
|
||||
const { getUserReposAsync, saveDefaultRepoAsync } = apiStatus;
|
||||
return getUserReposAsync === APIStatusType.LOADING || saveDefaultRepoAsync === APIStatusType.LOADING;
|
||||
}
|
||||
|
||||
const RepoSelectDialog: React.FC<Props> = ({ open, setOpen, defaultRepo }): ReactElement => {
|
||||
const dispatch = useAppDispatch();
|
||||
React.useEffect(() => {
|
||||
dispatch(getUserReposAsync())
|
||||
}, [])
|
||||
const repos = useAppSelector(selectUserRepos);
|
||||
const apiStatus = useAppSelector(selectPreferenceAPIStatus);
|
||||
|
||||
const [repoName, setDefaultRepoName] = React.useState<string>();
|
||||
const [alertOpen, setDefaultAlertOpen] = React.useState<boolean>();
|
||||
|
||||
const handleChange = (event: SelectChangeEvent<typeof repoName>) => {
|
||||
setDefaultRepoName(String(event.target.value) || '');
|
||||
|
||||
const visibility = repos.filter(r => r.name === String(event.target.value))[0]['visibility']
|
||||
setDefaultAlertOpen(visibility === 'public')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const selectedRepo = repos.filter(r => r.name === repoName)[0]
|
||||
await dispatch(saveDefaultRepoAsync(selectedRepo))
|
||||
await dispatch(getUserProfileAsync())
|
||||
setOpen && setOpen(false)
|
||||
}
|
||||
|
||||
const handleClose = (event: React.SyntheticEvent<unknown>, reason?: string) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
setOpen && setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog disableEscapeKeyDown open={open} onClose={handleClose} fullWidth>
|
||||
<DialogTitle>Select Notes Repository</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box component="form" sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
<FormControl fullWidth sx={{ m: 1, minWidth: 120 }}>
|
||||
<InputLabel id="notes-repo-select-label">Notes Repository</InputLabel>
|
||||
<Select autoWidth labelId="notes-repo-select-label" value={repoName || defaultRepo} onChange={handleChange} disabled={isLoading(apiStatus)} label="Notes Repository">
|
||||
{repos.map(r => <MenuItem key={r.name} value={r.name}>{r.name} (<SourceBranch sx={{ verticalAlign: 'middle' }} fontSize='inherit' /> {r.default_branch || 'main'})</MenuItem>)}
|
||||
</Select>
|
||||
<Collapse in={alertOpen}>
|
||||
<Alert sx={{ my: 1 }} severity="warning">You've selected a public repository. Notes could be accessed publicly.</Alert>
|
||||
</Collapse>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button disabled={!repoName || isLoading(apiStatus)} onClick={() => handleSave()}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default RepoSelectDialog;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Alert, Link, Typography } from '@mui/material';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import { unwrapResult } from '@reduxjs/toolkit';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../app/hooks';
|
||||
import { APIStatus, APIStatusType } from '../reducer/common';
|
||||
import { autoSetupRepoAsync, selectPreferenceAPIStatus } from '../reducer/preferenceSlice';
|
||||
import { getUserProfileAsync } from '../reducer/userSlice';
|
||||
import { getSanitizedErrorMessage, URL_ISSUES } from '../util/util';
|
||||
import RepoSelectDialog from './RepoSelectDialog';
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
setOpen?: (isOpen: boolean) => void
|
||||
}
|
||||
|
||||
const autoSetupRepoName = "notes";
|
||||
|
||||
const isLoading = (apiStatus: APIStatus): boolean => {
|
||||
const { autoSetupRepoAsync } = apiStatus;
|
||||
return autoSetupRepoAsync === APIStatusType.LOADING;
|
||||
}
|
||||
|
||||
const isFailed = (apiStatus: APIStatus): boolean => {
|
||||
const { autoSetupRepoAsync } = apiStatus;
|
||||
return autoSetupRepoAsync === APIStatusType.FAIL;
|
||||
}
|
||||
|
||||
const RepoSetupDialog: React.FC<Props> = ({ open, setOpen }): ReactElement => {
|
||||
const [openRepoSelectDialog, setOpenRepoSelectDialog] = React.useState(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState("");
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const apiStatus = useAppSelector(selectPreferenceAPIStatus);
|
||||
|
||||
const handleRepoSelect = () => {
|
||||
setOpenRepoSelectDialog(true);
|
||||
}
|
||||
|
||||
const handleAutoSetupRepo = async () => {
|
||||
await dispatch(autoSetupRepoAsync(autoSetupRepoName)).then(unwrapResult)
|
||||
.catch(err => setErrorMessage(getSanitizedErrorMessage(err)));
|
||||
await dispatch(getUserProfileAsync());
|
||||
setOpen && setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog disableEscapeKeyDown open={open} fullWidth>
|
||||
<DialogTitle>Setup Notes Repository</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
{isFailed(apiStatus) && <Alert severity="error" sx={{ width: "100%" }}>{errorMessage} <span>please try again or <Link href={URL_ISSUES} target="_blank" rel="noopener">create an issue</Link></span></Alert>}
|
||||
<Typography gutterBottom paragraph>
|
||||
You may choose to automatically setup your notes repository or manually select an existing repository for storing notes.
|
||||
The automatic setup will create a new private repository "{autoSetupRepoName}" and set it as your notes repository.
|
||||
</Typography>
|
||||
<Typography gutterBottom paragraph>
|
||||
Do you want to setup the notes repository automatically?
|
||||
</Typography>
|
||||
|
||||
{isFailed(apiStatus) && <Alert severity="warning" sx={{ width: "100%" }}>
|
||||
If you already have repository with name: "{autoSetupRepoName}" Then please use SELECT EXISTING REPO option.
|
||||
</Alert>}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button disabled={isLoading(apiStatus)} onClick={() => handleRepoSelect()}>SELECT EXISTING REPO</Button>
|
||||
<Button disabled={isLoading(apiStatus)} onClick={() => handleAutoSetupRepo()}>YES, SETUP AUTOMATICALLY</Button>
|
||||
</DialogActions>
|
||||
<RepoSelectDialog open={openRepoSelectDialog} setOpen={setOpenRepoSelectDialog} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default RepoSetupDialog;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Avatar, Button, Container, Grid, Typography } from '@mui/material'
|
||||
import { SourceBranch } from 'mdi-material-ui'
|
||||
import React, { ReactElement } from 'react'
|
||||
import { User } from '../reducer/userSlice'
|
||||
import RepoSelectDialog from './RepoSelectDialog'
|
||||
|
||||
interface Props {
|
||||
user: User | null
|
||||
}
|
||||
|
||||
const Settings: React.FC<Props> = ({ user }): ReactElement => {
|
||||
const [openRepoSelectDialog, setOpenRepoSelectDialog] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Grid container direction="column" textAlign="center">
|
||||
<Grid container direction="column">
|
||||
<Grid flexGrow={1} sx={{ backgroundImage: `url('${user?.avatar_url}')`, backgroundPosition: "center", filter: "blur(30px)", height: "150px" }} ></Grid>
|
||||
<Avatar alt={user?.name} src={user?.avatar_url} sx={{ width: 100, height: 100, alignSelf: "center", marginTop: "-100px" }} />
|
||||
</Grid>
|
||||
<Grid container direction="column" marginY={2}>
|
||||
<Typography m={0} variant="h5" gutterBottom component="div"> {user?.name || user?.email} </Typography>
|
||||
<Typography color="text.secondary" variant="body1" gutterBottom component="div"> {user?.location} </Typography>
|
||||
{user?.default_repo?.default_branch && <Typography color="text.secondary" m={0} variant="h6" gutterBottom component="div">Notes Repository: {user?.default_repo?.name} (<SourceBranch sx={{ verticalAlign: 'middle' }} fontSize='inherit' /> {user?.default_repo?.default_branch})</Typography>}
|
||||
<Button onClick={() => setOpenRepoSelectDialog(true)}>Change Notes Repository</Button>
|
||||
<RepoSelectDialog open={openRepoSelectDialog} setOpen={setOpenRepoSelectDialog} defaultRepo={user?.default_repo?.name} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
@@ -0,0 +1,44 @@
|
||||
import styled from '@emotion/styled';
|
||||
import AddBoxIcon from '@mui/icons-material/AddBox';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import { TreeItem, TreeItemProps } from '@mui/lab';
|
||||
import { IconButton } from '@mui/material';
|
||||
import React, { SyntheticEvent } from 'react';
|
||||
|
||||
type StyledTreeItemProps = TreeItemProps & {
|
||||
isDir: boolean,
|
||||
handleCreate: (e: SyntheticEvent, dirPath: string) => void,
|
||||
handleEdit: (e: SyntheticEvent, filepath: string) => void,
|
||||
handleDelete: (e: SyntheticEvent, filepath: string) => void
|
||||
}
|
||||
|
||||
const StyledTreeItem = styled((props: StyledTreeItemProps) => {
|
||||
const { isDir, handleCreate, handleEdit, handleDelete, ...otherProps } = props;
|
||||
return (
|
||||
<TreeItem {...otherProps} label={
|
||||
<>{otherProps.label + ' '}
|
||||
{isDir ? <IconButton size="small" onClick={(e) => handleCreate(e, otherProps.nodeId)}><AddBoxIcon sx={{ display: 'none', verticalAlign: 'text-bottom' }} fontSize='inherit' /> </IconButton> :
|
||||
<>
|
||||
<IconButton size="small" onClick={(e) => handleEdit(e, otherProps.nodeId)}><EditIcon sx={{ display: 'none', verticalAlign: 'text-bottom' }} fontSize='inherit' /></IconButton>
|
||||
<IconButton size="small" onClick={(e) => handleDelete(e, otherProps.nodeId)}><DeleteIcon className="delete" sx={{ display: 'none', verticalAlign: 'text-bottom' }} fontSize='inherit' /></IconButton>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
} />
|
||||
)
|
||||
})(() => ({
|
||||
[`& .MuiTreeItem-content .MuiTreeItem-label`]: {
|
||||
'&:hover svg': {
|
||||
display: 'inline-block',
|
||||
},
|
||||
'& svg:hover': {
|
||||
color: 'blue'
|
||||
},
|
||||
'& svg.delete:hover': {
|
||||
color: 'red'
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export default StyledTreeItem;
|
||||
@@ -0,0 +1,98 @@
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import NotesIcon from '@mui/icons-material/Notes';
|
||||
import { Alert, Box, Breadcrumbs, Button, CircularProgress, Container, Divider, Grid, Link } from "@mui/material";
|
||||
import { unwrapResult } from '@reduxjs/toolkit';
|
||||
import { useModal } from 'mui-modal-provider';
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useAppDispatch, useAppSelector } from "../app/hooks";
|
||||
import { APIStatus, APIStatusType } from '../reducer/common';
|
||||
import { deleteNoteAsync, getNoteAsync, resetStatus, selectNoteAPIStatus, selectNotesTree, TreeNode } from "../reducer/noteSlice";
|
||||
import TreeUtil from '../util/TreeUtil';
|
||||
import { confirmDeleteNote, getDecodedPath, getSanitizedErrorMessage, getTitleFromFilename, splitPath, URL_ISSUES } from "../util/util";
|
||||
import CustomReactMarkdown from './lib/CustomReactMarkdown';
|
||||
|
||||
const isLoading = (apiStatus: APIStatus): boolean => {
|
||||
const { getNoteAsync, deleteNoteAsync } = apiStatus;
|
||||
return getNoteAsync === APIStatusType.LOADING || deleteNoteAsync === APIStatusType.LOADING;
|
||||
}
|
||||
|
||||
const isGetNoteLoading = (apiStatus: APIStatus): boolean => {
|
||||
const { getNoteAsync } = apiStatus;
|
||||
return getNoteAsync === APIStatusType.LOADING;
|
||||
}
|
||||
|
||||
const isFailed = (apiStatus: APIStatus): boolean => {
|
||||
const { getNoteAsync, deleteNoteAsync } = apiStatus;
|
||||
return getNoteAsync === APIStatusType.FAIL || deleteNoteAsync === APIStatusType.FAIL;
|
||||
}
|
||||
|
||||
const Viewer: React.FC = (): ReactElement => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { showModal } = useModal();
|
||||
|
||||
const [note, setNote] = useState<TreeNode>()
|
||||
const [searchParams] = useSearchParams();
|
||||
const path = getDecodedPath(searchParams.get('path'));
|
||||
const tree = useAppSelector(selectNotesTree);
|
||||
const apiStatus = useAppSelector(selectNoteAPIStatus);
|
||||
const [errorMessage, setErrorMessage] = React.useState("");
|
||||
const dirPathArray = splitPath(path);
|
||||
const title = getTitleFromFilename(dirPathArray.pop() || '');
|
||||
|
||||
const handleDelete = () => {
|
||||
confirmDeleteNote(showModal, () => {
|
||||
dispatch(deleteNoteAsync(note as TreeNode)).then(unwrapResult)
|
||||
.then(() => navigate(`/?path=${encodeURIComponent(dirPathArray.join('/'))}`))
|
||||
.catch(err => setErrorMessage(getSanitizedErrorMessage(err)));
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// This should be the first useEffect hook. Declare other useEffect hooks below this one.
|
||||
dispatch(resetStatus());
|
||||
}, [path])
|
||||
|
||||
useEffect(() => {
|
||||
const treeNode = TreeUtil.searchNode(tree, path);
|
||||
if (treeNode == null || treeNode.is_dir) {
|
||||
return;
|
||||
}
|
||||
dispatch(getNoteAsync(treeNode.path)).then(unwrapResult)
|
||||
.catch(err => setErrorMessage(getSanitizedErrorMessage(err)));
|
||||
setNote(treeNode);
|
||||
}, [tree, path])
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg">{isGetNoteLoading(apiStatus) ? <CircularProgress sx={{ position: "relative", top: "50%", left: "50%" }} /> :
|
||||
<Box>
|
||||
<Grid container direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Breadcrumbs itemsAfterCollapse={2} sx={{ fontSize: '1.2rem' }}>
|
||||
<Link key="root" underline="hover" color="inherit"><FolderIcon fontSize="medium" sx={{ mr: 0.5, verticalAlign: 'middle', }} />root</Link>
|
||||
{dirPathArray.map((option) => (<Link key={option} underline="hover" color="inherit"> {option} </Link>))}
|
||||
</Breadcrumbs>
|
||||
<NotesIcon color="inherit" fontSize="medium" sx={{ mr: 0.5, verticalAlign: 'middle', }} />{title}
|
||||
</Box>
|
||||
<Box>
|
||||
<Button onClick={() => navigate('/')} variant="outlined" startIcon={<ArrowBackIcon />}>BACK</Button>
|
||||
<Button onClick={() => navigate(`/edit?path=${encodeURIComponent(note?.path || '')}`)} disabled={isLoading(apiStatus)} variant="contained" sx={{ mx: 2 }} startIcon={<EditIcon />}>EDIT</Button>
|
||||
<Button onClick={() => handleDelete()} disabled={isLoading(apiStatus)} variant="contained" startIcon={<DeleteIcon />} color="error">DELETE</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
{isFailed(apiStatus) && errorMessage && <Alert severity="error" sx={{ width: "100%", mb: 2 }}>{errorMessage} <span>please try again or <Link href={URL_ISSUES} target="_blank" rel="noopener">create an issue</Link></span></Alert>}
|
||||
<Box className='viewer-markdown' sx={{ p: 2 }}>
|
||||
<CustomReactMarkdown className='custom-html-style'>{note?.content || ''}</CustomReactMarkdown>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Viewer;
|
||||
@@ -0,0 +1,65 @@
|
||||
import ContentCopyOutlinedIcon from '@mui/icons-material/ContentCopyOutlined';
|
||||
import { styled, Theme } from '@mui/material';
|
||||
import React, { ReactElement } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
const StyledReactMarkdown = styled(ReactMarkdown)(
|
||||
({ theme }: { theme: Theme }) => ({
|
||||
position: "relative",
|
||||
color: theme.palette.text.secondary,
|
||||
pre: {
|
||||
display: "flex",
|
||||
backgroundColor: theme.palette.action.disabledBackground,
|
||||
svg: {
|
||||
opacity: 0.5,
|
||||
"&:hover": {
|
||||
opacity: 1
|
||||
},
|
||||
},
|
||||
code: {
|
||||
backgroundColor: "unset",
|
||||
borderRadius: 2
|
||||
},
|
||||
},
|
||||
"code": {
|
||||
backgroundColor: theme.palette.action.disabledBackground,
|
||||
borderRadius: 2,
|
||||
padding: 4
|
||||
},
|
||||
"blockquote": {
|
||||
color: theme.palette.mode === 'light' ? theme.palette.text.primary : theme.palette.text.secondary,
|
||||
borderColor: theme.palette.action.disabledBackground
|
||||
},
|
||||
"table": {
|
||||
"thead > tr > th": {
|
||||
backgroundColor: theme.palette.action.disabledBackground,
|
||||
},
|
||||
"&, thead > tr > th, tbody > tr > td": {
|
||||
borderColor: theme.palette.divider,
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const CustomReactMarkdown: React.FC<ReactMarkdownOptions> = (props: ReactMarkdownOptions): ReactElement => {
|
||||
return (
|
||||
<StyledReactMarkdown {...props}
|
||||
components={{
|
||||
code({ inline, className, children, ...props }) {
|
||||
return (
|
||||
<>
|
||||
<code className={className} {...props}>{children}</code>
|
||||
{!inline && <ContentCopyOutlinedIcon style={{ right: 5, position: "absolute", cursor: 'pointer' }}
|
||||
onClick={() => { navigator.clipboard.writeText(String(children)) }} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}>
|
||||
{props.children}
|
||||
</StyledReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomReactMarkdown;
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { User } from '../../reducer/userSlice';
|
||||
|
||||
const RequireAuth = ({ user, children }: { user: User | null, children: JSX.Element }) => {
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export default RequireAuth;
|
||||
@@ -0,0 +1,11 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
||||
"Droid Sans", "Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.scss';
|
||||
import App from './App';
|
||||
import { store } from './app/store';
|
||||
import { Provider } from 'react-redux';
|
||||
import * as serviceWorker from './serviceWorker';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
||||
// If you want your app to work offline and load faster, you can change
|
||||
// unregister() to register() below. Note this comes with some pitfalls.
|
||||
// Learn more about service workers: https://bit.ly/CRA-PWA
|
||||
serviceWorker.unregister();
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g fill="#764ABC"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum APIStatusType { LOADING, IDLE, FAIL }
|
||||
|
||||
export interface APIStatus {
|
||||
[asyncName: string]: APIStatusType
|
||||
}
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
@@ -0,0 +1,227 @@
|
||||
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
|
||||
import { deleteNote, getAllNotes, getNote, getNotesTree, saveNote, searchNotes } from "../api/api";
|
||||
import { RootState } from "../app/store";
|
||||
import TreeUtil from "../util/TreeUtil";
|
||||
import { APIStatus, APIStatusType } from "./common";
|
||||
|
||||
export interface SearchParams {
|
||||
page?: number
|
||||
path?: string
|
||||
query?: string
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
name: string
|
||||
sha?: string
|
||||
path: string
|
||||
content?: string
|
||||
size?: number
|
||||
is_dir: boolean
|
||||
cached: boolean
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
export interface NoteResponsePayload {
|
||||
sha: string
|
||||
path: string
|
||||
content: string
|
||||
size: number
|
||||
is_dir: boolean
|
||||
}
|
||||
|
||||
export interface NotePage {
|
||||
total: number
|
||||
notes: NoteResponsePayload[]
|
||||
}
|
||||
|
||||
interface NoteState {
|
||||
page: NotePage
|
||||
tree: TreeNode
|
||||
current: NoteResponsePayload | null
|
||||
status: APIStatus
|
||||
}
|
||||
|
||||
const initialState: NoteState = {
|
||||
page: {
|
||||
total: 1,
|
||||
notes: []
|
||||
},
|
||||
tree: {
|
||||
name: "root",
|
||||
path: "",
|
||||
cached: false,
|
||||
is_dir: true
|
||||
},
|
||||
current: null,
|
||||
status: {
|
||||
searchNotesAsync: APIStatusType.IDLE,
|
||||
getNotesTreeAsync: APIStatusType.IDLE,
|
||||
getNotesAsync: APIStatusType.IDLE,
|
||||
getNoteAsync: APIStatusType.IDLE,
|
||||
saveNoteAsync: APIStatusType.IDLE,
|
||||
deleteNoteAsync: APIStatusType.IDLE,
|
||||
}
|
||||
}
|
||||
|
||||
export const searchNotesAsync = createAsyncThunk(
|
||||
'note/searchNotes',
|
||||
async (params?: SearchParams) => {
|
||||
const response = await searchNotes(params?.page, params?.path, params?.query);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const getNotesTreeAsync = createAsyncThunk(
|
||||
'note/fetchNotesTree',
|
||||
async () => {
|
||||
const response = await getNotesTree() as NoteResponsePayload[];
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const getNotesAsync = createAsyncThunk(
|
||||
'note/fetchNotes',
|
||||
async (path: string) => {
|
||||
const response = await getAllNotes(path) as NoteResponsePayload[];
|
||||
return response;
|
||||
}, {
|
||||
condition: (path, { getState }) => {
|
||||
const state = getState() as RootState;
|
||||
const node = TreeUtil.searchNode(state.notes.tree, path);
|
||||
const hasFiles = !!(node?.children && node.children.find(o => !o.is_dir));
|
||||
return !node?.cached && hasFiles;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getNoteAsync = createAsyncThunk(
|
||||
'note/fetchNote',
|
||||
async (path: string) => {
|
||||
const response = await getNote(path) as NoteResponsePayload;
|
||||
return response;
|
||||
}, {
|
||||
condition: (path, { getState }) => {
|
||||
const state = getState() as RootState;
|
||||
const node = TreeUtil.searchNode(state.notes.tree, path);
|
||||
return !node?.cached;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const saveNoteAsync = createAsyncThunk(
|
||||
'note/saveNote',
|
||||
async ({ path, content, sha }: { path: string, content: string, sha?: string }) => {
|
||||
const response = await saveNote(path, content, sha) as NoteResponsePayload;
|
||||
return {
|
||||
...response,
|
||||
content: content
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteNoteAsync = createAsyncThunk(
|
||||
'note/deleteNote',
|
||||
async (note: TreeNode) => {
|
||||
await deleteNote(note.path, note.sha);
|
||||
return note;
|
||||
}
|
||||
);
|
||||
|
||||
export const noteSlice = createSlice({
|
||||
name: "notes",
|
||||
initialState,
|
||||
reducers: {
|
||||
resetStatus: (state) => { state.status = initialState.status; }
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(searchNotesAsync.pending, (state) => {
|
||||
state.status.searchNotesAsync = APIStatusType.LOADING;
|
||||
})
|
||||
.addCase(searchNotesAsync.fulfilled, (state, action) => {
|
||||
state.page = action.payload as NotePage;
|
||||
const tree = TreeUtil.parse(state.tree, state.page.notes, true);
|
||||
state.tree = tree;
|
||||
state.status.searchNotesAsync = APIStatusType.IDLE;
|
||||
})
|
||||
.addCase(searchNotesAsync.rejected, (state) => {
|
||||
state.page = initialState.page;
|
||||
state.status.searchNotesAsync = APIStatusType.FAIL;
|
||||
})
|
||||
|
||||
.addCase(getNotesTreeAsync.pending, (state) => {
|
||||
state.status.getNotesTreeAsync = APIStatusType.LOADING;
|
||||
})
|
||||
.addCase(getNotesTreeAsync.fulfilled, (state, action) => {
|
||||
state.page.notes = action.payload;
|
||||
const tree = TreeUtil.parse(initialState.tree, state.page.notes, false);
|
||||
state.tree = tree;
|
||||
state.status.getNotesTreeAsync = APIStatusType.IDLE;
|
||||
})
|
||||
.addCase(getNotesTreeAsync.rejected, (state) => {
|
||||
state.page.notes = initialState.page.notes;
|
||||
state.status.getNotesTreeAsync = APIStatusType.FAIL;
|
||||
})
|
||||
|
||||
.addCase(getNotesAsync.pending, (state) => {
|
||||
state.status.getNotesAsync = APIStatusType.LOADING;
|
||||
})
|
||||
.addCase(getNotesAsync.fulfilled, (state, action) => {
|
||||
state.page.notes = action.payload;
|
||||
const tree = TreeUtil.parse(state.tree, state.page.notes, true);
|
||||
state.tree = tree;
|
||||
state.status.getNotesAsync = APIStatusType.IDLE;
|
||||
})
|
||||
.addCase(getNotesAsync.rejected, (state) => {
|
||||
state.page.notes = initialState.page.notes;
|
||||
state.status.getNotesAsync = APIStatusType.FAIL;
|
||||
})
|
||||
|
||||
.addCase(getNoteAsync.pending, (state) => {
|
||||
state.current = null
|
||||
state.status.getNoteAsync = APIStatusType.LOADING;
|
||||
})
|
||||
.addCase(getNoteAsync.fulfilled, (state, action) => {
|
||||
state.current = action.payload;
|
||||
const tree = TreeUtil.parse(state.tree, [action.payload]);
|
||||
state.tree = tree;
|
||||
state.status.getNoteAsync = APIStatusType.IDLE;
|
||||
})
|
||||
.addCase(getNoteAsync.rejected, (state) => {
|
||||
state.status.getNoteAsync = APIStatusType.FAIL;
|
||||
})
|
||||
|
||||
.addCase(saveNoteAsync.pending, (state) => {
|
||||
state.status.saveNoteAsync = APIStatusType.LOADING;
|
||||
})
|
||||
.addCase(saveNoteAsync.fulfilled, (state, action) => {
|
||||
state.page.notes = state.page.notes.filter(n => n.sha !== action.payload.sha)
|
||||
state.page.notes.push(action.payload)
|
||||
const tree = TreeUtil.parse(state.tree, [action.payload]);
|
||||
state.tree = tree;
|
||||
state.status.saveNoteAsync = APIStatusType.IDLE;
|
||||
})
|
||||
.addCase(saveNoteAsync.rejected, (state) => {
|
||||
state.status.saveNoteAsync = APIStatusType.FAIL;
|
||||
})
|
||||
|
||||
.addCase(deleteNoteAsync.pending, (state) => {
|
||||
state.status.deleteNoteAsync = APIStatusType.LOADING;
|
||||
})
|
||||
.addCase(deleteNoteAsync.fulfilled, (state, action) => {
|
||||
state.page.notes = state.page.notes.filter(n => n.path !== action.payload.path)
|
||||
TreeUtil.deleteNode(state.tree, action.payload.path)
|
||||
state.status.deleteNoteAsync = APIStatusType.IDLE;
|
||||
})
|
||||
.addCase(deleteNoteAsync.rejected, (state) => {
|
||||
state.status.deleteNoteAsync = APIStatusType.FAIL;
|
||||
});
|
||||
},
|
||||
})
|
||||
|
||||
export const { resetStatus } = noteSlice.actions;
|
||||
export const selectCurrentNote = (state: RootState): NoteResponsePayload | null => state.notes.current;
|
||||
export const selectNotesPage = (state: RootState): NotePage => state.notes.page;
|
||||
export const selectNotesTree = (state: RootState): TreeNode => state.notes.tree;
|
||||
export const selectNoteAPIStatus = (state: RootState): APIStatus => state.notes.status;
|
||||
export default noteSlice.reducer;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { autoSetupRepo, getUserRepos, saveDefaultRepo } from "../api/api";
|
||||
import { RootState } from "../app/store";
|
||||
import { APIStatus, APIStatusType, ThemeMode } from "./common";
|
||||
|
||||
export interface Repo {
|
||||
name: string
|
||||
visibility: string
|
||||
default_branch?: string
|
||||
}
|
||||
|
||||
interface PreferenceState {
|
||||
userRepos: Repo[]
|
||||
status: APIStatus
|
||||
themeMode: ThemeMode
|
||||
}
|
||||
|
||||
const initialState: PreferenceState = {
|
||||
userRepos: [],
|
||||
status: {
|
||||
getUserReposAsync: APIStatusType.IDLE,
|
||||
autoSetupRepoAsync: APIStatusType.IDLE,
|
||||
saveDefaultRepoAsync: APIStatusType.IDLE,
|
||||
},
|
||||
themeMode: 'system',
|
||||
}
|
||||
|
||||
export const getUserReposAsync = createAsyncThunk(
|
||||
'user/fetchUserRepos',
|
||||
async () => {
|
||||
const response = await getUserRepos();
|
||||
// returned value becomes the `fulfilled` action payload
|
||||
return response;
|
||||
}
|
||||
)
|
||||
|
||||
export const autoSetupRepoAsync = createAsyncThunk(
|
||||
'user/autoSetupRepo',
|
||||
async (repoName: string) => {
|
||||
await autoSetupRepo(repoName);
|
||||
}
|
||||
);
|
||||
|
||||
export const saveDefaultRepoAsync = createAsyncThunk(
|
||||
'user/saveDefaultRepo',
|
||||
async (defaultRepo: Repo) => {
|
||||
await saveDefaultRepo(defaultRepo);
|
||||
}
|
||||
);
|
||||
|
||||
export const preferenceSlice = createSlice({
|
||||
name: "preference",
|
||||
initialState,
|
||||
reducers: {
|
||||
setThemeMode: (state: { themeMode: string; }, action: PayloadAction<ThemeMode>) => {
|
||||
state.themeMode = action.payload;
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(getUserReposAsync.pending, (state) => {
|
||||
state.status.getUserReposAsync = APIStatusType.LOADING;
|
||||
})
|
||||
.addCase(getUserReposAsync.fulfilled, (state, action) => {
|
||||
state.status.getUserReposAsync = APIStatusType.IDLE;
|
||||
state.userRepos = action.payload as Repo[];
|
||||
})
|
||||
.addCase(getUserReposAsync.rejected, (state) => {
|
||||
state.status.getUserReposAsync = APIStatusType.FAIL;
|
||||
state.userRepos = [];
|
||||
})
|
||||
|
||||
.addCase(autoSetupRepoAsync.pending, (state) => {
|
||||
state.status.autoSetupRepoAsync = APIStatusType.LOADING;
|
||||
})
|
||||
.addCase(autoSetupRepoAsync.fulfilled, (state) => {
|
||||
state.status.autoSetupRepoAsync = APIStatusType.IDLE;
|
||||
})
|
||||
.addCase(autoSetupRepoAsync.rejected, (state) => {
|
||||
state.status.autoSetupRepoAsync = APIStatusType.FAIL;
|
||||
})
|
||||
|
||||
.addCase(saveDefaultRepoAsync.pending, (state) => {
|
||||
state.status.saveDefaultRepoAsync = APIStatusType.LOADING;
|
||||
})
|
||||
.addCase(saveDefaultRepoAsync.fulfilled, (state) => {
|
||||
state.status.saveDefaultRepoAsync = APIStatusType.IDLE;
|
||||
})
|
||||
.addCase(saveDefaultRepoAsync.rejected, (state) => {
|
||||
state.status.saveDefaultRepoAsync = APIStatusType.FAIL;
|
||||
});
|
||||
},
|
||||
})
|
||||
|
||||
export const selectUserRepos = (state: RootState): Repo[] => state.preference.userRepos;
|
||||
export const selectPreferenceAPIStatus = (state: RootState): APIStatus => state.preference.status;
|
||||
export const selectThemeMode = (state: RootState): ThemeMode => state.preference.themeMode;
|
||||
export const { setThemeMode } = preferenceSlice.actions;
|
||||
export default preferenceSlice.reducer;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
|
||||
import { getUserProfile } from "../api/api";
|
||||
import { RootState } from "../app/store";
|
||||
import { APIStatusType } from "./common";
|
||||
|
||||
export interface User {
|
||||
email: string
|
||||
name: string
|
||||
location: string
|
||||
avatar_url: string
|
||||
default_repo?: {
|
||||
name: string,
|
||||
visibility: string,
|
||||
default_branch: string
|
||||
}
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
value: User | null
|
||||
status: APIStatusType
|
||||
}
|
||||
|
||||
const initialState: UserState = {
|
||||
value: null,
|
||||
status: APIStatusType.IDLE
|
||||
}
|
||||
|
||||
export const getUserProfileAsync = createAsyncThunk(
|
||||
'user/fetchUser',
|
||||
async () => {
|
||||
const response = await getUserProfile();
|
||||
// returned value becomes the `fulfilled` action payload
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const userSlice = createSlice({
|
||||
name: "user",
|
||||
initialState,
|
||||
reducers: {
|
||||
userLoading: (state) => {
|
||||
state.status = APIStatusType.LOADING;
|
||||
},
|
||||
userLogout: (state) => {
|
||||
state.value = null;
|
||||
localStorage.removeItem("token");
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(getUserProfileAsync.pending, (state) => {
|
||||
state.status = APIStatusType.LOADING;
|
||||
})
|
||||
.addCase(getUserProfileAsync.fulfilled, (state, action) => {
|
||||
state.status = APIStatusType.IDLE;
|
||||
state.value = action.payload as User;
|
||||
})
|
||||
.addCase(getUserProfileAsync.rejected, (state) => {
|
||||
state.status = APIStatusType.FAIL;
|
||||
state.value = null;
|
||||
localStorage.removeItem("token")
|
||||
});
|
||||
},
|
||||
})
|
||||
|
||||
export const { userLoading, userLogout } = userSlice.actions;
|
||||
export const selectUser = (state: RootState): User | null => state.user.value;
|
||||
export const selectUserAPIStatus = (state: RootState): APIStatusType => state.user.status;
|
||||
export default userSlice.reducer;
|
||||
@@ -0,0 +1,146 @@
|
||||
// This optional code is used to register a service worker.
|
||||
// register() is not called by default.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
// it offline capabilities. However, it also means that developers (and users)
|
||||
// will only see deployed updates on subsequent visits to a page, after all the
|
||||
// existing tabs open on the page have been closed, since previously cached
|
||||
// resources are updated in the background.
|
||||
|
||||
// To learn more about the benefits of this model and instructions on how to
|
||||
// opt-in, read https://bit.ly/CRA-PWA
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === '[::1]' ||
|
||||
// 127.0.0.0/8 are considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
)
|
||||
);
|
||||
|
||||
type Config = {
|
||||
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||
};
|
||||
|
||||
export function register(config?: Config) {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl, config);
|
||||
|
||||
// Add some additional logging to localhost, pointing developers to the
|
||||
// service worker/PWA documentation.
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://bit.ly/CRA-PWA'
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Is not localhost. Just register service worker
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl: string, config?: Config) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then((registration) => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
if (installingWorker == null) {
|
||||
return;
|
||||
}
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// At this point, the updated precached content has been fetched,
|
||||
// but the previous service worker will still serve the older
|
||||
// content until all client tabs are closed.
|
||||
console.log(
|
||||
'New content is available and will be used when all ' +
|
||||
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
|
||||
);
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onUpdate) {
|
||||
config.onUpdate(registration);
|
||||
}
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
console.log('Content is cached for offline use.');
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onSuccess) {
|
||||
config.onSuccess(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl, {
|
||||
headers: { 'Service-Worker': 'script' },
|
||||
})
|
||||
.then((response) => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (
|
||||
response.status === 404 ||
|
||||
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => {
|
||||
registration.unregister();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* This file does not support ES6 format */
|
||||
/* https://create-react-app.dev/docs/proxying-api-requests-in-development/ */
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
/* eslint-disable no-undef */
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
module.exports = function (app) {
|
||||
app.use(
|
||||
'/api',
|
||||
createProxyMiddleware({
|
||||
target: process.env.REACT_APP_PROXY_API_URL,
|
||||
logLevel: "debug",
|
||||
changeOrigin: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import React from "react";
|
||||
|
||||
|
||||
jest.mock("react-markdown", () => (props: { children: React.ReactNode }) =>
|
||||
jest.fn(() => <>{props.children}</>),
|
||||
);
|
||||
|
||||
jest.mock("remark-gfm", () =>
|
||||
jest.fn(() => {/* */ }),
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NoteResponsePayload, TreeNode } from "../reducer/noteSlice";
|
||||
|
||||
class TreeUtil {
|
||||
static parse(seedTree: TreeNode, notes: NoteResponsePayload[], cache?: boolean): TreeNode {
|
||||
const tree: TreeNode = notes.reduce((r, n) => {
|
||||
const pathArray = n.path.split('/');
|
||||
const fileName = pathArray.pop() || "";
|
||||
const final = pathArray.reduce((o, name) => {
|
||||
let temp = (o.children = o.children || []).find(q => q.name === name);
|
||||
if (!temp) o.children.push(temp = {
|
||||
name,
|
||||
path: o.path ? o.path + '/' + name : name,
|
||||
is_dir: true,
|
||||
cached: !!cache
|
||||
});
|
||||
cache != null && (temp.cached = cache);
|
||||
o.children.sort((a, b) => (Number(b.is_dir) - Number(a.is_dir)) || a.path.localeCompare(b.path))
|
||||
return temp;
|
||||
}, r);
|
||||
|
||||
const file = { ...n, name: fileName, cached: !!n.content }
|
||||
final.children = final.children || [];
|
||||
const index = final.children.findIndex(o => o.path === n.path);
|
||||
index > -1 && (final.children[index] = file) || final.children.push(file);
|
||||
final.children.sort((a, b) => (Number(b.is_dir) - Number(a.is_dir)) || a.path.localeCompare(b.path))
|
||||
cache != null && (final.cached = cache)
|
||||
return r;
|
||||
}, { ...seedTree });
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
static searchNode(root: TreeNode, path: string): TreeNode | null {
|
||||
if (root.path == path) {
|
||||
return root;
|
||||
}
|
||||
|
||||
if (root.children != null) {
|
||||
let result = null;
|
||||
for (let i = 0; result == null && i < root.children.length; i++) {
|
||||
result = TreeUtil.searchNode(root.children[i], path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static deleteNode(root: TreeNode, path: string) {
|
||||
if (!root.children) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < root.children.length; i++) {
|
||||
const child = root.children[i];
|
||||
if (child.path == path) {
|
||||
root.children.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
TreeUtil.deleteNode(child, path);
|
||||
if (child.is_dir && child.children?.length === 0) {
|
||||
// remove empty parent directories on delete
|
||||
root.children.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static getChildDirs(tree: TreeNode, path: string): string[] {
|
||||
const node = TreeUtil.searchNode(tree, path)
|
||||
if (!node?.children) {
|
||||
return [];
|
||||
}
|
||||
return node.children.filter(c => c.is_dir).map(c => c.name);
|
||||
}
|
||||
}
|
||||
|
||||
export default TreeUtil;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { SerializedError } from "@reduxjs/toolkit";
|
||||
import { ShowFn } from "mui-modal-provider/dist/types";
|
||||
import ConfirmDialog from "../components/ConfirmDialog";
|
||||
|
||||
export const URL_REPO = "https://github.com/batnoter/batnoter"
|
||||
export const URL_FAQ = `${URL_REPO}/wiki/FAQ`
|
||||
export const URL_ISSUES = `${URL_REPO}/issues`
|
||||
export const URL_TWITTER_HANDLE = "https://twitter.com/batnoter";
|
||||
export const URL_GITHUB = URL_REPO;
|
||||
export const URL_SPONSOR = "https://github.com/sponsors/vivekweb2013";
|
||||
|
||||
const REPLACE_EXT_REGEX = /(\.md)$/i;
|
||||
const EXT = '.md';
|
||||
const BACKEND_ERROR_CODES = ['internal_server_error', 'validation_failed'];
|
||||
const UNKNOWN_ERR_MSG = "Something went wrong. Please try again!"
|
||||
|
||||
export function getTitleFromFilename(filename: string): string {
|
||||
return filename.replace(REPLACE_EXT_REGEX, '');
|
||||
}
|
||||
|
||||
export function getFilenameFromTitle(title: string): string {
|
||||
return title + EXT;
|
||||
}
|
||||
|
||||
export function getPathWithoutExt(path: string): string {
|
||||
return path.replace(REPLACE_EXT_REGEX, '');
|
||||
}
|
||||
|
||||
export function getDecodedPath(path: string | null): string {
|
||||
if (path == null) {
|
||||
return "";
|
||||
}
|
||||
const decodedPath = decodeURIComponent(path || "");
|
||||
if (decodedPath === '/') {
|
||||
return "";
|
||||
}
|
||||
return decodedPath;
|
||||
}
|
||||
|
||||
export function appendPath(parentPath: string, path: string) {
|
||||
if (parentPath === "") {
|
||||
return path;
|
||||
}
|
||||
if (path === "") {
|
||||
return parentPath;
|
||||
}
|
||||
return parentPath + '/' + path;
|
||||
}
|
||||
|
||||
export function isFilePath(path: string): boolean {
|
||||
return path.endsWith(EXT);
|
||||
}
|
||||
|
||||
export function splitPath(path: string): string[] {
|
||||
// split the path and return the array ignoring any blank elements
|
||||
return path.split('/').filter(p => p);
|
||||
}
|
||||
|
||||
export function confirmDeleteNote(showModal: ShowFn, onConfirm: () => void) {
|
||||
showModal(ConfirmDialog, {
|
||||
desc: 'Are you sure you want to delete this note?',
|
||||
onConfirm: onConfirm
|
||||
});
|
||||
}
|
||||
|
||||
export function getSanitizedErrorMessage(error: SerializedError): string {
|
||||
// Validate error. Since we don't want to show programming errors to users.
|
||||
if (error.code != null && error.message != null && BACKEND_ERROR_CODES.includes(error.code)) {
|
||||
return error.message;
|
||||
}
|
||||
return UNKNOWN_ERR_MSG;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/src/setupTests.tsx"
|
||||
]
|
||||
}
|
||||