chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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();
|
||||
});
|
||||
+42
@@ -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
@@ -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>
|
||||
>;
|
||||
Binary file not shown.
|
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 |
Vendored
+1
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user