chore: import upstream snapshot with attribution
Deploy / deploy-production (push) Failing after 2s
Test / test (push) Failing after 1s
Build / build (push) Failing after 3m27s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:37 +08:00
commit f4ff771c12
66 changed files with 34894 additions and 0 deletions
+17
View File
@@ -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);
}
+15
View File
@@ -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();
});
+42
View File
@@ -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;
+135
View File
@@ -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());
}
})
}
+6
View File
@@ -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;
+21
View File
@@ -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>
>;
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+55
View File
@@ -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;
+115
View File
@@ -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;
+107
View File
@@ -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;
+24
View File
@@ -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;
+218
View File
@@ -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;
+80
View File
@@ -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;
+59
View File
@@ -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 &#127881;. 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;
+105
View File
@@ -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;
+40
View File
@@ -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;
+86
View File
@@ -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&apos;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;
+80
View File
@@ -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 &quot;{autoSetupRepoName}&quot; 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: &quot;{autoSetupRepoName}&quot; 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;
+33
View File
@@ -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;
+44
View File
@@ -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;
+98
View File
@@ -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;
+13
View File
@@ -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;
+11
View File
@@ -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;
}
+21
View File
@@ -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();
+1
View File
@@ -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

+1
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
+7
View File
@@ -0,0 +1,7 @@
export enum APIStatusType { LOADING, IDLE, FAIL }
export interface APIStatus {
[asyncName: string]: APIStatusType
}
export type ThemeMode = 'light' | 'dark' | 'system'
+227
View File
@@ -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;
+99
View File
@@ -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;
+69
View File
@@ -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;
+146
View File
@@ -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);
});
}
}
+17
View File
@@ -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,
})
);
};
+15
View File
@@ -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(() => {/* */ }),
);
+75
View File
@@ -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;
+72
View File
@@ -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;
}